[
  {
    "path": ".agent/rules/changelog.md",
    "content": "---\ntrigger: always_on\n---\n\n在完成一个任务后，应该去修改 CHANGELOG.md 以反应这个任务的更改。CHANGELOG 应该尽量简洁，一个任务只对应一条记录。\n"
  },
  {
    "path": ".agent/rules/measure-layout.md",
    "content": "---\ndescription: Best practice for measuring component layout in React Native\n---\n\n# Measuring Component Layout\n\nWhen you need to measure the current layout to apply changes to the overall layout or to make decisions based on precise coordinates (especially relative to the screen/page), use `measure` within `useLayoutEffect`.\n\nThis approach ensures you get the most recent values and can apply changes in the same frame, preventing UI flickering.\n\n## Recommended Pattern\n\n```tsx\nfunction AComponent({ children }) {\n\tconst targetRef = React.useRef(null)\n\n\tuseLayoutEffect(\n\t\t() => {\n\t\t\ttargetRef.current?.measure((x, y, width, height, pageX, pageY) => {\n\t\t\t\t// x, y: position relative to parent\n\t\t\t\t// width, height: dimensions\n\t\t\t\t// pageX, pageY: absolute position on screen\n\t\t\t\t// Do something with the measurements\n\t\t\t})\n\t\t},\n\t\t[\n\t\t\t/* dependencies */\n\t\t],\n\t)\n\n\treturn <View ref={targetRef}>{children}</View>\n}\n```\n\n## When to use `onLayout` vs `measure`\n\n- **Use `onLayout`**: When you only need the size (`width`, `height`) or position relative to the parent (`x`, `y`). It is simpler but passive.\n- **Use `measure`**: When you need absolute coordinates (`pageX`, `pageY`) or need to trigger logic synchronously after the view is ready to avoid visual jumps.\n"
  },
  {
    "path": ".agent/skills/gesture-handler-3-migration/SKILL.md",
    "content": "---\nname: gesture-handler-3-migration\ndescription: Migrates files containing React Native components which use the React Native Gesture Handler 2 API to Gesture Handler 3.\n---\n\n# Migrate to Gesture Handler 3\n\nThis skill scans React Native components that use the Gesture Handler builder-based API and updates them to use the new hook-based API. It also updates related types and components to adapt to the new version.\n\n## When to Use\n\n- Updating the usage of components imported from `react-native-gesture-handler`\n- Upgrading to Gesture Handler 3\n- Migrating to the new hook-based gesture API\n\n## Instructions\n\nUse the instructions below to correctly replace all legacy APIs with the modern ones.\n\n1. Identify all imports from 'react-native-gesture-handler'\n2. For each `Gesture.X()` call, replace with corresponding `useXGesture()` hook\n3. Replace `Gesture` import with imports for the used hooks\n4. Convert builder method chains to configuration objects\n5. Update callback names (onStart → onActivate, etc.)\n6. Replace composed gestures with relation hooks. Keep rules of hooks in mind\n7. Update GestureDetector usage if SVG is involved to Intercepting/Virtual GestureDetector\n8. Update usage of compoenent imported from 'react-native-gesture-handler' according to \"Legacy components\" section\n\n### Migrating gestures\n\nAll hook gestures have their counterparts in the builder API: `Gesture.X()` becomes `useXGesture(config)`. The methods are now config object fields with the same name as the relevant builder methods, unless specified otherwise.\n\nThe exception to thait is `esture.ForceTouch` which DOES NOT have a counterpart in the hook API.\n\n#### Callback changes\n\nIn Gesture Handler 3 some of the callbacks were renamed, namely:\n\n- `onStart` -> `onActivate`\n- `onEnd` -> `onDeactivate`\n- `onTouchesCancelled` -> `onTouchesCancel`\n\nIn the hooks API `onChange` is no longer available. Instead the `*change*` properties were moved to the event available inside `onUpdate`.\n\nAll callbacks of a gesture are now using the same type:\n\n- `usePanGesture()` -> `PanGestureEvent`\n- `useTapGesture()` -> `TapGestureEvent`\n- `useLongPressGesture()` -> `LongPressGestureEvent`\n- `useRotationGesture()` -> `RotationGestureEvent`\n- `usePinchGesture()` -> `PinchGestureEvent`\n- `useFlingGesture()` -> `FlingGestureEvent`\n- `useHoverGesture()` -> `HoverGestureEvent`\n- `useNativeGesture()` -> `RotationGestureEvent`\n- `useManualGesture()` -> `ManualGestureEvent`\n\nThe exception to this is touch events:\n\n- `onTouchesDown`\n- `onTouchesUp`\n- `onTouchesMove`\n- `onTouchesCancel`\n\nWhere each callback receives `GestureTouchEvent` regardless of the hook used.\n\n#### StateManager\n\nIn Gesture Handler 3, `stateManager` is no longer passed to `TouchEvent` callbacks. Instead, you should use the global `GestureStateManager`.\n\n`GestureStateManager` provides methods for imperative state management:\n\n- .begin(handlerTag: number)\n- .activate(handlerTag: number)\n- .deactivate(handlerTag: number) (.end() in the old API)\n- .fail(handlerTag: number)\n\n`handlerTag` can be obtained in two ways:\n\n1. From the gesture object returned by the hook (`gesture.handlerTag`)\n2. From the event inside callback (`event.handlerTag`)\n\nCallback definitions CANNOT reference the gesture that's being defined. In this scenario use events to get access to the handler tag.\n\n### Migrating relations\n\n#### Composed gestures\n\n`Gesture.Simultaneous(gesture1, gesture2);` becomes `useSimultaneousGestures(pan1, pan2);`\n\nAll relations from the old API and their counterparts in the new one:\n\n- `Gesture.Race()` -> `useCompetingGestures()`\n- `Gesture.Simultaneous()` -> `useSimultaneousGestures()`\n- `Gesture.Exclusive()` -> `useExclusiveGestures()`\n\n#### Cross components relations properties\n\nProperties used to define cross-components interactions were renamed:\n\n- `.simultaneousWithExternalGesture` -> `simultaneousWith:`\n- `.requireExternalGestureToFail` -> `requireToFail:`\n- `.blocksExternalGesture` -> `block:`\n\n### GestureDetector\n\nThe `GestureDetector` is a key component of `react-native-gesture-handler`. It supports gestures created either using the hooks API or the builder pattern (but those cannot be mixed, it's either or).\n\nUsing the same instance of a gesture across multiple Gesture Detectors may result in undefined behavior.\n\n### Integration with Reanimated\n\nWorklets' Babel plugin is setup in a way that automatically marks callbacks passed to gestures in the configuration chain as worklets. This means that you don't need to add a `'worklet';` directive at the beginning of the functions.\n\nThis will not be workletized because the callback is defined outside of the gesture object:\n\n```jsx\nconst callback = () => {\n\tconsole.log(_WORKLET)\n}\n\nconst gesture = useTapGesture({\n\tonBegin: callback,\n})\n```\n\nThe callback wrapped by any other higher order function will not be workletized:\n\n```jsx\nconst gesture = useTapGesture({\n\tonBegin: useCallback(() => {\n\t\tconsole.log(_WORKLET)\n\t}, []),\n})\n```\n\nIn the above cases, you should add a `\"worklet\";` directive as the first line of the callback.\n\n### Disabling Reanimated\n\nGestures created with the hook API have `Reanimated` integration enabled by default (if it's installed), meaning all callbacks are executed on the UI thread.\n\n#### runOnJS\n\nThe `runOnJS` property allows you to dynamically control whether callbacks are executed on the JS thread or the UI thread. When set to `true`, callbacks will run on the JS thread. Setting it to `false` will execute them on the UI thread. Default value is `false`.\n\n### Migrating components relying on view hierarchy\n\nCertain components, such as `SVG`, depend on the view hierarchy to function correctly. In Gesture Handler 3, `GestureDetector` disrupts these hierarchies. To resolve this issue, two new detectors have been introduced: `InterceptingGestureDetector` and `VirtualGestureDetector`.\n\n`InterceptingGestureDetector` functions similarly to the `GestureDetector`, but it can also act as a proxy for `VirtualGestureDetector` within its component subtree. Because it can be used solely to establish the context for virtual detectors, the `gesture` property is optional.\n\n`VirtualGestureDetector` is similar to the `GestureDetector` from RNGH2. Because it is not a host component, it does not interfere with the host view hierarchy. This allows you to attach gestures without disrupting functionality that depends on it.\n\n**Warning:** `VirtualGestureDetector` has to be a descendant of `InterceptingGestureDetector`.\n\n#### Migrating SVG\n\nIn Gesture Handler 2 it was possible to use `GestureDetector` directly on `SVG`. In Gesture Handler 3, the correct way to interact with `SVG` is to use `InterceptingGestureDetector` and `VirtualGestureDetector`.\n\n### Legacy components\n\nWhen the code using the component relies on the APIs that are no longer available on the components in Gesture Handler 3 (like `waitFor`, `simultaneousWith`, `blocksHandler`, `onHandlerStateChange`, `onGestureEvent` props), it cannot be easily migrated in isolation. In this case update the imports to the Legacy version of the component, and inform the user that the dependencies need to be migrated first.\n\nIf the migration is possible, use the ask questions tool to clarify the user intent unless clearly stated beforehand: should the components be using the new implementation (no `Legacy` prefix when imported), or should they revert to the old implementation (`Legacy` prefix when imported)?\n\nDon't suggest replacing buttons from Gesture Handler with components from React Native and vice versa.\n\nThe implementation of buttons has been updated, resolving most button-related issues. They have also been internally rewritten to utilize the new hook API. The legacy JS implementations of button components are still accessible but have been renamed with the prefix `Legacy`, e.g., `RectButton` is now available as `LegacyRectButton`. Those still use the new native component under the hood.\n\nOther components have also been internally rewritten using the new hook API but are exported under their original names, so no changes are necessary on your part. However, if you need to use the previous implementation for any reason, the legacy components are also available and are prefixed with `Legacy`, e.g., `ScrollView` is now available as `LegacyScrollView`.\n\n### Replaced types\n\nMost of the types used in the builder API, like `TapGesture`, are still present in Gesture Handler 3. However, they are now used in new hook API. Types for builder API now have `Legacy` prefix, e.g. `TapGesture` becomes `LegacyTapGesture`.\n"
  },
  {
    "path": ".agent/skills/upgrading-expo/SKILL.md",
    "content": "---\nname: upgrading-expo\ndescription: Guidelines for upgrading Expo SDK versions and fixing dependency issues\nversion: 1.0.0\nlicense: MIT\n---\n\n## References\n\n- ./references/new-architecture.md -- SDK +53: New Architecture migration guide\n- ./references/react-19.md -- SDK +54: React 19 changes (useContext → use, Context.Provider → Context, forwardRef removal)\n- ./references/react-compiler.md -- SDK +54: React Compiler setup and migration guide\n\n## Step-by-Step Upgrade Process\n\n1. Upgrade Expo and dependencies\n\n```bash\nnpx expo install expo@latest\nnpx expo install --fix\n```\n\n2. Run diagnostics: `npx expo-doctor`\n\n3. Clear caches and reinstall\n\n```bash\nnpx expo export -p ios --clear\nrm -rf node_modules .expo\nwatchman watch-del-all\n```\n\n## Breaking Changes Checklist\n\n- Check for removed APIs in release notes\n- Update import paths for moved modules\n- Review native module changes requiring prebuild\n- Test all camera, audio, and video features\n- Verify navigation still works correctly\n\n## Prebuild for Native Changes\n\nIf upgrading requires native changes:\n\n```bash\nnpx expo prebuild --clean\n```\n\nThis regenerates the `ios` and `android` directories. Ensure the project is not a bare workflow app before running this command.\n\n## Clear caches for bare workflow\n\n- Clear the cocoapods cache for iOS: `cd ios && pod install --repo-update`\n- Clear derived data for Xcode: `npx expo run:ios --no-build-cache`\n- Clear the Gradle cache for Android: `cd android && ./gradlew clean`\n\n## Housekeeping\n\n- Review release notes for the target SDK version at https://expo.dev/changelog\n- If using Expo SDK 54 or later, ensure react-native-worklets is installed — this is required for react-native-reanimated to work.\n- Enable React Compiler in SDK 54+ by adding `\"experiments\": { \"reactCompiler\": true }` to app.json — it's stable and recommended\n- Delete sdkVersion from `app.json` to let Expo manage it automatically\n- Remove implicit packages from `package.json`: `@babel/core`, `babel-preset-expo`, `expo-constants`.\n- If the babel.config.js only contains 'babel-preset-expo', delete the file\n- If the metro.config.js only contains expo defaults, delete the file\n\n## Deprecated Packages\n\n| Old Package          | Replacement                                          |\n| -------------------- | ---------------------------------------------------- |\n| `expo-av`            | `expo-audio` and `expo-video`                        |\n| `expo-permissions`   | Individual package permission APIs                   |\n| `@expo/vector-icons` | `expo-symbols` (for SF Symbols)                      |\n| `AsyncStorage`       | `expo-sqlite/localStorage/install`                   |\n| `expo-app-loading`   | `expo-splash-screen`                                 |\n| expo-linear-gradient | experimental_backgroundImage + CSS gradients in View |\n\n## Removing patches\n\nCheck if there are any outdated patches in the `patches/` directory. Remove them if they are no longer needed.\n\n## Postcss\n\n- `autoprefixer` isn't needed in SDK +53.\n- Use `postcss.config.mjs` in SDK +53.\n\n## Metro\n\nRemove redundant metro config options:\n\n- resolver.unstable_enablePackageExports is enabled by default in SDK +53.\n- `experimentalImportSupport` is enabled by default in SDK +54.\n- `EXPO_USE_FAST_RESOLVER=1` is removed in SDK +54.\n- cjs and mjs extensions are supported by default in SDK +50.\n- Expo webpack is deprecated, migrate to [Expo Router and Metro web](https://docs.expo.dev/router/migrate/from-expo-webpack/).\n\n## New Architecture\n\nThe new architecture is enabled by default, the app.json field `\"newArchEnabled\": true` is no longer needed as it's the default. Expo Go only supports the new architecture as of SDK +53.\n"
  },
  {
    "path": ".agent/skills/upgrading-expo/references/new-architecture.md",
    "content": "# New Architecture\n\nThe New Architecture is enabled by default in Expo SDK 53+. It replaces the legacy bridge with a faster, synchronous communication layer between JavaScript and native code.\n\n## Documentation\n\nFull guide: https://docs.expo.dev/guides/new-architecture/\n\n## What Changed\n\n- **JSI (JavaScript Interface)** — Direct synchronous calls between JS and native\n- **Fabric** — New rendering system with concurrent features\n- **TurboModules** — Lazy-loaded native modules with type safety\n\n## SDK Compatibility\n\n| SDK Version | New Architecture Status |\n| ----------- | ----------------------- |\n| SDK 53+     | Enabled by default      |\n| SDK 52      | Opt-in via app.json     |\n| SDK 51-     | Experimental            |\n\n## Configuration\n\nNew Architecture is enabled by default. To explicitly disable (not recommended):\n\n```json\n{\n\t\"expo\": {\n\t\t\"newArchEnabled\": false\n\t}\n}\n```\n\n## Expo Go\n\nExpo Go only supports the New Architecture as of SDK 53. Apps using the old architecture must use development builds.\n\n## Common Migration Issues\n\n### Native Module Compatibility\n\nSome older native modules may not support the New Architecture. Check:\n\n1. Module documentation for New Architecture support\n2. GitHub issues for compatibility discussions\n3. Consider alternatives if module is unmaintained\n\n### Reanimated\n\nReact Native Reanimated requires `react-native-worklets` in SDK 54+:\n\n```bash\nnpx expo install react-native-worklets\n```\n\n### Layout Animations\n\nSome layout animations behave differently. Test thoroughly after upgrading.\n\n## Verifying New Architecture\n\nCheck if New Architecture is active:\n\n```tsx\nimport { Platform } from 'react-native'\n\n// Returns true if Fabric is enabled\nconst isNewArch = global._IS_FABRIC !== undefined\n```\n\nVerify from the command line if the currently running app uses the New Architecture: `bunx xcobra expo eval \"_IS_FABRIC\"` -> `true`\n\n## Troubleshooting\n\n1. **Clear caches** — `npx expo start --clear`\n2. **Clean prebuild** — `npx expo prebuild --clean`\n3. **Check native modules** — Ensure all dependencies support New Architecture\n4. **Review console warnings** — Legacy modules log compatibility warnings\n"
  },
  {
    "path": ".agent/skills/upgrading-expo/references/react-19.md",
    "content": "# React 19\n\nReact 19 is included in Expo SDK 54. This release simplifies several common patterns.\n\n## Context Changes\n\n### useContext → use\n\nThe `use` hook replaces `useContext`:\n\n```tsx\n// Before (React 18)\nimport { useContext } from 'react'\nconst value = useContext(MyContext)\n\n// After (React 19)\nimport { use } from 'react'\nconst value = use(MyContext)\n```\n\n- The `use` hook can also read promises, enabling Suspense-based data fetching.\n- `use` can be called conditionally, this simplifies components that consume multiple contexts.\n\n### Context.Provider → Context\n\nContext providers no longer need the `.Provider` suffix:\n\n```tsx\n// Before (React 18)\n<ThemeContext.Provider value={theme}>\n  {children}\n</ThemeContext.Provider>\n\n// After (React 19)\n<ThemeContext value={theme}>\n  {children}\n</ThemeContext>\n```\n\n## ref as a Prop\n\n### Removing forwardRef\n\nComponents can now receive `ref` as a regular prop. `forwardRef` is no longer needed:\n\n```tsx\n// Before (React 18)\nimport { forwardRef } from 'react'\n\nconst Input = forwardRef<TextInput, Props>((props, ref) => {\n\treturn (\n\t\t<TextInput\n\t\t\tref={ref}\n\t\t\t{...props}\n\t\t/>\n\t)\n})\n\n// After (React 19)\nfunction Input({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {\n\treturn (\n\t\t<TextInput\n\t\t\tref={ref}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n```\n\n### Migration Steps\n\n1. Remove `forwardRef` wrapper\n2. Add `ref` to the props destructuring\n3. Update the type to include `ref?: React.Ref<T>`\n\n## Other React 19 Features\n\n- **Actions** — Functions that handle async transitions\n- **useOptimistic** — Optimistic UI updates\n- **useFormStatus** — Form submission state (web)\n- **Document Metadata** — Native `<title>` and `<meta>` support (web)\n\n## Cleanup Checklist\n\nWhen upgrading to SDK 54:\n\n- [ ] Replace `useContext` with `use`\n- [ ] Remove `.Provider` from Context components\n- [ ] Remove `forwardRef` wrappers, use `ref` prop instead\n"
  },
  {
    "path": ".agent/skills/upgrading-expo/references/react-compiler.md",
    "content": "# React Compiler\n\nReact Compiler is stable in Expo SDK 54 and later. It automatically memoizes components and hooks, eliminating the need for manual `useMemo`, `useCallback`, and `React.memo`.\n\n## Enabling React Compiler\n\nAdd to `app.json`:\n\n```json\n{\n\t\"expo\": {\n\t\t\"experiments\": {\n\t\t\t\"reactCompiler\": true\n\t\t}\n\t}\n}\n```\n\n## What React Compiler Does\n\n- Automatically memoizes components and values\n- Eliminates unnecessary re-renders\n- Removes the need for manual `useMemo` and `useCallback`\n- Works with existing code without modifications\n\n## Cleanup After Enabling\n\nOnce React Compiler is enabled, you can remove manual memoization:\n\n```tsx\n// Before (manual memoization)\nconst memoizedValue = useMemo(() => computeExpensive(a, b), [a, b])\nconst memoizedCallback = useCallback(() => doSomething(a), [a])\nconst MemoizedComponent = React.memo(MyComponent)\n\n// After (React Compiler handles it)\nconst value = computeExpensive(a, b)\nconst callback = () => doSomething(a)\n// Just use MyComponent directly\n```\n\n## Requirements\n\n- Expo SDK 54 or later\n- New Architecture enabled (default in SDK 54+)\n\n## Verifying It's Working\n\nReact Compiler runs at build time. Check the Metro bundler output for compilation messages. You can also use React DevTools to verify components are being optimized.\n\n## Troubleshooting\n\nIf you encounter issues:\n\n1. Ensure New Architecture is enabled\n2. Clear Metro cache: `npx expo start --clear`\n3. Check for incompatible patterns in your code (rare)\n\nReact Compiler is designed to work with idiomatic React code. If it can't safely optimize a component, it skips that component without breaking your app.\n"
  },
  {
    "path": ".agents/skills/react-doctor/SKILL.md",
    "content": "---\nname: react-doctor\ndescription: Diagnose and fix React codebase health issues. Use when reviewing React code, fixing performance problems, auditing security, or improving code quality.\nversion: 1.0.0\n---\n\n# React Doctor\n\nScans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.\n\n## Usage\n\n```bash\nnpx -y react-doctor@latest . --verbose\n```\n\n## Workflow\n\n1. Run the command above at the project root\n2. Read every diagnostic with file paths and line numbers\n3. Fix issues starting with errors (highest severity)\n4. Re-run to verify the score improved\n\n## Rules (47+)\n\n- **Security**: hardcoded secrets in client bundle, eval()\n- **State & Effects**: derived state in useEffect, missing cleanup, useState from props, cascading setState\n- **Architecture**: components inside components, giant components, inline render functions\n- **Performance**: layout property animations, transition-all, large blur values\n- **Correctness**: array index as key, conditional rendering bugs\n- **Next.js**: missing metadata, client-side fetching for server data, async client components\n- **Bundle Size**: barrel imports, full lodash, moment.js, missing code splitting\n- **Server**: missing auth in server actions, blocking without after()\n- **Accessibility**: missing prefers-reduced-motion\n- **Dead Code**: unused files, exports, types\n\n## Score\n\n- **75+**: Great\n- **50-74**: Needs work\n- **0-49**: Critical\n"
  },
  {
    "path": ".agents/skills/react-native-ease-refactor/SKILL.md",
    "content": "---\nname: react-native-ease-refactor\ndescription: Scan for Animated/Reanimated code and migrate to EaseView\nuser-invocable: true\n---\n\n# react-native-ease refactor\n\nYou are a migration assistant that converts `react-native-reanimated` and React Native's built-in `Animated` API code to `react-native-ease` `EaseView` components.\n\nFollow these 6 phases exactly. Do not skip phases or reorder them.\n\n---\n\n## Phase 1: Discovery\n\nScan the user's project for animation code:\n\n1. Use Grep to find all files importing from `react-native-reanimated`:\n   - Pattern: `from ['\"]react-native-reanimated['\"]`\n   - Search in `**/*.{ts,tsx,js,jsx}`\n\n2. Use Grep to find all files using React Native's built-in `Animated` API:\n   - Pattern: `from ['\"]react-native['\"]` that also use `Animated`\n   - Pattern: `Animated\\.View|Animated\\.Text|Animated\\.Image|Animated\\.Value|Animated\\.timing|Animated\\.spring`\n\n3. Use Grep to find files already using `react-native-ease` (to avoid re-migrating):\n   - Pattern: `from ['\"]react-native-ease['\"]`\n\n4. Read each file that contains animation code. Build a list of components with their animation patterns.\n\n**Exclude** from scanning:\n\n- `node_modules/`\n- `*.test.*` and `*.spec.*` files\n- Build output directories (`lib/`, `build/`, `dist/`)\n\n---\n\n## Phase 2: Classification\n\nFor each component found, classify as **migratable** or **not migratable**.\n\n### Decision Tree\n\nApply these checks in order. The first match determines the result:\n\n1. **Uses gesture APIs?** (`Gesture.Pan`, `Gesture.Pinch`, `Gesture.Rotation`, `useAnimatedGestureHandler`) → NOT migratable — \"Gesture-driven animation\"\n2. **Uses scroll handler?** (`useAnimatedScrollHandler`, `onScroll` with `Animated.event`) → NOT migratable — \"Scroll-driven animation\"\n3. **Uses shared element transitions?** (`sharedTransitionTag`) → NOT migratable — \"Shared element transition\"\n4. **Uses `runOnUI` or worklet directives?** → NOT migratable — \"Requires worklet runtime\"\n5. **Uses `withSequence` or `withDelay`?** → NOT migratable — \"Animation sequencing not supported\"\n6. **Uses complex `interpolate()`?** (more than 2 input/output values) → NOT migratable — \"Complex interpolation\"\n7. **Uses `layout={...}` prop?** → NOT migratable — \"Layout animation\"\n8. **Animates unsupported properties?** (anything besides: opacity, translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, borderRadius, backgroundColor) → NOT migratable — \"Animates unsupported property: `<prop>`\"\n9. **Uses different transition configs per property?** (e.g., opacity uses 200ms timing, scale uses spring) → NOT migratable — \"Per-property transition configs\"\n10. **Not driven by state?** (animation triggered by gesture/scroll value, not React state) → NOT migratable — \"Not state-driven\"\n11. **Otherwise** → MIGRATABLE\n\n### Migratable Pattern Mapping\n\nUse this table to convert Reanimated/Animated patterns to EaseView:\n\n| Reanimated / Animated Pattern                                                                                             | EaseView Equivalent                                                                                          |\n| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `useSharedValue` + `useAnimatedStyle` + `withTiming` for opacity, translate, scale, rotate, borderRadius, backgroundColor | `animate={{ prop: value }}` + `transition={{ type: 'timing', duration, easing }}`                            |\n| `withSpring`                                                                                                              | `transition={{ type: 'spring', damping, stiffness, mass }}`                                                  |\n| `entering={FadeIn}` / `FadeIn.duration(N)`                                                                                | `initialAnimate={{ opacity: 0 }}` + `animate={{ opacity: 1 }}` + timing transition                           |\n| `entering={FadeInDown}` / `FadeInUp`                                                                                      | `initialAnimate={{ opacity: 0, translateY: ±value }}` + `animate={{ opacity: 1, translateY: 0 }}`            |\n| `entering={SlideInLeft}` / `SlideInRight`                                                                                 | `initialAnimate={{ translateX: ±value }}` + `animate={{ translateX: 0 }}`                                    |\n| `entering={SlideInUp}` / `SlideInDown`                                                                                    | `initialAnimate={{ translateY: ±value }}` + `animate={{ translateY: 0 }}`                                    |\n| `entering={ZoomIn}`                                                                                                       | `initialAnimate={{ scale: 0 }}` + `animate={{ scale: 1 }}`                                                   |\n| `exiting={FadeOut}` / other exit animations                                                                               | State-driven exit: boolean state + `onTransitionEnd` to unmount (flag as \"requires state changes\" in report) |\n| `withRepeat(withTiming(...), -1, false)`                                                                                  | `transition={{ type: 'timing', ..., loop: 'repeat' }}` + `initialAnimate` for start value                    |\n| `withRepeat(withTiming(...), -1, true)`                                                                                   | `transition={{ type: 'timing', ..., loop: 'reverse' }}` + `initialAnimate` for start value                   |\n| `Easing.linear`                                                                                                           | `easing: 'linear'`                                                                                           |\n| `Easing.ease` / `Easing.inOut(Easing.ease)`                                                                               | `easing: 'easeInOut'`                                                                                        |\n| `Easing.in(Easing.ease)`                                                                                                  | `easing: 'easeIn'`                                                                                           |\n| `Easing.out(Easing.ease)`                                                                                                 | `easing: 'easeOut'`                                                                                          |\n| `Easing.bezier(x1, y1, x2, y2)`                                                                                           | `easing: [x1, y1, x2, y2]`                                                                                   |\n| `Animated.Value` + `Animated.timing`                                                                                      | Same `animate` + `transition` pattern — convert to state-driven                                              |\n| `Animated.Value` + `Animated.spring`                                                                                      | `animate` + `transition={{ type: 'spring' }}` — convert to state-driven                                      |\n\n### Default Value Mapping\n\n**CRITICAL: Reanimated and EaseView have different defaults. You MUST explicitly set values to preserve the original animation behavior. Do not rely on EaseView defaults matching Reanimated defaults.**\n\n#### `withSpring` → EaseView spring\n\n| Parameter   | Reanimated default | EaseView default | Action                        |\n| ----------- | ------------------ | ---------------- | ----------------------------- |\n| `damping`   | `10`               | `15`             | **Must set `damping: 10`**    |\n| `stiffness` | `100`              | `120`            | **Must set `stiffness: 100`** |\n| `mass`      | `1`                | `1`              | Same — omit                   |\n\nIf the source code explicitly sets any of these values, carry them over as-is. If the source relies on Reanimated defaults (no explicit value), set the Reanimated default explicitly on the EaseView transition.\n\nExample — bare `withSpring(1)` with no config:\n\n```typescript\n// Before (Reanimated)\nscale.value = withSpring(1);\n\n// After (EaseView) — must set damping: 10, stiffness: 100 to match\ntransition={{ type: 'spring', damping: 10, stiffness: 100 }}\n```\n\n**Note:** Reanimated v3+ uses duration-based spring by default (`duration: 550`, `dampingRatio: 1`) when no physics params are set. If migrating code that uses `withSpring` without any config, use `damping: 10, stiffness: 100` which matches the physics-based fallback. If the code explicitly sets `dampingRatio`/`duration`, convert using: `damping = dampingRatio * 2 * sqrt(stiffness * mass)`.\n\n#### `withTiming` → EaseView timing\n\n| Parameter  | Reanimated default          | EaseView default      | Action                                             |\n| ---------- | --------------------------- | --------------------- | -------------------------------------------------- |\n| `duration` | `300`                       | `300`                 | Same — omit                                        |\n| `easing`   | `Easing.inOut(Easing.quad)` | `'easeInOut'` (cubic) | **Must set `easing: [0.455, 0.03, 0.515, 0.955]`** |\n\nThe easing curves are different! Reanimated's default is quadratic ease-in-out, EaseView's is cubic. Always set the easing explicitly when the source doesn't specify one.\n\nExample — bare `withTiming(1)` with no config:\n\n```typescript\n// Before (Reanimated)\nopacity.value = withTiming(1);\n\n// After (EaseView) — must set quad easing to match\ntransition={{ type: 'timing', duration: 300, easing: [0.455, 0.03, 0.515, 0.955] }}\n```\n\nIf the source explicitly sets an easing, map it using the easing table above.\n\n#### `Animated.timing` (old RN API) → EaseView timing\n\n| Parameter  | RN Animated default         | EaseView default | Action                       |\n| ---------- | --------------------------- | ---------------- | ---------------------------- |\n| `duration` | `500`                       | `300`            | **Must set `duration: 500`** |\n| `easing`   | `Easing.inOut(Easing.ease)` | `'easeInOut'`    | Same curve — omit            |\n\n#### `Animated.spring` (old RN API) → EaseView spring\n\nRN Animated uses `friction`/`tension` by default: `friction: 7, tension: 40`. These map to: `stiffness = tension`, `damping = friction`.\n\n| Parameter           | RN Animated default | EaseView default | Action                       |\n| ------------------- | ------------------- | ---------------- | ---------------------------- |\n| stiffness (tension) | `40`                | `120`            | **Must set `stiffness: 40`** |\n| damping (friction)  | `7`                 | `15`             | **Must set `damping: 7`**    |\n| mass                | `1`                 | `1`              | Same — omit                  |\n\n### Unit Conversions\n\n- **Rotation:** Reanimated uses `'45deg'` strings in transforms → EaseView uses `45` (number, degrees). Strip the `'deg'` suffix and parse to number.\n- **Translation:** Both use DIPs (density-independent pixels). No conversion needed.\n- **Scale:** Both use unitless multipliers. No conversion needed.\n\n---\n\n## Phase 3: Dry-Run Report\n\n**ALWAYS print this report before asking the user to select components. This report must be visible to the user before Phase 4.**\n\nPrint a structured report. Do NOT apply any changes yet.\n\nFormat:\n\n```\n## Migration Report\n\n### Summary\n- Files scanned: X\n- Components with animations: Y\n- Migratable: Z  |  Not migratable: W\n\n### Migratable Components\n\n#### `path/to/file.tsx` — ComponentName\n**Current:** Brief description of what the animation does and which API it uses\n**Proposed:** What the EaseView equivalent looks like (include exact transition values with mapped defaults)\n**Changes:** What will be added/removed/modified\n**Note:** (only if applicable) \"Requires state changes for exit animation\" or other caveats\n\n### Not Migratable (will be skipped)\n\n#### `path/to/file.tsx` — ComponentName\n**Reason:** Why it can't be migrated (from decision tree)\n```\n\nThis report MUST be printed as text output in the conversation — not inside a plan, not collapsed. The user needs to read it before selecting components in Phase 4.\n\n---\n\n## Phase 4: User Confirmation\n\n**CRITICAL: You MUST use the `AskUserQuestion` tool here. Do NOT use plan mode, do NOT use text prompts, do NOT ask inline. Call the `AskUserQuestion` tool directly.**\n\nCall `AskUserQuestion` with these exact parameters:\n\n- `multiSelect`: `true`\n- `questions`: a single question object with:\n  - `header`: `\"Migrate\"`\n  - `question`: `\"Which components should be migrated to EaseView? All are selected — deselect any to skip.\"`\n  - `multiSelect`: `true`\n  - `options`: one entry per migratable component, each with:\n    - `label`: the component name (e.g., `\"AnimatedButton\"`)\n    - `description`: file path and brief animation description (e.g., `\"src/components/animated-button.tsx — spring scale on press\"`)\n\nExample tool call for 2 migratable components:\n\n```json\n{\n\t\"questions\": [\n\t\t{\n\t\t\t\"header\": \"Migrate\",\n\t\t\t\"question\": \"Which components should be migrated to EaseView? All are selected — deselect any to skip.\",\n\t\t\t\"multiSelect\": true,\n\t\t\t\"options\": [\n\t\t\t\t{\n\t\t\t\t\t\"label\": \"AnimatedButton\",\n\t\t\t\t\t\"description\": \"src/components/simple/animated-button.tsx — spring scale on press\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"label\": \"Collapsible\",\n\t\t\t\t\t\"description\": \"src/components/ui/collapsible.tsx — fade-in entering animation\"\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t]\n}\n```\n\n**Wait for the user's response before proceeding.** Do not enter plan mode. Do not apply any changes without the user selecting components.\n\nIf the user selects nothing or chooses \"Other\" to cancel, abort with: \"Migration aborted. No changes were made.\"\n\nOnly proceed to Phase 5 with the components the user confirmed.\n\n---\n\n## Phase 5: Apply Migrations\n\nFor each confirmed component, apply the migration:\n\n### Migration Steps (per component)\n\n1. **Add EaseView import** if not already present:\n\n   ```typescript\n   import { EaseView } from 'react-native-ease'\n   ```\n\n2. **Replace the animated view:**\n   - `Animated.View` → `EaseView`\n   - `<Animated.View style={[styles.box, animatedStyle]}>` → `<EaseView style={styles.box} animate={{ ... }} transition={{ ... }}>`\n\n3. **Convert animation hooks to props:**\n   - Remove `useSharedValue`, `useAnimatedStyle`, `withTiming`, `withSpring`, `withRepeat` calls\n   - Convert their values into `animate`, `initialAnimate`, and `transition` props\n\n4. **Convert entering/exiting animations:**\n   - `entering={FadeIn}` → `initialAnimate={{ opacity: 0 }}` on the EaseView + `animate={{ opacity: 1 }}`\n   - For `exiting`: introduce a state variable and `onTransitionEnd` callback:\n\n     ```typescript\n     const [visible, setVisible] = useState(true);\n     const [mounted, setMounted] = useState(true);\n\n     // When triggering exit:\n     setVisible(false);\n\n     // On the EaseView:\n     {\n       mounted && (\n         <EaseView\n           animate={{ opacity: visible ? 1 : 0 }}\n           transition={{ type: 'timing', duration: 300 }}\n           onTransitionEnd={({ finished }) => {\n             if (finished && !visible) setMounted(false);\n           }}\n         >\n           ...\n         </EaseView>\n       );\n     }\n     ```\n\n5. **Clean up imports:**\n   - Remove Reanimated imports that are no longer used in the file\n   - Keep any Reanimated imports still referenced by non-migrated code in the same file\n   - Never remove imports that are still used\n\n6. **Print progress:**\n   ```\n   [1/N] Migrated ComponentName in path/to/file.tsx\n   ```\n\n### Safety Rules\n\nThese rules are non-negotiable. Violating them corrupts user code.\n\n1. **When in doubt, skip.** If a pattern is ambiguous or you're not confident in the migration, add it to \"Not Migratable\" with reason: \"Complex pattern — manual review recommended\"\n2. **Never remove imports still used elsewhere in the file.** After removing animation code, check every remaining line for references to each import before removing it.\n3. **Preserve all non-animation logic.** Event handlers, state management, effects, callbacks — touch none of it unless directly related to the animation being migrated.\n4. **Preserve component structure and public API.** Props, ref forwarding, exported types — keep them identical.\n5. **Handle mixed files correctly.** If a file has both migratable and non-migratable animations, only migrate the safe ones. Keep Reanimated imports if any Reanimated code remains.\n6. **Map rotation units correctly.** Reanimated `'45deg'` string → EaseView `45` number. If the source uses radians, convert: `radians * (180 / Math.PI)`.\n7. **Map easing presets correctly.** See the mapping table in Phase 2.\n8. **Do not introduce TypeScript errors.** Ensure all types are correct after migration. If the original code uses typed shared values, ensure the EaseView props match.\n\n---\n\n## Phase 6: Final Report\n\nAfter all migrations are applied, print:\n\n```\n## Migration Complete\n\n### Changed (X components)\n- `path/to/file.tsx` — ComponentName: brief description of what was migrated\n\n### Unchanged (Y components)\n- `path/to/file.tsx` — ComponentName: reason skipped\n\n### Next Steps\n- Run your app and verify animations visually\n- Run your test suite to check for regressions\n- If no Reanimated code remains, consider removing `react-native-reanimated` from dependencies\n```\n\n---\n\n## EaseView API Reference (for migration accuracy)\n\n### Supported Animatable Properties\n\nAll properties in the `animate` prop:\n\n| Property          | Type         | Default         | Notes                                |\n| ----------------- | ------------ | --------------- | ------------------------------------ |\n| `opacity`         | `number`     | `1`             | 0–1 range                            |\n| `translateX`      | `number`     | `0`             | In DIPs (density-independent pixels) |\n| `translateY`      | `number`     | `0`             | In DIPs                              |\n| `scale`           | `number`     | `1`             | Shorthand for scaleX + scaleY        |\n| `scaleX`          | `number`     | `1`             | Overrides scale for X axis           |\n| `scaleY`          | `number`     | `1`             | Overrides scale for Y axis           |\n| `rotate`          | `number`     | `0`             | Z-axis rotation in degrees           |\n| `rotateX`         | `number`     | `0`             | X-axis rotation in degrees (3D)      |\n| `rotateY`         | `number`     | `0`             | Y-axis rotation in degrees (3D)      |\n| `borderRadius`    | `number`     | `0`             | In pixels                            |\n| `backgroundColor` | `ColorValue` | `'transparent'` | Any RN color value                   |\n\n### Transition Types\n\n**Timing:**\n\n```typescript\ntransition={{\n  type: 'timing',\n  duration: 300,        // ms, default 300\n  easing: 'easeInOut',  // 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | [x1,y1,x2,y2]\n  loop: 'repeat',       // 'repeat' | 'reverse' — requires initialAnimate\n}}\n```\n\n**Spring:**\n\n```typescript\ntransition={{\n  type: 'spring',\n  damping: 15,      // default 15\n  stiffness: 120,   // default 120\n  mass: 1,          // default 1\n}}\n```\n\n**None (instant):**\n\n```typescript\ntransition={{ type: 'none' }}\n```\n\n### Key Props\n\n- `animate` — target values for animated properties\n- `initialAnimate` — starting values (animates to `animate` on mount)\n- `transition` — animation config (timing or spring)\n- `onTransitionEnd` — callback with `{ finished: boolean }`\n- `transformOrigin` — pivot point as `{ x: 0-1, y: 0-1 }`, default center\n- `useHardwareLayer` — Android GPU optimization (boolean, default false)\n\n### Important Constraints\n\n- **Loop requires timing** (not spring) and `initialAnimate` must define the start value\n- **No per-property transitions** — one transition config applies to all animated properties\n- **No animation sequencing** — no equivalent to `withSequence`/`withDelay`\n- **No gesture/scroll-driven animations** — EaseView is state-driven only\n- **Style/animate conflict** — if a property appears in both `style` and `animate`, the animated value wins\n"
  },
  {
    "path": ".easignore",
    "content": "# .easignore - Overrides .gitignore for EAS builds\n\n# dependencies\nnode_modules/\n\n# Expo\n.expo/\ndist/\nweb-build/\nexpo-env.d.ts\n\n# Native - DO NOT IGNORE android/ or ios/ folders in packages/apps\n# We intentionally omit 'android/' and 'ios/' and 'apps/**/android' etc so they are INCLUDED.\n\n# Metro\n.metro-health-check*\n\n# debug\nnpm-debug.*\nyarn-debug.*\nyarn-error.*\n\n# macOS\n.DS_Store\n*.pem\n\n# local env files\n.env*.local\n\n# typescript\n*.tsbuildinfo\n\n# generated native folders - we want to KEEP these for local builds if they exist\n# /ios\n# /android\n# apps/**/android\n# apps/**/ios\n\ntemp-builds\n.yarn/install-state.gz\n**/.vscode/\n!/.vscode/\n!/.vscode/settings.json\n!/.vscode/extensions.json\n!/.vscode/tasks.json\n!/.vscode/launch.json\n\n.zed\n.idea\n\n# Secrets - Explicitly include the real ones\n!google-services.real.json\n!GoogleService-Info.real.plist\n!**/google-services.real.json\n!**/GoogleService-Info.real.plist\n\n# Ignore the templates/dummies if necessary, but usually safe to keep\n"
  },
  {
    "path": ".gitattributes",
    "content": "apps/mobile/src/lib/api/bilibili/proto/*.js linguist-generated\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: '🐞 Bug 报告'\ndescription: '提交 Bug 报告以帮助我们改进 BBPlayer'\ntitle: '[Bug] <title>'\nlabels: ['bug']\ntype: Bug\nbody:\n  - type: checkboxes\n    attributes:\n      label: '问题是否已存在？'\n      description: '在提交前，请确保您已经搜索过现有的 issues，确认问题尚未被报告。'\n      options:\n        - label: '我搜索过了'\n          required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: 'App 版本'\n      description: '当前你使用的 BBPlayer 版本号（建议优先升级最新版本尝试，我们不会对旧版本做 backport 修复）'\n      placeholder: '例如：v1.2.3'\n    validations:\n      required: true\n\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: '问题描述'\n      description: '请清晰地描述该 Bug。如果可以，请附上详细错误日志（可通过设置页面「打开 Debug 日志」按钮开启详细日志后重新操作复现问题，并通过「分享今日运行日志」按钮导出）或截图'\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps-to-reproduce\n    attributes:\n      label: '复现步骤'\n      description: '请提供重现该问题的具体步骤。如果问题较简单，上面「问题描述」中足够描述清楚，可选择不填'\n      placeholder: |\n        1. 前往 '...'\n        2. 点击 '....'\n        3. 滚动到 '....'\n    validations:\n      required: false\n\n  - type: textarea\n    id: expected-behavior\n    attributes:\n      label: '期望行为'\n      description: '请描述这里正确的行为应该是什么。'\n    validations:\n      required: true\n\n  - type: input\n    id: device-info\n    attributes:\n      label: '设备型号 + 操作系统（可选）'\n      placeholder: '例如: Xiaomi 10 + Android 14'\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: '🚀 功能请求'\ndescription: '为项目建议一个新功能或改进'\ntitle: '[Feature] <title>'\nlabels: ['enhancement']\ntype: Feature\nbody:\n  - type: checkboxes\n    attributes:\n      label: '功能请求是否已存在？'\n      description: '在提交前，请确保您已经搜索过现有的 issues，确认相关功能请求尚未被提出。'\n      options:\n        - label: '我搜索过了'\n          required: true\n\n  - type: textarea\n    id: details\n    attributes:\n      label: '建议内容'\n      description: '详细描述您想要的功能或改进，以及需要该功能的原因。如果可以，请附上截图或使用场景描述来帮助我们理解需求。'\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/release.yml",
    "content": "changelog:\n  categories:\n    - title: '🚀 New Features'\n      labels:\n        - 'feat'\n        - 'feature'\n    - title: '🐛 Bug Fixes'\n      labels:\n        - 'fix'\n        - 'bug'\n    - title: '📚 Documents'\n      labels:\n        - 'docs'\n        - 'documentation'\n    - title: 'Other Changes'\n      labels:\n        - '*'\n"
  },
  {
    "path": ".github/wiki/Home.md",
    "content": "# BBPlayer 开源项目 Wiki\n\n> [!TIP]\n> 如果您是最终用户，请优先访问 **[BBPlayer 官方文档站点](https://bbplayer.roitium.com)** 以获取安装及使用指南。\n\n---\n\n欢迎查阅 BBPlayer 相关的技术与开发文档。本项目采用 Monorepo 架构，各组件的文档分布如下：\n\n## 📚 快速导航\n\n### 🏁 [BBPlayer 移动端主程序 (App Home)](App-Home)\n\n包含项目架构、贡献指南、发版流程以及开发规范。\n\n### 🎸 [Orpheus 音频模块 (Orpheus Home)](orpheus-Home)\n\n包含核心音频播放器的 API 方法、事件说明及技术方案。\n"
  },
  {
    "path": ".github/wiki/_Sidebar.md",
    "content": "### [BBPlayer Wiki](Home)\n\n---\n\n#### [移动端应用 (App)](App-Home)\n\n- [贡献指南](CONTRIBUTING)\n- [架构设计](ARCHITECTURE)\n- [开发规范](BEST_PRACTICES)\n- [发版流程](RELEASE)\n\n#### [Orpheus 音频库](orpheus-Home)\n\n- [API 方法](orpheus-API-Methods)\n- [数据类型](orpheus-API-Types)\n- [事件说明](orpheus-API-Events)\n\n---\n\n- [官网](https://bbplayer.roitium.com)\n- [GitHub Repo](https://github.com/bbplayer-app/bbplayer)\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build and Release\n\non:\n  workflow_dispatch:\n    inputs:\n      buildType:\n        description: '构建类型'\n        required: true\n        default: 'prod'\n        type: choice\n        options:\n          - prod\n          - dev\n          - preview\n          - blank-test\n\n  pull_request:\n    types: [closed]\n    branches:\n      - master\n\nenv:\n  NODE_VERSION: 22.x\n  EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}\n  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n  MENTION_USER: '@roitium'\n\njobs:\n  setup:\n    if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true)\n    runs-on: ubuntu-latest\n    outputs:\n      matrix: ${{ steps.set-matrix.outputs.matrix }}\n      buildType: ${{ steps.set-vars.outputs.buildType }}\n      profile: ${{ steps.set-matrix.outputs.profile }}\n      commitSha: ${{ steps.set-vars.outputs.commitSha }}\n      prNumber: ${{ steps.set-vars.outputs.prNumber }}\n    steps:\n      - name: Determine Variables\n        id: set-vars\n        run: |\n          if [[ \"${{ github.event_name }}\" == \"pull_request\" ]]; then\n            echo \"buildType=prod\" >> $GITHUB_OUTPUT\n            echo \"commitSha=${{ github.event.pull_request.merge_commit_sha }}\" >> $GITHUB_OUTPUT\n            echo \"prNumber=${{ github.event.pull_request.number }}\" >> $GITHUB_OUTPUT\n          else\n            echo \"buildType=${{ github.event.inputs.buildType }}\" >> $GITHUB_OUTPUT\n            echo \"commitSha=${{ github.sha }}\" >> $GITHUB_OUTPUT\n            echo \"prNumber=\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Determine Matrix and Profile\n        id: set-matrix\n        run: |\n          BUILD_TYPE=\"${{ steps.set-vars.outputs.buildType }}\"\n\n          if [[ \"$BUILD_TYPE\" == \"prod\" ]]; then\n            echo \"matrix={\\\"include\\\":[{\\\"arch\\\":\\\"arm64-v8a\\\"},{\\\"arch\\\":\\\"armeabi-v7a\\\"},{\\\"arch\\\":\\\"x86_64\\\"},{\\\"arch\\\":\\\"x86\\\"}]}\" >> $GITHUB_OUTPUT\n            echo \"profile=prod-ci\" >> $GITHUB_OUTPUT\n          elif [[ \"$BUILD_TYPE\" == \"blank-test\" ]]; then\n             echo \"matrix={\\\"include\\\":[{\\\"arch\\\":\\\"arm64-v8a\\\"}]}\" >> $GITHUB_OUTPUT\n             echo \"profile=blank-test\" >> $GITHUB_OUTPUT\n          else\n            # dev, preview - default to arm64-v8a\n            echo \"matrix={\\\"include\\\":[{\\\"arch\\\":\\\"arm64-v8a\\\"}]}\" >> $GITHUB_OUTPUT\n            echo \"profile=$BUILD_TYPE\" >> $GITHUB_OUTPUT\n          fi\n\n  build:\n    needs: setup\n    runs-on: blacksmith-2vcpu-ubuntu-2404\n    strategy:\n      fail-fast: false\n      matrix: ${{ fromJson(needs.setup.outputs.matrix) }}\n    permissions:\n      contents: write\n      pull-requests: write\n      packages: read\n    env:\n      BUILD_TYPE: ${{ needs.setup.outputs.buildType }}\n      EAS_PROFILE: ${{ needs.setup.outputs.profile }}\n      ABI_FILTERS: ${{ matrix.arch }}\n      EAS_LOCAL_BUILD_SKIP_CLEANUP: 1\n\n    environment: ${{ (github.event_name == 'pull_request' && 'production') || null }}\n\n    steps:\n      - name: 🏗 Setup repo\n        uses: actions/checkout@v5\n        with:\n          ref: ${{ needs.setup.outputs.commitSha }}\n          fetch-depth: 0 # 获取完整历史记录以计算 commit 数量\n\n      - name: 🤖 Setup PNPM\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: pnpm\n\n      - name: 🏗 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ env.EXPO_TOKEN }}\n          packager: pnpm\n\n      - name: 📦 Install dependencies\n        run: pnpm install\n\n      - name: ⚙️ Prepare Variables\n        id: prepare-vars\n        run: |\n          APP_VERSION=$(node -p \"require('./apps/mobile/package.json').version\")\n          VERSION_CODE=$(git rev-list --count HEAD)\n\n          # e.g. bbplayer-v1.0.0-prod-arm64-v8a\n          APK_NAME=\"bbplayer-v${APP_VERSION}-${{ env.BUILD_TYPE }}-${{ matrix.arch }}\"\n          MAPPING_NAME=\"${APK_NAME}-mapping.txt\"\n          ARTIFACT_DIR=\"${RUNNER_TEMP}/${APK_NAME}\"\n\n          mkdir -p \"$ARTIFACT_DIR\"\n\n          echo \"APK_NAME=${APK_NAME}\" >> $GITHUB_ENV\n          echo \"MAPPING_NAME=${MAPPING_NAME}\" >> $GITHUB_ENV\n          echo \"ARTIFACT_DIR=${ARTIFACT_DIR}\" >> $GITHUB_ENV\n          echo \"APK_TEMP_PATH=${ARTIFACT_DIR}/${APK_NAME}.apk\" >> $GITHUB_ENV\n          echo \"MAPPING_TEMP_PATH=${ARTIFACT_DIR}/${MAPPING_NAME}\" >> $GITHUB_ENV\n          echo \"EAS_LOCAL_BUILD_WORKINGDIR=${RUNNER_TEMP}/eas-local-build-${{ matrix.arch }}\" >> $GITHUB_ENV\n          echo \"VERSION_CODE=${VERSION_CODE}\" >> $GITHUB_ENV\n\n          echo \"apkName=${APK_NAME}\" >> $GITHUB_OUTPUT\n          echo \"artifactDir=${ARTIFACT_DIR}\" >> $GITHUB_OUTPUT\n\n      - name: ⚙️ Prepare Firebase Config\n        env:\n          FIREBASE_ANDROID_JSON_B64: ${{ secrets.FIREBASE_ANDROID_JSON_B64 }}\n        if: ${{ env.FIREBASE_ANDROID_JSON_B64 != '' }}\n        run: |\n          mkdir -p apps/mobile/assets/config/google-services\n          echo \"$FIREBASE_ANDROID_JSON_B64\" | base64 -d > apps/mobile/assets/config/google-services/google-services.real.json\n\n      - name: 🚀 Build APK\n        if: env.BUILD_TYPE != 'blank-test'\n        run: cd apps/mobile && eas build --platform android --profile ${{ env.EAS_PROFILE }} --local --no-wait --output=\"$APK_TEMP_PATH\"\n\n      - name: 📦 Collect R8 Mapping\n        if: env.BUILD_TYPE != 'blank-test' && env.EAS_PROFILE != 'dev'\n        run: |\n          SEARCH_ROOTS=()\n\n          if [[ -d \"$EAS_LOCAL_BUILD_WORKINGDIR\" ]]; then\n            SEARCH_ROOTS+=(\"$EAS_LOCAL_BUILD_WORKINGDIR\")\n          fi\n\n          if [[ -d apps/mobile/android/app/build/outputs/mapping ]]; then\n            SEARCH_ROOTS+=(apps/mobile/android/app/build/outputs/mapping)\n          fi\n\n          if [[ ${#SEARCH_ROOTS[@]} -eq 0 ]]; then\n            echo \"No Android mapping output directories were found.\"\n            echo \"Checked EAS local build working directory: $EAS_LOCAL_BUILD_WORKINGDIR\"\n            exit 1\n          fi\n\n          MAPPING_SOURCE=$(find \"${SEARCH_ROOTS[@]}\" -path \"*/release/mapping.txt\" -type f -print -quit)\n\n          if [[ -z \"$MAPPING_SOURCE\" ]]; then\n            echo \"Expected R8 mapping file was not found.\"\n            printf 'Checked directories:\\n'\n            printf ' - %s\\n' \"${SEARCH_ROOTS[@]}\"\n            exit 1\n          fi\n\n          cp \"$MAPPING_SOURCE\" \"$MAPPING_TEMP_PATH\"\n\n      - name: 🧪 Create Dummy APK\n        if: env.BUILD_TYPE == 'blank-test'\n        run: |\n          echo \"Dummy APK $APK_NAME\" > \"$APK_TEMP_PATH\"\n\n      - name: 🚀 Upload Artifact\n        uses: actions/upload-artifact@v5\n        with:\n          name: ${{ steps.prepare-vars.outputs.apkName }}\n          path: ${{ steps.prepare-vars.outputs.artifactDir }}\n          if-no-files-found: error\n\n  release:\n    needs: [setup, build]\n    if: needs.setup.outputs.buildType == 'prod' || needs.setup.outputs.buildType == 'blank-test'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: 🏗 Setup repo\n        uses: actions/checkout@v5\n        with:\n          ref: ${{ needs.setup.outputs.commitSha }}\n\n      - name: 📥 Download Artifacts\n        uses: actions/download-artifact@v4\n        with:\n          path: dist\n          merge-multiple: true\n\n      - name: ⚙️ Prepare Release Variables\n        run: |\n          APP_VERSION=$(node -p \"require('./apps/mobile/package.json').version\")\n          RELEASE_TAG=\"v${APP_VERSION}\"\n          echo \"RELEASE_TAG=${RELEASE_TAG}\" >> $GITHUB_ENV\n          ls -R dist/\n\n      - name: 🎁 Create Release\n        run: |\n          mapfile -t FILES < <(find dist -type f \\( -name \"*.apk\" -o -name \"*-mapping.txt\" \\) | sort)\n\n          gh release create \"$RELEASE_TAG\" \\\n            \"${FILES[@]}\" \\\n            --title \"$RELEASE_TAG\" \\\n            --draft \\\n            --notes \"auto release by GitHub Actions\"\n\n  notify:\n    needs: [setup, build]\n    if: always() && github.event_name == 'pull_request'\n    permissions:\n      pull-requests: write\n    runs-on: ubuntu-latest\n    steps:\n      - name: 💬 Send build status notification\n        uses: actions/github-script@v8\n        with:\n          script: |\n            const buildResult = \"${{ needs.build.result }}\";\n            const buildType = \"${{ needs.setup.outputs.buildType }}\";\n            const commitSha = \"${{ needs.setup.outputs.commitSha }}\";\n            const prNumber = \"${{ needs.setup.outputs.prNumber }}\";\n            const runUrl = \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\";\n            const mention = \"${{ env.MENTION_USER }}\";\n\n            const icon = buildResult == 'success' ? '✅' : '❌';\n            const title = buildResult == 'success' ? `构建软件包成功` : `构建软件包失败`;\n\n            let body = `\n            ## ${icon} ${title} (${buildType})\n\n            ${mention}, 新版触发的构建已经完成\n\n            - **状态:** ${buildResult}\n            - **提交:**\n            ${commitSha.substring(0, 7)}\n            - **详细信息:** [Workflow](${runUrl})\n            `;\n\n            if (prNumber) {\n              body += `\\n- **触发事件:** PR #${prNumber}`;\n              try {\n                await github.rest.issues.createComment({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: prNumber,\n                  body: body\n                });\n              } catch (e) {\n                console.error(`Failed to comment on PR: ${e.message}.`);\n              }\n            }\n"
  },
  {
    "path": ".github/workflows/check-lyricon-updates.yml",
    "content": "name: Check Lyricon Updates\n\non:\n  schedule:\n    # 每天凌晨 2 点运行一次 (UTC 时间)\n    - cron: '0 2 * * *'\n  workflow_dispatch: # 允许手动触发\n    inputs:\n      force_update:\n        description: '强制更新（即使无变化也创建/更新 PR）'\n        required: false\n        default: 'false'\n        type: choice\n        options:\n          - 'true'\n          - 'false'\n\nenv:\n  ISSUE_TITLE: '🔄 Lyricon Provider 上游代码有更新'\n  ISSUE_LABELS: 'dependencies,lyricon'\n  PR_BRANCH: 'bot/lyricon-update'\n  PR_TITLE: '[Bot] Update Lyricon Provider'\n  LYRICON_REPO: 'tomakino/lyricon'\n\njobs:\n  check-updates:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      contents: write\n      pull-requests: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Fetch latest commit from Lyricon\n        id: fetch_commit\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          # 获取 tomakino/lyricon 仓库 master 分支的最新 commit hash\n          RESPONSE=$(curl -sL -H \"Authorization: Bearer $GITHUB_TOKEN\" \"https://api.github.com/repos/${{ env.LYRICON_REPO }}/commits/master\")\n          LATEST_COMMIT=$(echo \"$RESPONSE\" | jq -r '.sha // empty')\n\n          if [ -z \"$LATEST_COMMIT\" ] || [ \"$LATEST_COMMIT\" = \"null\" ]; then\n            echo \"Error: Could not fetch latest commit hash. Response was:\"\n            echo \"$RESPONSE\"\n            exit 1\n          fi\n\n          echo \"latest_commit=$LATEST_COMMIT\" >> $GITHUB_OUTPUT\n\n          # 获取我们当前记录的 commit (如果有的话)\n          CURRENT_COMMIT=\"unknown\"\n          VERSION_FILE=\"packages/orpheus/.lyricon_version\"\n          if [ -f \"$VERSION_FILE\" ]; then\n            CURRENT_COMMIT=$(cat \"$VERSION_FILE\")\n          fi\n          echo \"current_commit=$CURRENT_COMMIT\" >> $GITHUB_OUTPUT\n\n          echo \"Current: $CURRENT_COMMIT\"\n          echo \"Latest:  $LATEST_COMMIT\"\n\n      - name: Check if there are changes in provider or model directories\n        if: steps.fetch_commit.outputs.current_commit != steps.fetch_commit.outputs.latest_commit || github.event.inputs.force_update == 'true'\n        id: check_diff\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          # 比较我们记录的 commit 和最新的 commit 之间，provider 和 model 目录是否有变动\n          if [ \"${{ github.event.inputs.force_update }}\" = \"true\" ]; then\n            echo \"has_changes=true\" >> $GITHUB_OUTPUT\n            echo \"Force update enabled.\"\n            exit 0\n          fi\n\n          DIFF_URL=\"https://api.github.com/repos/${{ env.LYRICON_REPO }}/compare/${{ steps.fetch_commit.outputs.current_commit }}...${{ steps.fetch_commit.outputs.latest_commit }}\"\n\n          HAS_CHANGES=$(curl -sL -H \"Authorization: Bearer $GITHUB_TOKEN\" \"$DIFF_URL\" | jq -r '(.files // [])[] | select(.filename | test(\"lyric/bridge/provider/src/main/|lyric/model/src/main/\")) | .filename' | wc -l)\n\n          if [ \"$HAS_CHANGES\" -gt 0 ]; then\n            echo \"has_changes=true\" >> $GITHUB_OUTPUT\n            echo \"Found changes in relevant directories.\"\n          else\n            echo \"has_changes=false\" >> $GITHUB_OUTPUT\n            echo \"No changes in provider or model directories.\"\n          fi\n\n      - name: Find or create tracking issue\n        if: steps.check_diff.outputs.has_changes == 'true' || steps.fetch_commit.outputs.current_commit == 'unknown'\n        id: manage_issue\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issueTitle = '${{ env.ISSUE_TITLE }}';\n            const labels = '${{ env.ISSUE_LABELS }}'.split(',');\n\n            // 查找已有的 lyricon issue\n            const issues = await github.rest.issues.listForRepo({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              state: 'open',\n              labels: labels[1] // 'lyricon'\n            });\n\n            const existingIssue = issues.data.find(issue => \n              issue.title.includes('Lyricon') && issue.title.includes('更新')\n            );\n\n            if (existingIssue) {\n              console.log(`Found existing issue: #${existingIssue.number}`);\n              core.setOutput('issue_number', existingIssue.number);\n              core.setOutput('issue_exists', 'true');\n            } else {\n              console.log('No existing issue found');\n              core.setOutput('issue_exists', 'false');\n            }\n\n      - name: Update issue and add comment\n        if: steps.manage_issue.outputs.issue_exists == 'true' || steps.manage_issue.outputs.issue_exists == 'false'\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const latestCommit = '${{ steps.fetch_commit.outputs.latest_commit }}';\n            const currentCommit = '${{ steps.fetch_commit.outputs.current_commit }}';\n            const issueNumber = '${{ steps.manage_issue.outputs.issue_number }}';\n            const issueExists = '${{ steps.manage_issue.outputs.issue_exists }}' === 'true';\n            const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });\n\n            // 构建 issue body\n            let body = `## 📢 Lyricon Provider 上游代码更新跟踪\\n\\n`;\n            body += `**检测时间**: ${now}\\n\\n`;\n            body += `### 当前状态\\n`;\n            body += `- **当前版本**: \\`${currentCommit === 'unknown' ? '未知' : currentCommit.substring(0, 7)}\\`\\n`;\n            body += `- **最新版本**: \\`${latestCommit.substring(0, 7)}\\`\\n\\n`;\n\n            if (currentCommit !== 'unknown') {\n              body += `### 变更详情\\n`;\n              body += `[查看完整对比](https://github.com/${{ env.LYRICON_REPO }}/compare/${currentCommit}...${latestCommit})\\n\\n`;\n            }\n\n            body += `### 自动更新\\n`;\n            body += `🤖 已自动创建 Draft PR 进行代码同步，请查看下方的 PR 链接。\\n\\n`;\n\n            body += `### 手动更新\\n`;\n            body += `如需手动更新，请运行：\\n`;\n            body += \"\\`\\`\\`bash\\n\";\n            body += `./scripts/update-lyricon.sh ${latestCommit.substring(0, 7)}\\n`;\n            body += \"\\`\\`\\`\\n\\n\";\n\n            body += `---\\n`;\n            body += `*此 Issue 由 GitHub Actions 自动维护，会在代码同步完成后自动关闭。*`;\n\n            if (issueExists) {\n              // 更新现有 issue\n              await github.rest.issues.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: parseInt(issueNumber),\n                body: body\n              });\n              console.log(`Updated issue #${issueNumber}`);\n              \n              // 添加评论通知\n              let commentBody = `## 🔔 检测到新的更新\\n\\n`;\n              commentBody += `**时间**: ${now}\\n`;\n              commentBody += `**最新 Commit**: \\`${latestCommit.substring(0, 7)}\\`\\n\\n`;\n              commentBody += `Issue 描述已更新，请查看最新的变更详情。`;\n              \n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: parseInt(issueNumber),\n                body: commentBody\n              });\n              console.log(`Added comment to issue #${issueNumber}`);\n            } else {\n              // 创建新 issue\n              const newIssue = await github.rest.issues.create({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                title: '${{ env.ISSUE_TITLE }}',\n                body: body,\n                labels: '${{ env.ISSUE_LABELS }}'.split(',')\n              });\n              console.log(`Created new issue #${newIssue.data.number}`);\n              core.setOutput('issue_number', newIssue.data.number);\n            }\n\n      - name: Setup Git\n        if: steps.check_diff.outputs.has_changes == 'true' || steps.fetch_commit.outputs.current_commit == 'unknown'\n        run: |\n          git config --global user.name 'github-actions[bot]'\n          git config --global user.email 'github-actions[bot]@users.noreply.github.com'\n\n      - name: Check for existing PR branch\n        if: steps.check_diff.outputs.has_changes == 'true' || steps.fetch_commit.outputs.current_commit == 'unknown'\n        id: check_branch\n        run: |\n          BRANCH_NAME=\"${{ env.PR_BRANCH }}\"\n\n          # 检查远程分支是否存在\n          if git ls-remote --heads origin \"$BRANCH_NAME\" | grep -q \"$BRANCH_NAME\"; then\n            echo \"branch_exists=true\" >> $GITHUB_OUTPUT\n            echo \"Remote branch $BRANCH_NAME exists\"\n          else\n            echo \"branch_exists=false\" >> $GITHUB_OUTPUT\n            echo \"Remote branch $BRANCH_NAME does not exist\"\n          fi\n\n          echo \"branch_name=$BRANCH_NAME\" >> $GITHUB_OUTPUT\n\n      - name: Update existing branch\n        if: steps.check_branch.outputs.branch_exists == 'true'\n        run: |\n          BRANCH_NAME=\"${{ steps.check_branch.outputs.branch_name }}\"\n          LATEST_COMMIT=\"${{ steps.fetch_commit.outputs.latest_commit }}\"\n\n          # 获取远程分支\n          git fetch origin \"$BRANCH_NAME\"\n\n          # 检出分支\n          git checkout -B \"$BRANCH_NAME\" origin/\"$BRANCH_NAME\"\n\n          # 运行更新脚本\n          ./scripts/update-lyricon.sh \"$LATEST_COMMIT\"\n\n          # 提交更改\n          git add -A\n          if git diff --cached --quiet; then\n            echo \"No changes to commit\"\n          else\n            git commit -m \"chore(orpheus): update lyricon to ${LATEST_COMMIT:0:7}\"\n            git push origin \"$BRANCH_NAME\"\n            echo \"Updated branch $BRANCH_NAME\"\n          fi\n\n      - name: Create new branch\n        if: steps.check_branch.outputs.branch_exists == 'false'\n        run: |\n          BRANCH_NAME=\"${{ steps.check_branch.outputs.branch_name }}\"\n          LATEST_COMMIT=\"${{ steps.fetch_commit.outputs.latest_commit }}\"\n\n          # 创建并切换到新分支\n          git checkout -b \"$BRANCH_NAME\"\n\n          # 运行更新脚本\n          ./scripts/update-lyricon.sh \"$LATEST_COMMIT\"\n\n          # 提交更改\n          git add -A\n          git commit -m \"chore(orpheus): update lyricon to ${LATEST_COMMIT:0:7}\"\n          git push -u origin \"$BRANCH_NAME\"\n          echo \"Created and pushed branch $BRANCH_NAME\"\n\n      - name: Find or create Draft PR\n        if: steps.check_diff.outputs.has_changes == 'true' || steps.fetch_commit.outputs.current_commit == 'unknown'\n        id: manage_pr\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const latestCommit = '${{ steps.fetch_commit.outputs.latest_commit }}';\n            const currentCommit = '${{ steps.fetch_commit.outputs.current_commit }}';\n            const branchName = '${{ steps.check_branch.outputs.branch_name }}';\n            const issueNumber = '${{ steps.manage_issue.outputs.issue_number }}' || '${{ steps.manage_issue_issue_number }}';\n\n            // 查找已有的 PR\n            const prs = await github.rest.pulls.list({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              state: 'open',\n              head: `${context.repo.owner}:${branchName}`\n            });\n\n            const existingPR = prs.data[0];\n\n            // 构建 PR body\n            let body = `## 🤖 自动更新: Lyricon Provider\\n\\n`;\n            body += `此 PR 自动同步上游 [tomakino/lyricon](https://github.com/tomakino/lyricon) 的最新更改。\\n\\n`;\n            body += `### 更新详情\\n`;\n            body += `- **从**: \\`${currentCommit === 'unknown' ? '未知' : currentCommit.substring(0, 7)}\\`\\n`;\n            body += `- **到**: \\`${latestCommit.substring(0, 7)}\\`\\n\\n`;\n\n            if (currentCommit !== 'unknown') {\n              body += `### 变更摘要\\n`;\n              body += `[查看完整对比](https://github.com/${{ env.LYRICON_REPO }}/compare/${currentCommit}...${latestCommit})\\n\\n`;\n            }\n\n            body += `### 相关 Issue\\n`;\n            body += `Closes #${issueNumber}\\n\\n`;\n\n            body += `---\\n`;\n            body += `*此 PR 由 GitHub Actions 自动创建和维护。*`;\n\n            if (existingPR) {\n              // 更新现有 PR\n              await github.rest.pulls.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                pull_number: existingPR.number,\n                body: body\n              });\n              console.log(`Updated existing PR #${existingPR.number}`);\n              core.setOutput('pr_number', existingPR.number);\n              core.setOutput('pr_url', existingPR.html_url);\n            } else {\n              // 创建新 PR\n              const newPR = await github.rest.pulls.create({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                title: '${{ env.PR_TITLE }}',\n                head: branchName,\n                base: 'dev',\n                body: body,\n                draft: true\n              });\n              console.log(`Created new PR #${newPR.data.number}`);\n              core.setOutput('pr_number', newPR.data.number);\n              core.setOutput('pr_url', newPR.data.html_url);\n              \n              // 添加标签\n              await github.rest.issues.addLabels({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: newPR.data.number,\n                labels: ['dependencies', 'lyricon', 'automated']\n              });\n            }\n\n      - name: Summary\n        if: always() && (steps.check_diff.outputs.has_changes == 'true' || steps.fetch_commit.outputs.current_commit == 'unknown')\n        run: |\n          echo \"## 📋 工作流执行摘要\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"- **当前 Commit**: ${{ steps.fetch_commit.outputs.current_commit }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"- **最新 Commit**: ${{ steps.fetch_commit.outputs.latest_commit }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n\n          if [ \"${{ steps.manage_issue.outputs.issue_exists }}\" = \"true\" ]; then\n            echo \"- **Issue**: #${{ steps.manage_issue.outputs.issue_number }} (已更新)\" >> $GITHUB_STEP_SUMMARY\n          else\n            echo \"- **Issue**: #${{ steps.manage_issue.outputs.issue_number }} (新创建)\" >> $GITHUB_STEP_SUMMARY\n          fi\n\n          echo \"- **PR**: ${{ steps.manage_pr.outputs.pr_url }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"- **分支**: ${{ steps.check_branch.outputs.branch_name }}\" >> $GITHUB_STEP_SUMMARY\n"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "content": "name: Nightly Build\n\non:\n  # 支持 Actions 页面手动触发\n  workflow_dispatch:\n\n  # 支持 PR 评论触发\n  issue_comment:\n    types: [created]\n\nenv:\n  NODE_VERSION: 22.x\n  EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}\n  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n  NIGHTLY_TAG: nightly\n  ALLOWED_USER: roitium\n\njobs:\n  check-trigger:\n    permissions:\n      issues: write\n      contents: read\n      pull-requests: write\n    # 手动触发时直接通过，PR 评论触发时需要检查条件\n    if: |\n      github.event_name == 'workflow_dispatch' ||\n      (github.event_name == 'issue_comment' &&\n       github.event.issue.pull_request &&\n       github.event.comment.body == '/build-nightly')\n    runs-on: ubuntu-latest\n    outputs:\n      should_build: ${{ steps.check.outputs.should_build }}\n      pr_number: ${{ steps.context.outputs.pr_number }}\n      head_sha: ${{ steps.context.outputs.head_sha }}\n      trigger_type: ${{ steps.context.outputs.trigger_type }}\n    steps:\n      - name: 🔐 Check user permission\n        id: check\n        run: |\n          if [[ \"${{ github.event_name }}\" == \"workflow_dispatch\" ]]; then\n            # 手动触发，直接允许\n            echo \"should_build=true\" >> $GITHUB_OUTPUT\n            echo \"✅ Manual trigger by ${{ github.actor }}\"\n          elif [[ \"${{ github.event.comment.user.login }}\" == \"${{ env.ALLOWED_USER }}\" ]]; then\n            echo \"should_build=true\" >> $GITHUB_OUTPUT\n            echo \"✅ User ${{ github.event.comment.user.login }} is allowed to trigger nightly build\"\n          else\n            echo \"should_build=false\" >> $GITHUB_OUTPUT\n            echo \"❌ User ${{ github.event.comment.user.login }} is not allowed to trigger nightly build\"\n          fi\n\n      - name: 📝 React to comment\n        if: github.event_name == 'issue_comment' && steps.check.outputs.should_build == 'true'\n        uses: actions/github-script@v7\n        with:\n          script: |\n            await github.rest.reactions.createForIssueComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              comment_id: context.payload.comment.id,\n              content: 'rocket'\n            });\n\n      - name: 🔍 Determine build context\n        if: steps.check.outputs.should_build == 'true'\n        id: context\n        uses: actions/github-script@v7\n        with:\n          script: |\n            // 获取默认分支的最新 commit\n            const repo = await github.rest.repos.get({\n              owner: context.repo.owner,\n              repo: context.repo.repo\n            });\n            const defaultBranch = repo.data.default_branch;\n\n            const ref = await github.rest.git.getRef({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              ref: `heads/${defaultBranch}`\n            });\n\n            core.setOutput('head_sha', ref.data.object.sha);\n\n            if (context.eventName === 'workflow_dispatch') {\n              core.setOutput('pr_number', '');\n              core.setOutput('trigger_type', 'manual');\n            } else {\n              core.setOutput('pr_number', context.issue.number);\n              core.setOutput('trigger_type', 'pr_comment');\n            }\n\n  build:\n    needs: check-trigger\n    concurrency:\n      group: nightly-build\n      cancel-in-progress: true\n    if: needs.check-trigger.outputs.should_build == 'true'\n    runs-on: blacksmith-2vcpu-ubuntu-2404\n    permissions:\n      contents: write\n      pull-requests: write\n      packages: read\n    env:\n      ABI_FILTERS: arm64-v8a\n\n    steps:\n      - name: 🏗 Setup repo\n        uses: actions/checkout@v5\n        with:\n          ref: ${{ needs.check-trigger.outputs.head_sha }}\n          fetch-depth: 0 # 获取完整历史记录以计算 commit 数量\n\n      - name: 🤖 Setup PNPM\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: pnpm\n\n      - name: 🏗 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ env.EXPO_TOKEN }}\n          packager: pnpm\n\n      - name: 📦 Install dependencies\n        run: pnpm install\n\n      - name: ⚙️ Prepare Variables\n        run: |\n          APP_VERSION=$(node -p \"require('./apps/mobile/package.json').version\")\n          COMMIT_SHA=\"${{ needs.check-trigger.outputs.head_sha }}\"\n          SHORT_SHA=\"${COMMIT_SHA:0:7}\"\n          TIMESTAMP=$(date -u +\"%Y%m%d-%H%M%S\")\n          VERSION_CODE=$(git rev-list --count HEAD)\n\n          APK_NAME=\"bbplayer-nightly-${SHORT_SHA}\"\n\n          echo \"APP_VERSION=${APP_VERSION}\" >> $GITHUB_ENV\n          echo \"APK_NAME=${APK_NAME}\" >> $GITHUB_ENV\n          echo \"APK_PATH=${{ runner.temp }}/${APK_NAME}.apk\" >> $GITHUB_ENV\n          echo \"COMMIT_SHA=${COMMIT_SHA}\" >> $GITHUB_ENV\n          echo \"SHORT_SHA=${SHORT_SHA}\" >> $GITHUB_ENV\n          echo \"TIMESTAMP=${TIMESTAMP}\" >> $GITHUB_ENV\n          echo \"VERSION_CODE=${VERSION_CODE}\" >> $GITHUB_ENV\n\n          echo \"📊 Calculated VERSION_CODE: ${VERSION_CODE}\"\n\n      - name: 🚀 Build APK\n        run: cd apps/mobile && eas build --platform android --profile prod-v8a --local --no-wait --output=${{ env.APK_PATH }}\n        env:\n          VERSION_CODE: ${{ env.VERSION_CODE }}\n\n      - name: 📦 Upload Artifact\n        uses: actions/upload-artifact@v5\n        with:\n          name: ${{ env.APK_NAME }}\n          path: ${{ env.APK_PATH }}\n          if-no-files-found: error\n\n      - name: 🏷️ Update Nightly Release\n        run: |\n          RELEASE_NOTES=\"## 🌙 Nightly Build\n\n          **⚠️ 警告：这是自动构建的开发版本，可能不稳定**\n\n          ---\n\n          | 信息 | 值 |\n          |------|-----|\n          | 📝 Commit | [\\`${{ env.SHORT_SHA }}\\`](${{ github.server_url }}/${{ github.repository }}/commit/${{ env.COMMIT_SHA }}) |\n          | 🕐 构建时间 | ${{ env.TIMESTAMP }} UTC |\n          | 📦 架构 | arm64-v8a |\"\n\n          # 检查 nightly release 是否存在\n          if gh release view ${{ env.NIGHTLY_TAG }} > /dev/null 2>&1; then\n            echo \"Nightly release exists, updating...\"\n            \n            # 删除所有旧的 APK 附件\n            gh release view ${{ env.NIGHTLY_TAG }} --json assets -q '.assets[].name' | while read asset; do\n              if [[ \"$asset\" == *.apk ]]; then\n                echo \"Deleting old asset: $asset\"\n                gh release delete-asset ${{ env.NIGHTLY_TAG }} \"$asset\" --yes || true\n              fi\n            done\n            \n            # 上传新的 APK\n            gh release upload ${{ env.NIGHTLY_TAG }} \"${{ env.APK_PATH }}\" --clobber\n            \n            # 更新 release notes\n            gh release edit ${{ env.NIGHTLY_TAG }} \\\n              --title \"Nightly Build\" \\\n              --notes \"$RELEASE_NOTES\"\n          else\n            echo \"Creating new nightly release...\"\n            gh release create ${{ env.NIGHTLY_TAG }} \\\n              \"${{ env.APK_PATH }}\" \\\n              --title \"Nightly Build\" \\\n              --prerelease \\\n              --notes \"$RELEASE_NOTES\"\n          fi\n\n      - name: 🔄 Update Nightly Tag\n        run: |\n          # 强制更新 nightly tag 指向当前构建的 commit\n          git tag -f ${{ env.NIGHTLY_TAG }} ${{ env.COMMIT_SHA }}\n          git push -f origin ${{ env.NIGHTLY_TAG }}\n\n  notify:\n    needs: [check-trigger, build]\n    # 只在 PR 评论触发时发送通知\n    if: always() && needs.check-trigger.outputs.should_build == 'true' && needs.check-trigger.outputs.trigger_type == 'pr_comment'\n    runs-on: ubuntu-latest\n    permissions:\n      pull-requests: write\n    steps:\n      - name: 💬 Send build notification\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const buildResult = \"${{ needs.build.result }}\";\n            const prNumber = ${{ needs.check-trigger.outputs.pr_number }};\n            const headSha = \"${{ needs.check-trigger.outputs.head_sha }}\";\n            const shortSha = headSha.substring(0, 7);\n            const runUrl = \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\";\n            const releaseUrl = \"${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ env.NIGHTLY_TAG }}\";\n\n            let body;\n            if (buildResult === 'success') {\n              body = `## ✅ Nightly 构建成功\n\n            | 信息 | 值 |\n            |------|-----|\n            | 📝 Commit | \\`${shortSha}\\` |\n            | 📦 下载 | [Nightly Release](${releaseUrl}) |\n            | 🔗 详情 | [Workflow](${runUrl}) |\n\n            APK 已上传到 [Nightly Release](${releaseUrl}) 🚀`;\n            } else if (buildResult === 'cancelled') {\n              body = `## ⏹️ Nightly 构建已取消\n\n            构建被新的 \\`/build-nightly\\` 请求取消。\n\n            - **Commit:** \\`${shortSha}\\`\n            - **详情:** [Workflow](${runUrl})`;\n            } else {\n              body = `## ❌ Nightly 构建失败\n\n            - **Commit:** \\`${shortSha}\\`\n            - **详情:** [Workflow](${runUrl})\n\n            请查看 workflow 日志了解详情。`;\n            }\n\n            await github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: prNumber,\n              body: body\n            });\n"
  },
  {
    "path": ".github/workflows/pr-checks.yml",
    "content": "name: 'PR Checks'\n\non: pull_request\n\njobs:\n  lint:\n    name: 🚨 Lint Codebase\n    runs-on: ubuntu-latest\n    permissions:\n      packages: read\n    steps:\n      - name: 🏗 Setup repo\n        uses: actions/checkout@v5\n\n      - name: 🏗 Setup PNPM\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '22.x'\n          cache: 'pnpm'\n\n      - name: 📦 Install dependencies\n        run: pnpm install\n\n      - name: 🕵️ Check Dependencies\n        run: pnpm check:deps\n\n      - name: 🚀 Run Lint\n        run: pnpm lint\n"
  },
  {
    "path": ".github/workflows/update.yml",
    "content": "name: Hot Update\non: workflow_dispatch\njobs:\n  update:\n    runs-on: ubuntu-latest\n    permissions:\n      packages: read\n    steps:\n      - name: 🏗 Setup repo\n        uses: actions/checkout@v5\n\n      - name: 🤖 Setup PNPM\n        uses: pnpm/action-setup@v4\n\n      - name: 🏗 Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22.x\n          cache: pnpm\n\n      - name: 🏗 Setup EAS\n        uses: expo/expo-github-action@v8\n        with:\n          eas-version: latest\n          token: ${{ secrets.EXPO_TOKEN }}\n          packager: pnpm\n\n      - name: 📦 Install dependencies\n        run: pnpm install\n\n      - name: 🚀 Create update\n        run: cd apps/mobile && eas update --auto --platform=android\n\n      - name: 🚀 Submit source map\n        run: cd apps/mobile && SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} npx sentry-expo-upload-sourcemaps dist\n"
  },
  {
    "path": ".github/workflows/wiki.yml",
    "content": "name: Publish wiki\non:\n  push:\n    branches: [dev]\n    paths:\n      - apps/mobile/docs/**\n      - packages/orpheus/docs/**\n      - .github/wiki/**\n      - .github/workflows/wiki.yml\n\nconcurrency:\n  group: publish-wiki\n  cancel-in-progress: true\n\npermissions:\n  contents: write\n\njobs:\n  publish-wiki:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Prepare Wiki Content\n        run: |\n          mkdir temp_wiki\n          # 1. Copy main entry files from .github/wiki (Home.md, _Sidebar.md)\n          cp -r .github/wiki/* temp_wiki/\n          # 2. Move apps/mobile/docs/Home.md -> App-Home.md\n          cp apps/mobile/docs/Home.md temp_wiki/App-Home.md\n          # 3. Copy remaining mobile docs (excluding Home.md)\n          find apps/mobile/docs -maxdepth 1 -type f ! -name 'Home.md' -exec cp {} temp_wiki/ \\;\n          # 4. Copy orpheus docs with orpheus- prefix (flat structure for GitHub Wiki)\n          for file in packages/orpheus/docs/*.md; do\n            filename=$(basename \"$file\")\n            cp \"$file\" \"temp_wiki/orpheus-$filename\"\n          done\n      - uses: Andrew-Chen-Wang/github-wiki-action@v5\n        with:\n          path: temp_wiki\n"
  },
  {
    "path": ".gitignore",
    "content": "# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files\n\n# dependencies\nnode_modules/\n\n# Expo\n.expo/\ndist/\nweb-build/\nexpo-env.d.ts\n\n# Native\n.kotlin/\n*.orig.*\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n\n# Metro\n.metro-health-check*\n\n# debug\nnpm-debug.*\nyarn-debug.*\nyarn-error.*\n\n# macOS\n.DS_Store\n*.pem\n\n# local env files\n.env*.local\n\n# typescript\n*.tsbuildinfo\n\napp-example\n\n# generated native folders\n/ios\n/android\n\napp-example\ntemp-builds\n.yarn/install-state.gz\n**/.vscode/\n!/.vscode/\n!/.vscode/settings.json\n!/.vscode/extensions.json\n!/.vscode/tasks.json\n!/.vscode/launch.json\n\n.zed\n.idea\n\ngha-creds-*.json\n.env\napps/**/android\napps/**/ios\n# Desloppify\n.desloppify/\nexternal-playlist-experiment/\n"
  },
  {
    "path": ".gitleaks-baseline.json",
    "content": "[\n\t{\n\t\t\"RuleID\": \"generic-api-key\",\n\t\t\"Description\": \"Detected a Generic API Key, potentially exposing access to various services and sensitive operations.\",\n\t\t\"StartLine\": 9,\n\t\t\"EndLine\": 9,\n\t\t\"StartColumn\": 14,\n\t\t\"EndColumn\": 43,\n\t\t\"Match\": \"presetKey = '0CoJUm6Qyw8W8jud'\",\n\t\t\"Secret\": \"0CoJUm6Qyw8W8jud\",\n\t\t\"File\": \".gitleaks-baseline.json\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"c1f28d5888660087f55d3e7144a9190774d87e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/c1f28d5888660087f55d3e7144a9190774d87e9f/.gitleaks-baseline.json#L9\",\n\t\t\"Entropy\": 3.875,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+roitium@users.noreply.github.com\",\n\t\t\"Date\": \"2026-02-11T11:27:02Z\",\n\t\t\"Message\": \"chore(root): add gitleaks\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"c1f28d5888660087f55d3e7144a9190774d87e9f:.gitleaks-baseline.json:generic-api-key:9\"\n\t},\n\t{\n\t\t\"RuleID\": \"generic-api-key\",\n\t\t\"Description\": \"Detected a Generic API Key, potentially exposing access to various services and sensitive operations.\",\n\t\t\"StartLine\": 10,\n\t\t\"EndLine\": 10,\n\t\t\"StartColumn\": 5,\n\t\t\"EndColumn\": 31,\n\t\t\"Match\": \"Secret\\\": \\\"0CoJUm6Qyw8W8jud\\\"\",\n\t\t\"Secret\": \"0CoJUm6Qyw8W8jud\",\n\t\t\"File\": \".gitleaks-baseline.json\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"c1f28d5888660087f55d3e7144a9190774d87e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/c1f28d5888660087f55d3e7144a9190774d87e9f/.gitleaks-baseline.json#L10\",\n\t\t\"Entropy\": 3.875,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+roitium@users.noreply.github.com\",\n\t\t\"Date\": \"2026-02-11T11:27:02Z\",\n\t\t\"Message\": \"chore(root): add gitleaks\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"c1f28d5888660087f55d3e7144a9190774d87e9f:.gitleaks-baseline.json:generic-api-key:10\"\n\t},\n\t{\n\t\t\"RuleID\": \"generic-api-key\",\n\t\t\"Description\": \"Detected a Generic API Key, potentially exposing access to various services and sensitive operations.\",\n\t\t\"StartLine\": 30,\n\t\t\"EndLine\": 30,\n\t\t\"StartColumn\": 14,\n\t\t\"EndColumn\": 43,\n\t\t\"Match\": \"presetKey = '0CoJUm6Qyw8W8jud'\",\n\t\t\"Secret\": \"0CoJUm6Qyw8W8jud\",\n\t\t\"File\": \".gitleaks-baseline.json\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"c1f28d5888660087f55d3e7144a9190774d87e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/c1f28d5888660087f55d3e7144a9190774d87e9f/.gitleaks-baseline.json#L30\",\n\t\t\"Entropy\": 3.875,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+roitium@users.noreply.github.com\",\n\t\t\"Date\": \"2026-02-11T11:27:02Z\",\n\t\t\"Message\": \"chore(root): add gitleaks\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"c1f28d5888660087f55d3e7144a9190774d87e9f:.gitleaks-baseline.json:generic-api-key:30\"\n\t},\n\t{\n\t\t\"RuleID\": \"generic-api-key\",\n\t\t\"Description\": \"Detected a Generic API Key, potentially exposing access to various services and sensitive operations.\",\n\t\t\"StartLine\": 31,\n\t\t\"EndLine\": 31,\n\t\t\"StartColumn\": 5,\n\t\t\"EndColumn\": 31,\n\t\t\"Match\": \"Secret\\\": \\\"0CoJUm6Qyw8W8jud\\\"\",\n\t\t\"Secret\": \"0CoJUm6Qyw8W8jud\",\n\t\t\"File\": \".gitleaks-baseline.json\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"c1f28d5888660087f55d3e7144a9190774d87e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/c1f28d5888660087f55d3e7144a9190774d87e9f/.gitleaks-baseline.json#L31\",\n\t\t\"Entropy\": 3.875,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+roitium@users.noreply.github.com\",\n\t\t\"Date\": \"2026-02-11T11:27:02Z\",\n\t\t\"Message\": \"chore(root): add gitleaks\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"c1f28d5888660087f55d3e7144a9190774d87e9f:.gitleaks-baseline.json:generic-api-key:31\"\n\t},\n\t{\n\t\t\"RuleID\": \"sentry-org-token\",\n\t\t\"Description\": \"Found a Sentry.io Organization Token, risking unauthorized access to error tracking services and sensitive application data.\",\n\t\t\"StartLine\": 51,\n\t\t\"EndLine\": 51,\n\t\t\"StartColumn\": 14,\n\t\t\"EndColumn\": 205,\n\t\t\"Match\": \"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\\\"\",\n\t\t\"Secret\": \"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\\\"\",\n\t\t\"File\": \".gitleaks-baseline.json\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"c1f28d5888660087f55d3e7144a9190774d87e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/c1f28d5888660087f55d3e7144a9190774d87e9f/.gitleaks-baseline.json#L51\",\n\t\t\"Entropy\": 5.6347446,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+roitium@users.noreply.github.com\",\n\t\t\"Date\": \"2026-02-11T11:27:02Z\",\n\t\t\"Message\": \"chore(root): add gitleaks\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"c1f28d5888660087f55d3e7144a9190774d87e9f:.gitleaks-baseline.json:sentry-org-token:51\"\n\t},\n\t{\n\t\t\"RuleID\": \"sentry-org-token\",\n\t\t\"Description\": \"Found a Sentry.io Organization Token, risking unauthorized access to error tracking services and sensitive application data.\",\n\t\t\"StartLine\": 52,\n\t\t\"EndLine\": 52,\n\t\t\"StartColumn\": 15,\n\t\t\"EndColumn\": 206,\n\t\t\"Match\": \"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\\\"\",\n\t\t\"Secret\": \"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\\\"\",\n\t\t\"File\": \".gitleaks-baseline.json\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"c1f28d5888660087f55d3e7144a9190774d87e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/c1f28d5888660087f55d3e7144a9190774d87e9f/.gitleaks-baseline.json#L52\",\n\t\t\"Entropy\": 5.6347446,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+roitium@users.noreply.github.com\",\n\t\t\"Date\": \"2026-02-11T11:27:02Z\",\n\t\t\"Message\": \"chore(root): add gitleaks\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"c1f28d5888660087f55d3e7144a9190774d87e9f:.gitleaks-baseline.json:sentry-org-token:52\"\n\t},\n\t{\n\t\t\"RuleID\": \"generic-api-key\",\n\t\t\"Description\": \"Detected a Generic API Key, potentially exposing access to various services and sensitive operations.\",\n\t\t\"StartLine\": 7,\n\t\t\"EndLine\": 7,\n\t\t\"StartColumn\": 8,\n\t\t\"EndColumn\": 37,\n\t\t\"Match\": \"presetKey = '0CoJUm6Qyw8W8jud'\",\n\t\t\"Secret\": \"0CoJUm6Qyw8W8jud\",\n\t\t\"File\": \"lib/api/netease/crypto.ts\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"7dff4d67eb924169e4a7f187ec15d5d56f276cd0\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/7dff4d67eb924169e4a7f187ec15d5d56f276cd0/lib/api/netease/crypto.ts#L7\",\n\t\t\"Entropy\": 3.875,\n\t\t\"Author\": \"Roitium\",\n\t\t\"Email\": \"65794453+yanyao2333@users.noreply.github.com\",\n\t\t\"Date\": \"2025-09-14T14:41:02Z\",\n\t\t\"Message\": \"chore: new\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"7dff4d67eb924169e4a7f187ec15d5d56f276cd0:lib/api/netease/crypto.ts:generic-api-key:7\"\n\t},\n\t{\n\t\t\"RuleID\": \"generic-api-key\",\n\t\t\"Description\": \"Detected a Generic API Key, potentially exposing access to various services and sensitive operations.\",\n\t\t\"StartLine\": 6,\n\t\t\"EndLine\": 6,\n\t\t\"StartColumn\": 8,\n\t\t\"EndColumn\": 37,\n\t\t\"Match\": \"presetKey = '0CoJUm6Qyw8W8jud'\",\n\t\t\"Secret\": \"0CoJUm6Qyw8W8jud\",\n\t\t\"File\": \"lib/api/netease/netease.crypto.ts\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"f9bf820ff08f7b3ef85b1cdc831ec21de7b990ee\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/f9bf820ff08f7b3ef85b1cdc831ec21de7b990ee/lib/api/netease/netease.crypto.ts#L6\",\n\t\t\"Entropy\": 3.875,\n\t\t\"Author\": \"Roitium\",\n\t\t\"Email\": \"65794453+yanyao2333@users.noreply.github.com\",\n\t\t\"Date\": \"2025-07-10T12:00:51Z\",\n\t\t\"Message\": \"feat: implement netease apis\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"f9bf820ff08f7b3ef85b1cdc831ec21de7b990ee:lib/api/netease/netease.crypto.ts:generic-api-key:6\"\n\t},\n\t{\n\t\t\"RuleID\": \"sentry-org-token\",\n\t\t\"Description\": \"Found a Sentry.io Organization Token, risking unauthorized access to error tracking services and sensitive application data.\",\n\t\t\"StartLine\": 2,\n\t\t\"EndLine\": 2,\n\t\t\"StartColumn\": 13,\n\t\t\"EndColumn\": 203,\n\t\t\"Match\": \"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\",\n\t\t\"Secret\": \"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\",\n\t\t\"File\": \"android/sentry.properties\",\n\t\t\"SymlinkFile\": \"\",\n\t\t\"Commit\": \"45716e430e730db4284e2ced8812619e78362e9f\",\n\t\t\"Link\": \"https://github.com/bbplayer-app/BBPlayer/blob/45716e430e730db4284e2ced8812619e78362e9f/android/sentry.properties#L2\",\n\t\t\"Entropy\": 5.617,\n\t\t\"Author\": \"roitium\",\n\t\t\"Email\": \"65794453+yanyao2333@users.noreply.github.com\",\n\t\t\"Date\": \"2025-03-23T05:22:42Z\",\n\t\t\"Message\": \"feat: add sentry\",\n\t\t\"Tags\": [],\n\t\t\"Fingerprint\": \"45716e430e730db4284e2ced8812619e78362e9f:android/sentry.properties:sentry-org-token:2\"\n\t}\n]\n"
  },
  {
    "path": ".gitleaks.toml",
    "content": "# https://github.com/gitleaks/gitleaks\n\ntitle = \"BBPlayer Gitleaks Config\"\n\n[extend]\nuseDefault = true\n\n[allowlist]\npaths = ['''pnpm-lock\\.yaml''', '''\\.expo/''', '''node_modules/''']\n"
  },
  {
    "path": ".npmrc",
    "content": "node-linker=hoisted\nshamefully-hoist=true"
  },
  {
    "path": ".oxfmtrc.json",
    "content": "{\n\t\"$schema\": \"./node_modules/oxfmt/configuration_schema.json\",\n\t\"printWidth\": 80,\n\t\"tabWidth\": 2,\n\t\"useTabs\": true,\n\t\"semi\": false,\n\t\"singleQuote\": true,\n\t\"jsxSingleQuote\": true,\n\t\"quoteProps\": \"as-needed\",\n\t\"trailingComma\": \"all\",\n\t\"bracketSpacing\": true,\n\t\"bracketSameLine\": false,\n\t\"arrowParens\": \"always\",\n\t\"endOfLine\": \"lf\",\n\t\"singleAttributePerLine\": true,\n\t\"ignorePatterns\": [\"**/dm.d.ts\", \"**/dm.js\"],\n\t\"experimentalSortImports\": {\n\t\t\"groups\": [\n\t\t\t[\"side-effect\"],\n\t\t\t[\"builtin\"],\n\t\t\t[\"external\", \"type-external\"],\n\t\t\t[\"internal\", \"type-internal\"],\n\t\t\t[\"parent\", \"type-parent\"],\n\t\t\t[\"sibling\", \"type-sibling\"],\n\t\t\t[\"index\", \"type-index\"]\n\t\t]\n\t},\n\t\"experimentalSortPackageJson\": {\n\t\t\"sortScripts\": true\n\t}\n}\n"
  },
  {
    "path": ".oxlintrc.json",
    "content": "{\n\t\"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n\t\"plugins\": [\n\t\t\"react\",\n\t\t\"typescript\",\n\t\t\"unicorn\",\n\t\t\"eslint\",\n\t\t\"oxc\",\n\t\t\"import\",\n\t\t\"promise\"\n\t],\n\t\"categories\": {\n\t\t\"correctness\": \"error\",\n\t\t\"suspicious\": \"error\",\n\t\t\"pedantic\": \"allow\",\n\t\t\"perf\": \"error\",\n\t\t\"style\": \"allow\",\n\t\t\"restriction\": \"allow\"\n\t},\n\t\"env\": {\n\t\t\"builtin\": true,\n\t\t\"es2022\": true,\n\t\t\"browser\": true,\n\t\t\"node\": true\n\t},\n\t\"ignorePatterns\": [\n\t\t\"dist/*\",\n\t\t\"**/dm.d.ts\",\n\t\t\"**/dm.js\",\n\t\t\"**/dist/**\",\n\t\t\"**/build/**\",\n\t\t\"**/.expo/**\",\n\t\t\"**/node_modules/**\",\n\t\t\"**/*.config.mjs\",\n\t\t\"**/*.js\",\n\t\t\"packages/logs/**\",\n\t\t\"**/package-lock.json\",\n\t\t\"**/pnpm-lock.yaml\"\n\t],\n\t\"rules\": {\n\t\t\"react/react-in-jsx-scope\": \"off\",\n\t\t\"no-unused-vars\": [\n\t\t\t\"error\",\n\t\t\t{\n\t\t\t\t\"args\": \"all\",\n\t\t\t\t\"argsIgnorePattern\": \"^_\",\n\t\t\t\t\"caughtErrors\": \"all\",\n\t\t\t\t\"caughtErrorsIgnorePattern\": \"^_\",\n\t\t\t\t\"destructuredArrayIgnorePattern\": \"^_\",\n\t\t\t\t\"varsIgnorePattern\": \"^_\",\n\t\t\t\t\"ignoreRestSiblings\": true\n\t\t\t}\n\t\t],\n\t\t\"no-console\": \"error\",\n\t\t\"react-hooks/exhaustive-deps\": \"error\",\n\t\t\"typescript/no-explicit-any\": \"error\",\n\t\t\"typescript/no-misused-promises\": [\"error\", { \"checksVoidReturn\": false }],\n\t\t\"typescript/no-unsafe-type-assertion\": \"allow\",\n\n\t\t// tanstack query\n\t\t\"@tanstack/query/exhaustive-deps\": \"error\",\n\t\t\"@tanstack/query/no-rest-destructuring\": \"warn\",\n\t\t\"@tanstack/query/stable-query-client\": \"error\",\n\t\t\"@tanstack/query/no-unstable-deps\": \"error\",\n\t\t\"@tanstack/query/infinite-query-property-order\": \"error\",\n\t\t\"@tanstack/query/no-void-query-fn\": \"error\",\n\t\t\"@tanstack/query/mutation-property-order\": \"error\",\n\n\t\t// react-compiler\n\t\t\"react-compiler/react-compiler\": \"error\",\n\n\t\t// bbplayer\n\t\t\"bbplayer/no-navigate-after-modal-close\": \"error\",\n\n\t\t// react-hooks-extra\n\t\t\"react-hooks-extra/no-direct-set-state-in-use-effect\": \"off\",\n\t\t\"react-hooks-extra/no-unnecessary-use-prefix\": \"error\",\n\t\t\"react-hooks-extra/prefer-use-state-lazy-initialization\": \"error\",\n\n\t\t// react-you-might-not-need-an-effect\n\t\t\"react-you-might-not-need-an-effect/no-empty-effect\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-adjust-state-on-prop-change\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-event-handler\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-pass-live-state-to-parent\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-pass-data-to-parent\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-manage-parent\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-initialize-state\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-chain-state-updates\": \"warn\",\n\t\t\"react-you-might-not-need-an-effect/no-derived-state\": \"warn\",\n\n\t\t\"eslint/no-await-in-loop\": \"error\",\n\t\t\"always-return\": \"allow\",\n\t\t\"no-array-sort\": \"allow\",\n\t\t\"no-new-array\": \"allow\",\n\t\t\"style-prop-object\": \"allow\",\n\t\t\"no-map-spread\": \"allow\",\n\t\t\"no-await-in-loop\": \"allow\"\n\t},\n\t\"settings\": {\n\t\t\"react\": {\n\t\t\t\"version\": \"19.2\"\n\t\t}\n\t},\n\t\"jsPlugins\": [\n\t\t\"@tanstack/eslint-plugin-query\",\n\t\t\"eslint-plugin-react-compiler\",\n\t\t{\n\t\t\t\"name\": \"bbplayer\",\n\t\t\t\"specifier\": \"./packages/eslint-plugin/index.js\"\n\t\t},\n\t\t\"eslint-plugin-react-hooks-extra\",\n\t\t\"eslint-plugin-react-you-might-not-need-an-effect\",\n\t\t\"eslint-plugin-drizzle\"\n\t],\n\t\"overrides\": [\n\t\t{\n\t\t\t\"files\": [\"packages/**/*.{ts,tsx,js,jsx}\"],\n\t\t\t\"rules\": {\n\t\t\t\t\"no-console\": \"allow\"\n\t\t\t}\n\t\t}\n\t]\n}\n"
  },
  {
    "path": ".sisyphus/boulder.json",
    "content": "{\n\t\"active_plan\": \"/Users/roitium/Programming/BBPlayer/.sisyphus/plans/homepage-ui-optimization.md\",\n\t\"plan_name\": \"homepage-ui-optimization\",\n\t\"started_at\": \"2026-03-23T00:00:00Z\",\n\t\"session_ids\": [\n\t\t\"ses_2e794c59affeLc9Er6WH2C1FGz\",\n\t\t\"ses_2e786a794ffe6zYSJqfYTOh6X1\",\n\t\t\"ses_2e78165edffejlAxsULt0fkT9T\",\n\t\t\"ses_2e77df0c4ffehRpuJAk0c2LGhh\",\n\t\t\"ses_2e77e9fe3ffe528RLEFSla4Q4g\",\n\t\t\"ses_2e77f3502ffeLMMvR5KMKG80wj\",\n\t\t\"ses_2e779212affe78EvzRfLllbYMz\",\n\t\t\"ses_2e773bef5ffeU1jDErgl93geuk\",\n\t\t\"ses_2e77396feffe5QXjEvCfSwM4ZO\",\n\t\t\"ses_2e773e06cffeS7K5ms72ndT3IB\",\n\t\t\"ses_2e77361aaffeEBCCHzflUHH66A\",\n\t\t\"ses_2e76031bcffesUeRCcqrZhWb2N\",\n\t\t\"ses_2e7605973ffe5ycTn3CO4SEM7m\",\n\t\t\"ses_2e7607e12ffehyezB7ToU5hgc7\",\n\t\t\"ses_2e758df7effegxQkMPrjEZPoTE\",\n\t\t\"ses_2e7595408ffeMHYOHNw5cIBvMh\",\n\t\t\"ses_2e7516cdeffeKALmBfy03sdnZs\",\n\t\t\"ses_2e749caf6ffeeCobMXywN1CkZE\",\n\t\t\"ses_2e73c4bcbffeX5tFSiI4ZA0yJD\",\n\t\t\"ses_2e7358a25ffeHDE6rJFZCiigHI\",\n\t\t\"ses_2e72c6b0dffekOkaMp96FY5Z47\",\n\t\t\"ses_2e7265e80ffe5HG82dSobHPs0a\",\n\t\t\"ses_2e725f042ffeyvx7ZgcAegzvCA\",\n\t\t\"ses_2e721eeaeffeOI77BqOXCNZoEI\",\n\t\t\"ses_2e71ecbaeffeTsB8RHUZNBs6DF\",\n\t\t\"ses_2e71e680dffeojWSfiyGR1Kgd3\"\n\t],\n\t\"worktree_path\": null\n}\n"
  },
  {
    "path": ".sisyphus/evidence/task-1-complete.txt",
    "content": "Task 1 Complete: Add getMostPlayedTracksInLastDays method\n\n## Summary\nAdded `getMostPlayedTracksInLastDays` method to TrackService class in `apps/mobile/src/lib/services/trackService.ts`\n\n## Implementation Details\n- **Method signature**: `getMostPlayedTracksInLastDays(options: { days: number; limit: number }): ResultAsync<Array<{ track: Track; totalDuration: number }>, DatabaseError>`\n- **Returns**: Array of tracks with their total play duration, ordered by totalDuration DESC\n- **Timestamp handling**: Normalizes both ms and seconds timestamps using `CASE WHEN startTime > 10000000000 THEN startTime / 1000 ELSE startTime END`\n- **Filter**: Uses cutoff time calculated as `Date.now() - days * 24 * 60 * 60 * 1000`\n- **Subquery pattern**: Aggregates durationPlayed by trackId using `sum()`\n- **Joins**: tracks, artists, bilibiliMetadata, localMetadata\n- **Instrumentation**: Uses Sentry.startSpan\n- **Error handling**: Returns ResultAsync with DatabaseError\n\n## Pattern Followed\n- Exact pattern from `getPlayCountHistoryPaginated` for subqueries and joins\n- Timestamp normalization pattern from `playHistory.ts:66`\n- Consistent with existing TrackService method style\n\n## Notes\n- No pagination (limit only as specified)\n- No caching (as specified)\n- No new dependencies\n- Existing codebase has pre-existing type errors in node_modules (unrelated to this change)\n"
  },
  {
    "path": ".sisyphus/evidence/task-2-complete.txt",
    "content": "# Task 2 Complete - useMostPlayedTracks Query Hook\n\n## Summary\nAdded `useMostPlayedTracks` query hook to `apps/mobile/src/hooks/queries/playHistory.ts`\n\n## Changes Made\n\n### 1. Added import for trackService\n```typescript\nimport { trackService } from '@/lib/services/trackService'\n```\n\n### 2. Added query key factory entry\n```typescript\ntopPlayed: (days: number, limit: number) => [...playHistoryKeys.all, 'topPlayed', days, limit] as const,\n```\n\n### 3. Created useMostPlayedTracks hook\n```typescript\nexport const useMostPlayedTracks = (days: number, limit: number) => {\n\treturn useQuery({\n\t\tqueryKey: playHistoryKeys.topPlayed(days, limit),\n\t\tqueryFn: async () => {\n\t\t\tconst result = await trackService.getMostPlayedTracksInLastDays({ days, limit })\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tenabled: true,\n\t\tnetworkMode: 'always',\n\t\tstaleTime: 60 * 1000,\n\t})\n}\n```\n\n## Verification\n\n- [x] Query key factory entry added: `topPlayed: (days: number, limit: number) => [...]`\n- [x] New hook `useMostPlayedTracks(days: number, limit: number)` created\n- [x] Hook returns TanStack Query result with tracks and totalDuration\n- [x] Uses `enabled: true` and `staleTime: 60 * 1000`\n- [x] TypeScript check passes for playHistory.ts (no new errors)\n- [x] Uses Result unwrapping pattern\n- [x] Sets `networkMode: 'always'`\n\n## TypeScript Check\n```\ncd apps/mobile && pnpm tsc --noEmit\n# No errors in playHistory.ts (backend errors are pre-existing)\n```\n"
  },
  {
    "path": ".sisyphus/evidence/task-3-lint-output.txt",
    "content": "\n> @bbplayer/mobile@2.4.2 lint /Users/roitium/Programming/BBPlayer/apps/mobile\n> eslint .\n\n"
  },
  {
    "path": ".sisyphus/evidence/task-4-page-created.txt",
    "content": "Task 4: Create \"最近常听\" (Recently Played) Page - COMPLETED\n\nEvidence of Completion:\n============================\n\n1. PAGE FILE CREATED:\n   Location: apps/mobile/src/app/playlist/recently/index.tsx\n   Lines: 138\n\n2. VERIFICATION CHECKLIST:\n   ✅ Page shows \"最近常听\" title (line 84: Appbar.Content title='最近常听')\n   ✅ Shows subtitle \"最近14天最常播放的歌曲\" (line 101: subtitles='最近14天最常播放的歌曲')\n   ✅ Uses useMostPlayedTracks(14, 10) hook (line 45)\n   ✅ Displays tracks ordered by total play duration (data from service, pre-sorted)\n   ✅ Empty state shows \"暂无播放记录\" (line 90)\n   ✅ Uses PlaylistPageSkeleton for loading (line 68)\n   ✅ Uses PlaylistError text='加载失败' for errors (line 72)\n\n3. STRUCTURE COPIED FROM toview.tsx:\n   ✅ Appbar.Header with back button\n   ✅ PlaylistHeader with title and subtitle\n   ✅ TrackList for displaying tracks\n   ✅ NowPlayingBar at bottom\n   ✅ Background color handling via usePlaylistBackgroundColor\n\n4. ADAPTED FOR LOCAL DATA:\n   ✅ Uses useMostPlayedTracks(14, 10) instead of useGetToViewVideoList\n   ✅ Data already sorted by totalDuration from service\n   ✅ No progress display (not applicable)\n   ✅ Track.play() uses addToQueue with playNow: true\n\n5. MUST NOT DO - VERIFIED:\n   ✅ No filter/sort options added\n   ✅ No selection mode added\n   ✅ No custom item renderer (uses default TrackListItem)\n   ✅ No refresh control (local data, not remote)\n\n6. TYPE CHECK:\n   ✅ TypeScript compilation passed (pnpm tsc --noEmit)\n   No errors in the mobile app\n\n7. ROUTE ACCESSIBILITY:\n   Page accessible at: /playlist/recently\n   (Expo Router file-based routing)\n\nDependencies Used:\n- useMostPlayedTracks from @/hooks/queries/playHistory\n- PlaylistHeader from @/features/playlist/remote/components/PlaylistHeader\n- TrackList from @/features/playlist/remote/components/RemoteTrackList\n- PlaylistPageSkeleton from @/features/playlist/skeletons/PlaylistSkeleton\n- PlaylistError from @/features/playlist/local/components/PlaylistError\n- NowPlayingBar from @/components/NowPlayingBar\n- addToQueue from @/utils/player\n"
  },
  {
    "path": ".sisyphus/evidence/task-5-play-all.txt",
    "content": "# Task 5: Add \"Play All\" Button to history/[date].tsx\n\n## Status: COMPLETED\n\n## Changes Made\n\n### File: apps/mobile/src/app/history/[date].tsx\n\n1. **Added imports:**\n   - `Button` from `@/components/common/Button`\n   - `addToQueue` from `@/utils/player`\n   - `toast` from `@/utils/toast`\n\n2. **Added handlePlayAll callback (lines 102-121):**\n   - Extracts all tracks from `aggregatedTracks`\n   - Filters to playable tracks:\n     - Local tracks: always playable\n     - Bilibili tracks: only playable if `localMetadata` exists (cached)\n   - Shows toast error if no playable tracks\n   - Calls `addToQueue` with `playNow: true, clearQueue: true, playNext: false`\n\n3. **Added Play All button (lines 182-186):**\n   - Position: after `totalDurationSurface`, before `contentContainer`\n   - Uses `mode='contained'` and `icon='play'`\n   - Label: \"播放全部\"\n   - Only visible when `aggregatedTracks.length > 0`\n\n4. **Added style (lines 222-226):**\n   - `playAllContainer`: `marginHorizontal: 16, marginTop: 8, marginBottom: 8`\n\n## Verification\n\n- [x] TypeScript check passed for history/[date].tsx\n- [x] Button properly positioned between Surface and contentContainer\n- [x] Button hidden when no tracks (condition matches aggregatedTracks.length > 0)\n- [x] Offline-aware filtering for playable tracks\n- [x] Toast error shown when no playable tracks\n\n## Type Handling Note\n\nUsed `(track as unknown as { localMetadata?: unknown }).localMetadata` pattern because:\n- `Track` union type includes `BilibiliTrack` which doesn't have `localMetadata`\n- `LocalTrack` has `localMetadata`\n- Simple casting to `BilibiliTrack` wouldn't allow accessing `localMetadata`\n"
  },
  {
    "path": ".sisyphus/evidence/task-6-structure.txt",
    "content": "// ============================================================\n// QUICK ACCESS SECTION STRUCTURE (Task 6)\n// This structure will be integrated into homepage in Task 7\n// ============================================================\n\n// ============================================================\n// REQUIRED IMPORTS (add to existing imports)\n// ============================================================\n// Note: dayjs is already imported in index.tsx\n// Note: RectButton is already imported\n// Note: IconButton is already imported\n// Note: useAppStore is already imported\n\n// ============================================================\n// COMPONENT STRUCTURE (to be inserted in render)\n// ============================================================\n// Insert after the WeeklyHeatMap component, before recentPlaylistsSection\n\n{/* 快捷入口 */}\n<View style={styles.quickAccessSection}>\n\t<Text variant='titleMedium' style={styles.sectionTitle}>\n\t\t快捷入口\n\t</Text>\n\t<ScrollView\n\t\thorizontal\n\t\tshowsHorizontalScrollIndicator={false}\n\t\tsnapToInterval={156}  // 140 (card width) + 16 (gap)\n\t\tsnapToAlignment='start'\n\t\tdecelerationRate='fast'\n\t\tcontentContainerStyle={styles.quickAccessScrollContent}\n\t>\n\t\t{/* 那年今日 */}\n\t\t<RectButton\n\t\t\tkey=\"on-this-day\"\n\t\t\tstyle={[styles.quickAccessCard, { backgroundColor: colors.surfaceVariant }]}\n\t\t\tonPress={() => {\n\t\t\t\tconst lastYear = dayjs().subtract(1, 'year').format('YYYY-MM-DD')\n\t\t\t\trouter.push(`/history/${lastYear}`)\n\t\t\t}}\n\t\t>\n\t\t\t<IconButton icon='calendar-star' size={32} mode='contained-tonal' />\n\t\t\t<Text variant='labelMedium' style={styles.quickAccessText}>\n\t\t\t\t那年今日\n\t\t\t</Text>\n\t\t</RectButton>\n\n\t\t{/* 最近常听 */}\n\t\t<RectButton\n\t\t\tkey=\"recently-played\"\n\t\t\tstyle={[styles.quickAccessCard, { backgroundColor: colors.surfaceVariant }]}\n\t\t\tonPress={() => router.push('/playlist/recently')}\n\t\t>\n\t\t\t<IconButton icon='history' size={32} mode='contained-tonal' />\n\t\t\t<Text variant='labelMedium' style={styles.quickAccessText}>\n\t\t\t\t最近常听\n\t\t\t</Text>\n\t\t</RectButton>\n\n\t\t{/* 稍后再看 - conditional on Bilibili cookie */}\n\t\t{hasBilibiliCookie() && (\n\t\t\t<RectButton\n\t\t\t\tkey=\"watch-later\"\n\t\t\t\tstyle={[styles.quickAccessCard, { backgroundColor: colors.surfaceVariant }]}\n\t\t\t\tonPress={() => router.push('/playlist/remote/toview')}\n\t\t\t>\n\t\t\t\t<IconButton icon='clock-outline' size={32} mode='contained-tonal' />\n\t\t\t\t<Text variant='labelMedium' style={styles.quickAccessText}>\n\t\t\t\t\t稍后再看\n\t\t\t\t</Text>\n\t\t\t</RectButton>\n\t\t)}\n\t</ScrollView>\n</View>\n\n// ============================================================\n// STYLES (add to StyleSheet.create)\n// ============================================================\nquickAccessSection: {\n\tmarginBottom: 32,\n},\nquickAccessCard: {\n\twidth: 140,\n\tborderRadius: 12,\n\toverflow: 'hidden',\n\tpaddingVertical: 16,\n\tpaddingHorizontal: 12,\n\talignItems: 'center',\n\tjustifyContent: 'center',\n\tgap: 8,\n},\nquickAccessText: {\n\tfontWeight: '600',\n},\nquickAccessScrollContent: {\n\tpaddingHorizontal: 16,\n\tgap: 16,\n},\n\n// ============================================================\n// ADDITIONAL IMPORT NEEDED\n// ============================================================\n// Add ScrollView to react-native imports:\n// import { ..., ScrollView, ... } from 'react-native'\n\n// ============================================================\n// PLACEMENT NOTES\n// ============================================================\n// 1. Insert the Quick Access section AFTER WeeklyHeatMap\n// 2. Insert BEFORE the recentPlaylistsSection check\n// 3. The section title uses existing sectionTitle style\n// 4. Uses existing colors.surfaceVariant for card background\n// 5. Uses existing IconButton component (already imported)\n// 6. Uses existing hasBilibiliCookie from useAppStore (already imported)\n// 7. Uses existing dayjs (already imported)\n// 8. Uses existing router from expo-router (already imported)\n\n// ============================================================\n// SNAP BEHAVIOR EXPLANATION\n// ============================================================\n// snapToInterval: 156 = 140 (card width) + 16 (gap)\n// This ensures cards snap to the left edge when scrolling stops\n// decelerationRate='fast' provides snappy scrolling feel\n\n// ============================================================\n// CARD DETAILS\n// ============================================================\n// Card 1: 那年今日 (On This Day)\n//   - Icon: calendar-star\n//   - Navigates to: /history/[YYYY-MM-DD] (last year's date)\n//   - Always visible\n//\n// Card 2: 最近常听 (Recently Played)\n//   - Icon: history\n//   - Navigates to: /playlist/recently\n//   - Always visible\n//\n// Card 3: 稍后再看 (Watch Later)\n//   - Icon: clock-outline\n//   - Navigates to: /playlist/remote/toview\n//   - Only visible when hasBilibiliCookie() returns true"
  },
  {
    "path": ".sisyphus/evidence/task-7-complete.txt",
    "content": "# Task 7 Complete: Replace \"近期歌单\" with \"快捷入口\"\n\n## Date: 2026-03-23\n\n## Changes Made\n\n### 1. Deleted recentPlaylists query (lines 99-111)\n- Removed useLiveQuery for fetching recent playlists from database\n- Removed associated schema imports that were only used for this query\n\n### 2. Deleted recentPlaylistsSection block (lines 393-451)\n- Removed entire \"近期歌单\" section with horizontal scrolling playlist cards\n- Removed associated JSX structure\n\n### 3. Added ScrollView import\n- Added `ScrollView` to react-native imports\n\n### 4. Inserted Quick Access section after WeeklyHeatMap\n- Added \"快捷入口\" section with 3 cards:\n  - 那年今日 (On This Day) - navigates to history for last year's date\n  - 最近常听 (Recently Played) - navigates to /playlist/recently\n  - 稍后再看 (Watch Later) - conditional on hasBilibiliCookie()\n- Uses snap scrolling with 156px intervals (140px card + 16px gap)\n\n### 5. Added Quick Access styles\n- quickAccessSection: marginBottom 32\n- quickAccessCard: 140px width, centered content, 12px border radius\n- quickAccessText: fontWeight 600\n- quickAccessScrollContent: horizontal padding 16, gap 16\n\n### 6. Cleaned up dead styles\n- Removed unused: recentPlaylistsSection, horizontalScrollContent, playlistCard, playlistCover, playlistInfo, playlistTitle\n\n## Verification Results\n\n✅ Lint passed: No ESLint errors\n✅ TypeScript passed: No type errors in mobile app\n✅ Quick Access section renders after WeeklyHeatMap\n✅ All 3 cards navigate correctly\n✅ No \"近期歌单\" section visible\n✅ No dead code remains\n\n## Navigation Paths Verified\n- /history/[YYYY-MM-DD] - On This Day card\n- /playlist/recently - Recently Played card  \n- /playlist/remote/toview - Watch Later card (conditional)\n\n## Files Modified\n- apps/mobile/src/app/(tabs)/index.tsx\n\n## Evidence\nThis file serves as completion evidence for Task 7.\n"
  },
  {
    "path": ".sisyphus/notepads/task-5-play-all/learnings.md",
    "content": "# Task 5: Play All Button - Learnings\n\n## Patterns Discovered\n\n### Type Union Handling in Filter\n\nWhen filtering a union type (`Track = BilibiliTrack | LocalTrack`) where only one variant has a certain property:\n\n```typescript\n// This fails: Property doesn't exist on BilibiliTrack\n;(track) => track.source === 'local' || !!track.localMetadata\n\n// This works: Cast through unknown\n;(track) =>\n\ttrack.source === 'local' ||\n\t!!(track as unknown as { localMetadata?: unknown }).localMetadata\n```\n\n### Button Component Pattern\n\nBBPlayer uses a custom Button component at `@/components/common/Button`:\n\n- Props: `mode`, `icon`, `onPress`, children\n- Modes: 'text', 'outlined', 'contained', 'elevated', 'contained-tonal'\n\n### Toast Usage\n\nImport from `@/utils/toast` (wraps sonner-native):\n\n```typescript\nimport toast from '@/utils/toast'\ntoast.error('message')\n```\n\n### addToQueue Pattern\n\nFrom `@/utils/player`:\n\n```typescript\nawait addToQueue({\n\ttracks: playableTracks,\n\tplayNow: true,\n\tclearQueue: true,\n\tplayNext: false,\n})\n```\n"
  },
  {
    "path": ".sisyphus/plans/homepage-ui-optimization.md",
    "content": "# Homepage UI Optimization\n\n## TL;DR\n\n> **Quick Summary**: Optimize homepage by removing quick action buttons, creating a \"Recently Played\" page, replacing \"Recent Playlists\" section with a \"Quick Access\" horizontal snap-scroll section, and adding a \"Play All\" button to the history/date page.\n>\n> **Deliverables**:\n>\n> - Remove 4 quick action buttons from homepage\n> - New \"最近常听\" (Recently Played) page showing weighted play history\n> - New \"快捷入口\" (Quick Access) section with horizontal snap-scroll cards\n> - \"Play All\" button on history/[date] page (offline-aware)\n>\n> **Estimated Effort**: Medium\n> **Parallel Execution**: YES - 3 waves\n> **Critical Path**: Task 4 (Data Layer) → Task 5 (Recently Page) → Task 7 (Homepage Section)\n\n---\n\n## Context\n\n### Original Request\n\n优化主页UI，达到更好的效果：\n\n1. 删除四个操作按钮（本地音乐、稍后再看、我的收藏、最近播放）\n2. 创建新页面「最近常听」，按播放时长加权统计最近14天最常听的歌\n3. 删除「近期歌单」栏目，改成「快捷入口」，包含三个卡片：那年今日、最近常听、稍后再看（有cookie时显示）\n4. 历史记录页加「播放全部」按钮，离线时只播放已缓存歌曲\n\n### Interview Summary\n\n**Key Discussions**:\n\n- **播放统计规则**: 按播放时长加权计算（durationPlayed字段求和）\n- **播放全部行为**: 从第一首开始顺序播放\n- **离线处理**: 只播放已缓存的歌曲，跳过未缓存的\n- **卡片内容**: 3个卡片 - 那年今日、最近常听、稍后再看（条件显示）\n\n**Research Findings**:\n\n- 快捷按钮位于 `index.tsx:408-469` (quickActionsContainer)\n- 近期歌单位于 `index.tsx:471-529` (recentPlaylistsSection)\n- 历史页结构已分析，Play All 按钮位置确定\n- 无现有snap滚动模式，推荐使用 ScrollView + snapToInterval\n\n### Metis Review\n\n**Identified Gaps** (addressed):\n\n- Gap: 需要确认播放统计规则 → **已确认**: 按播放时长加权\n- Gap: 需要确认离线处理方式 → **已确认**: 只播放已缓存歌曲\n- Gap: 需要确认卡片数量和内容 → **已确认**: 3个卡片，内容确定\n\n---\n\n## Work Objectives\n\n### Core Objective\n\n优化首页用户体验，提供更直接的访问路径和更便捷的播放操作。\n\n### Concrete Deliverables\n\n- `apps/mobile/src/app/(tabs)/index.tsx` - 删除快捷按钮和近期歌单，添加快捷入口\n- `apps/mobile/src/app/playlist/recently/index.tsx` - 新建最近常听页面\n- `apps/mobile/src/app/history/[date].tsx` - 添加播放全部按钮\n- `apps/mobile/src/lib/services/trackService.ts` - 新增查询方法\n- `apps/mobile/src/hooks/queries/playHistory.ts` - 新增查询hook\n\n### Definition of Done\n\n- [ ] 主页无快捷按钮和近期歌单\n- [ ] 快捷入口显示3个卡片，横向滚动有吸附效果\n- [ ] 最近常听页面显示正确数据，按播放时长排序\n- [ ] 历史页有播放全部按钮，离线时只播放缓存歌曲\n\n### Must Have\n\n- 按播放时长加权排序（durationPlayed求和）\n- 只统计最近14天数据\n- 最多显示10首歌曲\n- 快捷入口横向滚动有吸附效果\n- 播放全部按钮在离线时自动过滤未缓存歌曲\n\n### Must NOT Have (Guardrails from Metis)\n\n- 不得添加超过3个卡片\n- 不得添加复杂动画（仅使用基础snap）\n- 不得为卡片创建新组件（使用inline RectButton模式）\n- 不得添加shuffle功能\n- 不得添加分页功能\n\n---\n\n## Verification Strategy (MANDATORY)\n\n### Test Decision\n\n- **Infrastructure exists**: YES (Jest + @testing-library/react-native)\n- **Automated tests**: YES (TDD for service/hook, component tests for UI)\n- **Framework**: Jest\n- **Agent-Executed QA**: ALWAYS (Playwright for browser UI, Bash for API/CLI)\n\n### QA Policy\n\nEvery task MUST include agent-executed QA scenarios.\n\n---\n\n## Execution Strategy\n\n### Parallel Execution Waves\n\n```\nWave 1 (Foundation - Data Layer):\n├── Task 1: Add getMostPlayedTracks service method [deep]\n├── Task 2: Add useMostPlayedTracks query hook [quick]\n└── Task 3: Remove quick action buttons from homepage [quick]\n\nWave 2 (Feature Pages):\n├── Task 4: Create Recently Played page [artistry]\n├── Task 5: Add Play All button to history page [quick]\n└── Task 6: Create Quick Access section component [visual-engineering]\n\nWave 3 (Integration):\n└── Task 7: Replace 近期歌单 with 快捷入口 section [artistry]\n\nCritical Path: Task 1 → Task 2 → Task 4 → Task 7\nParallel Speedup: ~50% faster than sequential\n```\n\n### Dependency Matrix\n\n| Task | Depends On | Blocks |\n| ---- | ---------- | ------ |\n| 1    | -          | 2, 4   |\n| 2    | 1          | 4      |\n| 3    | -          | 7      |\n| 4    | 1, 2       | -      |\n| 5    | -          | -      |\n| 6    | -          | 7      |\n| 7    | 3, 6       | -      |\n\n---\n\n## TODOs\n\n### Wave 1: Foundation (Data Layer)\n\n- [x] 1. Add getMostPlayedTracks service method to trackService\n\n  **What to do**:\n  - Open `apps/mobile/src/lib/services/trackService.ts`\n  - Add new method `getMostPlayedTracksInLastDays(options: { days: number; limit: number })`\n  - Query logic:\n    1. Calculate cutoff time: `Date.now() - days * 24 * 60 * 60 * 1000` (convert to Unix seconds)\n    2. Filter playHistory where `startTime >= cutoff` (handle both ms and seconds timestamps)\n    3. Group by `trackId`, sum `durationPlayed`\n    4. Order by totalDuration DESC\n    5. Limit to N tracks\n    6. Join with tracks and artists tables to get full track info\n  - Return type: `Promise<Array<{ track: Track; totalDuration: number }>>`\n  - Follow existing pattern from `getPlayCountHistoryPaginated`\n\n  **Must NOT do**:\n  - Do NOT add caching beyond what TanStack Query provides\n  - Do NOT create a new service file\n  - Do NOT add pagination (limit is sufficient)\n\n  **Recommended Agent Profile**:\n  - **Category**: `deep`\n  - **Skills**: []\n  - Reason: Database query with complex aggregation, needs careful thought\n\n  **Parallelization**:\n  - **Can Run In Parallel**: YES (with Task 3)\n  - **Parallel Group**: Wave 1\n  - **Blocks**: Task 2, Task 4\n\n  **References**:\n  - `apps/mobile/src/lib/services/trackService.ts:400-500` - `getPlayCountHistoryPaginated` method for query pattern\n  - `apps/mobile/src/lib/db/schema.ts:79-97` - playHistory table schema\n  - `apps/mobile/src/hooks/queries/playHistory.ts:56-99` - `usePlayHistoryByDate` for timestamp handling\n\n  **Acceptance Criteria**:\n  - [ ] Method exists on TrackService class\n  - [ ] Returns tracks ordered by totalDuration DESC\n  - [ ] Respects days and limit parameters\n  - [ ] Handles both ms and seconds timestamps correctly\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Query returns correct tracks ordered by duration\n    Tool: Bash (node/bun REPL)\n    Preconditions: Database has playHistory records with varying durations\n    Steps:\n      1. Import TrackService from '@/lib/services/trackService'\n      2. Call trackService.getMostPlayedTracksInLastDays({ days: 14, limit: 10 })\n      3. Verify result is Array<{ track, totalDuration }>\n      4. Verify results are sorted by totalDuration DESC\n    Expected Result: Array sorted correctly, max 10 items\n    Failure Indicators: Wrong sort order, more than limit items\n    Evidence: .sisyphus/evidence/task-1-query-order.txt\n  ```\n\n  **Commit**: YES (1 of 7)\n  - Message: `feat(mobile): add getMostPlayedTracks service method`\n  - Files: `apps/mobile/src/lib/services/trackService.ts`\n\n---\n\n- [x] 2. Add useMostPlayedTracks query hook\n\n  **What to do**:\n  - Open `apps/mobile/src/hooks/queries/playHistory.ts`\n  - Add new query key: `topPlayed: (days: number, limit: number) => [...playHistoryKeys.all, 'topPlayed', days, limit] as const`\n  - Add new hook `useMostPlayedTracks(days: number, limit: number)`\n  - Use TanStack Query with the new service method\n  - Set `enabled: true` and `staleTime: 60 * 1000` (1 minute)\n  - Map result to include full track data with artist\n\n  **Must NOT do**:\n  - Do NOT add mutation hooks\n  - Do NOT add optimistic updates\n  - Do NOT over-engineer caching\n\n  **Recommended Agent Profile**:\n  - **Category**: `quick`\n  - **Skills**: []\n  - Reason: Straightforward hook following existing patterns\n\n  **Parallelization**:\n  - **Can Run In Parallel**: NO (depends on Task 1)\n  - **Parallel Group**: Sequential after Task 1\n  - **Blocks**: Task 4\n\n  **References**:\n  - `apps/mobile/src/hooks/queries/playHistory.ts:9-13` - Query key pattern\n  - `apps/mobile/src/hooks/queries/playHistory.ts:56-99` - Hook structure pattern\n\n  **Acceptance Criteria**:\n  - [ ] Query key factory extended\n  - [ ] Hook returns correct TanStack Query result\n  - [ ] Hook calls trackService method correctly\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Hook returns query result with tracks\n    Tool: Bash (bun test)\n    Steps:\n      1. Create test file `apps/mobile/src/hooks/queries/__tests__/playHistory.topPlayed.test.ts`\n      2. Mock trackService.getMostPlayedTracksInLastDays\n      3. Render hook with useMostPlayedTracks(14, 10)\n      4. Verify hook returns { data, isPending, isError }\n    Expected Result: Hook returns expected structure\n    Evidence: .sisyphus/evidence/task-2-hook-test.txt\n  ```\n\n  **Commit**: YES (2 of 7)\n  - Message: `feat(mobile): add useMostPlayedTracks query hook`\n  - Files: `apps/mobile/src/hooks/queries/playHistory.ts`\n\n---\n\n- [x] 3. Remove quick action buttons from homepage\n\n  **What to do**:\n  - Open `apps/mobile/src/app/(tabs)/index.tsx`\n  - Delete lines 407-469 (entire `quickActionsContainer` View block)\n  - Remove unused imports: `IconButton` if no longer used elsewhere\n  - Remove unused styles: `quickActionsContainer`, `quickActionItem`, `quickActionText`\n\n  **Must NOT do**:\n  - Do NOT modify any other functionality\n  - Do NOT change the WeeklyHeatMap component\n  - Do NOT remove any other imports that are still used\n\n  **Recommended Agent Profile**:\n  - **Category**: `quick`\n  - **Skills**: []\n  - Reason: Simple deletion, no complex logic\n\n  **Parallelization**:\n  - **Can Run In Parallel**: YES (with Tasks 1, 2)\n  - **Parallel Group**: Wave 1\n  - **Blocks**: Task 7\n\n  **References**:\n  - `apps/mobile/src/app/(tabs)/index.tsx:407-469` - Code to delete\n  - `apps/mobile/src/app/(tabs)/index.tsx:581-593` - Styles to remove\n\n  **Acceptance Criteria**:\n  - [ ] No quick action buttons visible on homepage\n  - [ ] No unused imports or styles remain\n  - [ ] App compiles and runs successfully\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Homepage renders without quick actions\n    Tool: Bash (pnpm lint + pnpm test)\n    Steps:\n      1. Run `cd apps/mobile && pnpm lint`\n      2. Run `cd apps/mobile && pnpm test -- --passWithNoTests`\n      3. Verify no lint errors related to unused imports\n    Expected Result: Lint passes, tests pass\n    Evidence: .sisyphus/evidence/task-3-lint-output.txt\n  ```\n\n  **Commit**: YES (3 of 7)\n  - Message: `refactor(mobile): remove quick action buttons from homepage`\n  - Files: `apps/mobile/src/app/(tabs)/index.tsx`\n\n---\n\n### Wave 2: Feature Pages\n\n- [x] 4. Create Recently Played page\n\n  **What to do**:\n  - Create `apps/mobile/src/app/playlist/recently/index.tsx`\n  - Copy structure from `toview.tsx` but adapt:\n    - Title: \"最近常听\"\n    - Subtitle: \"最近14天最常播放的歌曲\"\n    - Use `useMostPlayedTracks(14, 10)` instead of `useGetToViewVideoList`\n    - Sort by `totalDuration` (already sorted from service)\n    - Remove progress display (not applicable)\n  - Handle empty state: show \"暂无播放记录\" text when no data\n  - Handle loading/error states using `PlaylistPageSkeleton` and `PlaylistError`\n\n  **Must NOT do**:\n  - Do NOT add filter/sort options for user\n  - Do NOT add selection mode (keep simple)\n  - Do NOT create custom item renderer (use default TrackListItem)\n\n  **Recommended Agent Profile**:\n  - **Category**: `artistry`\n  - **Skills**: []\n  - Reason: UI composition with existing patterns, needs careful adaptation\n\n  **Parallelization**:\n  - **Can Run In Parallel**: YES (with Tasks 5, 6)\n  - **Parallel Group**: Wave 2\n  - **Blocks**: Task 7\n\n  **References**:\n  - `apps/mobile/src/app/playlist/remote/toview.tsx:1-317` - Full structure to copy\n  - `apps/mobile/src/features/playlist/remote/components/RemoteTrackList.tsx` - TrackList component\n  - `apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx` - Header component\n  - `apps/mobile/src/features/playlist/skeletons/PlaylistSkeleton.tsx` - Loading state\n\n  **Acceptance Criteria**:\n  - [ ] Page renders at `/playlist/recently`\n  - [ ] Shows tracks ordered by play duration\n  - [ ] Shows empty state when no plays in last 14 days\n  - [ ] Play button starts playback from selected track\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Recently Played page shows correct tracks\n    Tool: Bash (pnpm android + manual verification)\n    Steps:\n      1. Navigate to /playlist/recently\n      2. Verify page title is \"最近常听\"\n      3. Verify tracks are displayed\n      4. Tap a track, verify playback starts\n    Expected Result: Page works correctly\n    Evidence: .sisyphus/evidence/task-4-page-screenshot.png\n  ```\n\n  **Commit**: YES (4 of 7)\n  - Message: `feat(mobile): create Recently Played page`\n  - Files: `apps/mobile/src/app/playlist/recently/index.tsx`\n\n---\n\n- [x] 5. Add Play All button to history/[date] page\n\n  **What to do**:\n  - Open `apps/mobile/src/app/history/[date].tsx`\n  - Add `Button` import from `@/components/common/Button`\n  - Add `useCallback` for `handlePlayAll`:\n    ```typescript\n    const handlePlayAll = useCallback(async () => {\n    \tconst allTracks = aggregatedTracks.map((t) => t.track)\n    \tconst playableTracks = allTracks.filter((track) => {\n    \t\t// Check if track is cached/downloaded\n    \t\t// For bilibili tracks: check if downloaded\n    \t\t// For local tracks: always playable\n    \t\treturn track.source === 'local' || isTrackDownloaded(track)\n    \t})\n    \tif (playableTracks.length === 0) {\n    \t\ttoast.error('没有可播放的歌曲')\n    \t\treturn\n    \t}\n    \tawait addToQueue({\n    \t\ttracks: playableTracks,\n    \t\tplayNow: true,\n    \t\tclearQueue: true,\n    \t\tplayNext: false,\n    \t})\n    }, [aggregatedTracks])\n    ```\n  - Add Button between `totalDurationSurface` and `contentContainer`\n  - Button style: `mode='contained'`, `icon='play'`\n\n  **Must NOT do**:\n  - Do NOT add shuffle button\n  - Do NOT add progress indicator\n  - Do NOT disable button for empty state (condition will hide it)\n\n  **Recommended Agent Profile**:\n  - **Category**: `quick`\n  - **Skills**: []\n  - Reason: Straightforward addition following existing pattern\n\n  **Parallelization**:\n  - **Can Run In Parallel**: YES (with Tasks 4, 6)\n  - **Parallel Group**: Wave 2\n  - **Blocks**: None\n\n  **References**:\n  - `apps/mobile/src/features/playlist/local/components/LocalPlaylistHeader.tsx:92-100` - Button pattern\n  - `apps/mobile/src/utils/player.ts:70-90` - addToQueue function\n  - `apps/mobile/src/app/history/[date].tsx:144-157` - Insertion point\n\n  **Acceptance Criteria**:\n  - [ ] Play All button visible when tracks exist\n  - [ ] Button clears queue and starts from first track\n  - [ ] Offline: only cached tracks are queued\n  - [ ] Empty state: button not shown or shows \"暂无数据\"\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Play All button queues correct tracks\n    Tool: Bash (pnpm android + manual)\n    Steps:\n      1. Navigate to history/[date] page with tracks\n      2. Verify Play All button is visible\n      3. Tap Play All\n      4. Verify queue is cleared and playback starts from first track\n    Expected Result: Queue replaced, playback starts\n    Evidence: .sisyphus/evidence/task-5-play-all.txt\n  ```\n\n  **Commit**: YES (5 of 7)\n  - Message: `feat(mobile): add Play All button to history date page`\n  - Files: `apps/mobile/src/app/history/[date].tsx`\n\n---\n\n- [x] 6. Create Quick Access section component infrastructure\n\n  **What to do**:\n  - Define the section structure in `index.tsx` (inline, not separate component)\n  - Create horizontal ScrollView with snap:\n    ```typescript\n    <ScrollView\n      horizontal\n      showsHorizontalScrollIndicator={false}\n      snapToInterval={CARD_WIDTH + CARD_GAP}  // 156 = 140 + 16\n      snapToAlignment='start'\n      decelerationRate='fast'\n      contentContainerStyle={styles.quickAccessScrollContent}\n    >\n      {/* Card items */}\n    </ScrollView>\n    ```\n  - Card dimensions: width 140, gap 16\n  - Section title: \"快捷入口\"\n  - Three cards:\n    1. \"那年今日\" - icon `calendar-star`, navigate to `/history/${todayLastYear}` (calculate in component)\n    2. \"最近常听\" - icon `history`, navigate to `/playlist/recently`\n    3. \"稍后再看\" - icon `clock-outline`, navigate to `/playlist/remote/toview`, only show if `hasBilibiliCookie()`\n\n  **Must NOT do**:\n  - Do NOT create a separate QuickAccessCard component\n  - Do NOT add more than 3 cards\n  - Do NOT add complex animations\n\n  **Recommended Agent Profile**:\n  - **Category**: `visual-engineering`\n  - **Skills**: []\n  - Reason: UI layout with snap scroll, visual polish needed\n\n  **Parallelization**:\n  - **Can Run In Parallel**: YES (with Tasks 4, 5)\n  - **Parallel Group**: Wave 2\n  - **Blocks**: Task 7\n\n  **References**:\n  - `apps/mobile/src/app/(tabs)/index.tsx:480-527` - Existing horizontal scroll pattern\n  - `apps/mobile/src/app/(tabs)/index.tsx:83` - hasBilibiliCookie pattern\n  - `apps/mobile/src/components/common/IconButton.tsx` - IconButton usage\n\n  **Acceptance Criteria**:\n  - [ ] Section shows \"快捷入口\" title\n  - [ ] Three cards display correctly with icons\n  - [ ] Horizontal scroll has snap effect\n  - [ ] \"稍后再看\" card hidden when no cookie\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Quick Access section snaps correctly\n    Tool: Bash (pnpm android + manual)\n    Steps:\n      1. Navigate to homepage\n      2. Scroll the Quick Access section horizontally\n      3. Verify snap-to-card behavior\n      4. Verify all cards are visible and tap target works\n    Expected Result: Snap works, cards navigate correctly\n    Evidence: .sisyphus/evidence/task-6-snap-scroll.mp4\n  ```\n\n  **Commit**: NO (groups with Task 7)\n\n---\n\n### Wave 3: Integration\n\n- [x] 7. Replace 近期歌单 with 快捷入口 section\n\n  **What to do**:\n  - Open `apps/mobile/src/app/(tabs)/index.tsx`\n  - Delete lines 471-529 (entire `recentPlaylistsSection` block)\n  - Delete unused `recentPlaylists` query (lines 99-111)\n  - Delete unused `useLiveQuery` import if no longer used\n  - Insert the Quick Access section from Task 6 after the WeeklyHeatMap component\n  - Remove unused styles: `recentPlaylistsSection`, `sectionTitle`, `horizontalScrollContent`, `playlistCard`, `playlistCover`, `playlistInfo`, `playlistTitle`\n  - Add new styles for Quick Access section\n\n  **Must NOT do**:\n  - Do NOT modify WeeklyHeatMap\n  - Do NOT change any other sections\n  - Do NOT keep dead code\n\n  **Recommended Agent Profile**:\n  - **Category**: `artistry`\n  - **Skills**: []\n  - Reason: Integration task, needs care to not break existing functionality\n\n  **Parallelization**:\n  - **Can Run In Parallel**: NO (depends on Tasks 3, 6)\n  - **Parallel Group**: Wave 3 (Final)\n  - **Blocks**: None\n\n  **References**:\n  - `apps/mobile/src/app/(tabs)/index.tsx:99-111` - Query to remove\n  - `apps/mobile/src/app/(tabs)/index.tsx:471-529` - Section to replace\n  - `apps/mobile/src/app/(tabs)/index.tsx:594-624` - Styles to clean up\n\n  **Acceptance Criteria**:\n  - [ ] Homepage shows WeeklyHeatMap + Quick Access section\n  - [ ] No 近期歌单 visible\n  - [ ] All navigation works correctly\n\n  **QA Scenarios**:\n\n  ```\n  Scenario: Homepage displays correctly\n    Tool: Bash (pnpm lint + pnpm android)\n    Steps:\n      1. Run `pnpm lint` in apps/mobile\n      2. Build and run app\n      3. Navigate to homepage\n      4. Verify WeeklyHeatMap is visible\n      5. Verify Quick Access section is below heatmap\n      6. Verify no quick action buttons\n      7. Verify no recent playlists section\n    Expected Result: Lint passes, app displays correctly\n    Evidence: .sisyphus/evidence/task-7-homepage-final.png\n  ```\n\n  **Commit**: YES (6 & 7 of 7)\n  - Message: `feat(mobile): replace 近期歌单 with 快捷入口 section`\n  - Files: `apps/mobile/src/app/(tabs)/index.tsx`\n\n---\n\n## Final Verification Wave (MANDATORY)\n\n> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit \"okay\" before completing.\n\n- [ ] F1. Plan Compliance Audit — `oracle`\n      Read the plan end-to-end. For each \"Must Have\": verify implementation exists (read file, run if applicable). For each \"Must NOT Have\": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/.\n      Output: `Must Have [4/4] | Must NOT Have [4/4] | Tasks [7/7] | VERDICT: APPROVE/REJECT`\n\n- [ ] F2. Code Quality Review — `unspecified-high`\n      Run `tsc --noEmit` + `pnpm lint` in apps/mobile. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names.\n      Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Files [N clean/N issues] | VERDICT`\n\n- [ ] F3. Real Manual QA — `unspecified-high`\n      Start from clean build. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together). Test edge cases: empty state, no cookie, offline. Save to `.sisyphus/evidence/final-qa/`.\n      Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`\n\n- [ ] F4. Scope Fidelity Check — `deep`\n      For each task: read \"What to do\", compare with actual diff (git diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check \"Must NOT do\" compliance. Detect cross-task contamination.\n      Output: `Tasks [7/7 compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`\n\n---\n\n## Commit Strategy\n\n| Commit | Message                                                       | Files                       | Pre-commit  |\n| ------ | ------------------------------------------------------------- | --------------------------- | ----------- |\n| 1      | `feat(mobile): add getMostPlayedTracks service method`        | trackService.ts             | `pnpm test` |\n| 2      | `feat(mobile): add useMostPlayedTracks query hook`            | playHistory.ts              | `pnpm test` |\n| 3      | `refactor(mobile): remove quick action buttons from homepage` | index.tsx                   | `pnpm lint` |\n| 4      | `feat(mobile): create Recently Played page`                   | playlist/recently/index.tsx | `pnpm lint` |\n| 5      | `feat(mobile): add Play All button to history date page`      | history/[date].tsx          | `pnpm lint` |\n| 6-7    | `feat(mobile): add Quick Access section to homepage`          | index.tsx                   | `pnpm lint` |\n\n---\n\n## Success Criteria\n\n### Verification Commands\n\n```bash\n# Lint check\ncd apps/mobile && pnpm lint\n\n# Type check\ncd apps/mobile && pnpm tsc --noEmit\n\n# Build check\ncd apps/mobile && pnpm android\n```\n\n### Final Checklist\n\n- [ ] All \"Must Have\" features present\n- [ ] All \"Must NOT Have\" absent\n- [ ] No console.log in production code\n- [ ] All imports use `@/*` alias\n- [ ] No TypeScript errors\n- [ ] Lint passes\n- [ ] App builds and runs on Android\n"
  },
  {
    "path": ".syncpackrc",
    "content": "{\n\t\"dependencyTypes\": [\"dev\", \"prod\", \"peer\", \"overrides\"],\n\t\"filter\": \".\",\n\t\"indent\": \"\\t\",\n\t\"semverRange\": \"\",\n\t\"source\": [\"package.json\", \"apps/*/package.json\", \"packages/*/package.json\"],\n\t\"versionGroups\": [\n\t\t{\n\t\t\t\"label\": \"Use apps/mobile versions for core dependencies\",\n\t\t\t\"dependencies\": [\"react\", \"react-native\", \"expo\", \"react-native-reanimated\", \"expo-router\", \"react-native-worklets\", \"react-native-svg\"],\n\t\t\t\"packages\": [\"**\"],\n\t\t\t\"snapTo\": [\"@bbplayer/mobile\"]\n\t\t},\n\t\t{\n\t\t\t\"label\": \"Use apps/mobile versions for dev tools\",\n\t\t\t\"dependencies\": [\"eslint\", \"typescript\", \"@types/node\", \"eslint-plugin-*\", \"@typescript-eslint/*\", \"oxlint\", \"oxfmt\"],\n\t\t\t\"packages\": [\"**\"],\n\t\t\t\"snapTo\": [\"bbplayer-root\"]\n\t\t}\n\t]\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n\t\"recommendations\": [\"oxc.oxc-vscode\"]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n\t\"files.autoSave\": \"onFocusChange\",\n\t\"editor.formatOnSave\": true,\n\t\"editor.defaultFormatter\": \"oxc.oxc-vscode\",\n\t\"[typescriptreact]\": {\n\t\t\"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n\t},\n\t\"[json]\": {\n\t\t\"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n\t},\n\t\"[typescript]\": {\n\t\t\"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n\t},\n\t\"[javascript]\": {\n\t\t\"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n\t},\n\t\"typescript.tsdk\": \"node_modules/typescript/lib\",\n\t\"oxc.typeAware\": true,\n\t\"editor.codeActionsOnSave\": {\n\t\t\"source.fixAll.oxc\": \"always\"\n\t},\n\t\"typescript.native-preview.tsdk\": \"${workspaceFolder}/node_modules/@typescript/native-preview\"\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# BBPlayer Project Knowledge Base\n\n**Generated:** 2026-03-23\n**Project:** BBPlayer - Bilibili Audio Player (React Native)\n**Repository:** https://github.com/bbplayer-app/bbplayer\n\n---\n\n## OVERVIEW\n\nBBPlayer is a local-first Bilibili audio player built with React Native and Expo. It features offline playback, lyrics support (SPL format), Bilibili integration, and Material Design 3 UI.\n\n**Core Stack:**\n\n- React Native 0.83.2 + Expo 55 + React 19\n- TypeScript with project references\n- pnpm workspaces (monorepo)\n- Zustand (state) + TanStack Query (data)\n- Drizzle ORM + expo-sqlite\n- Material Design 3 (React Native Paper)\n\n---\n\n## STRUCTURE\n\n```\n.\n├── apps/\n│   ├── mobile/          # Main React Native app (Expo)\n│   ├── backend/         # Cloudflare Workers API (Hono)\n│   └── docs/            # VitePress documentation\n├── packages/\n│   ├── orpheus/         # Native audio module (Media3/AVFoundation)\n│   ├── splash/          # Lyric parser (SPL format)\n│   ├── image-theme-colors/  # Color extraction\n│   ├── logs/            # Logging utility\n│   ├── heatmap/         # Audio visualization\n│   └── eslint-plugin/   # Custom ESLint rules\n├── .agent/              # AI agent rules & skills\n└── .github/workflows/   # CI/CD\n```\n\n---\n\n## WHERE TO LOOK\n\n| Task                | Location                         | Notes                          |\n| ------------------- | -------------------------------- | ------------------------------ |\n| **Mobile Screens**  | `apps/mobile/src/app/`           | Expo Router file-based routing |\n| **UI Components**   | `apps/mobile/src/components/`    | Shared components              |\n| **Feature Modules** | `apps/mobile/src/features/`      | Domain-organized features      |\n| **Global State**    | `apps/mobile/src/hooks/stores/`  | Zustand stores                 |\n| **API Calls**       | `apps/mobile/src/hooks/queries/` | TanStack Query hooks           |\n| **Business Logic**  | `apps/mobile/src/lib/`           | Facades, Services, DB          |\n| **Audio Player**    | `packages/orpheus/src/`          | Native module entry            |\n| **Lyrics Parsing**  | `packages/splash/src/`           | LRC/SPL parser                 |\n| **Custom ESLint**   | `packages/eslint-plugin/rules/`  | Project-specific rules         |\n| **Documentation**   | `apps/docs/docs/`                | VitePress site                 |\n\n---\n\n## COMMANDS\n\n```bash\n# Development\npnpm install                    # Install deps (pnpm only!)\npnpm lefthook install          # Setup git hooks\n\n# Code Quality\npnpm lint                      # oxlint + eslint\npnpm lint:fix                  # Auto-fix\npnpm format                    # oxfmt\npnpm check:deps                # syncpack dependency check\n\n# Mobile App\ncd apps/mobile\npnpm android                   # Run Android (dev build required)\npnpm start                     # Start Metro (WITH_ROZENITE=true)\npnpm test                      # Jest tests\n\n# Backend\ncd apps/backend\npnpm dev                       # Wrangler dev\npnpm deploy                    # Deploy to Cloudflare\n\n# Native Modules\ncd packages/orpheus\npnpm build                     # expo-module build\npnpm test                      # expo-module test\n```\n\n---\n\n## CONVENTIONS\n\n### Import Aliases\n\n- **Mobile app:** `@/*` → `./apps/mobile/src/*`\n- **Configured in:** `eslint.config.mjs` (via `@dword-design/eslint-plugin-import-alias`)\n- **Must use** for all imports in mobile app (not relative paths)\n\n### Linting Stack\n\n| Tool       | Purpose             | Config              |\n| ---------- | ------------------- | ------------------- |\n| **oxlint** | Primary linter      | `.oxlintrc.json`    |\n| **eslint** | Secondary + plugins | `eslint.config.mjs` |\n| **oxfmt**  | Formatter           | CLI only            |\n\n### Commit Format\n\n```\n<type>(<scope>): <message>\n\n# Types: feat, fix, docs, style, refactor, chore\n# Scopes: mobile, backend, docs, orpheus, splash, logs, root\n# Example: feat(mobile): add playlist shuffle\n```\n\n### Git Hooks (Lefthook)\n\n- **pre-commit:** oxfmt + oxlint + eslint + gitleaks\n- **commit-msg:** commitlint validation\n- Stage-fixed files auto-committed\n\n### Package Manager\n\n- **ONLY pnpm** - npm/yarn will break workspace resolution\n- Version: `pnpm@10.30.3`\n\n---\n\n## ANTI-PATTERNS\n\n### 🚫 NEVER\n\n- Use Expo Go - requires custom dev build (native code)\n- Throw errors in business logic - use `neverthrow` Result pattern\n- Define `renderItem` inside component - FlashList performance\n- Skip `extraData` with `useMemo` for FlashList dependencies\n- Use npm/yarn - pnpm only\n\n### ⚠️ CAUTION\n\n- iOS support is minimal (\"birth without nurture\") - Android focus\n- `console.log` is forbidden (error in oxlint) except in packages/\n- MMKV migration code exists - don't remove until migration complete\n- Multi-P Bilibili videos may have duplicate DB records\n\n### Type Workarounds\n\n- 27 `@ts-expect-error` in codebase (mostly Zustand/MM migrations)\n- Each has explanatory comment - understand before modifying\n- Key locations: `useAppStore.ts`, `mmkv.ts`, `LyricsControlOverlay.tsx`\n\n---\n\n## UNIQUE STYLES\n\n### Architecture: Facade + Service Pattern\n\n```\nUI Layer (app/, features/)\n    ↓ calls\nFacade Layer (lib/facades/) - orchestrates, manages transactions\n    ↓ calls\nService Layer (lib/services/) - single domain logic, DB access\n```\n\n### Error Handling\n\n```typescript\n// GOOD - neverthrow Result\nimport { ok, err } from 'neverthrow'\nreturn ok(data) // or err(new MyError())\n\n// BAD - throwing\nthrow new Error('...')\n```\n\n### React Query Patterns\n\n- Queries: `src/hooks/queries/<domain>/useXxx.ts`\n- Mutations: `src/hooks/mutations/<domain>/useXxx.ts`\n- Strict exhaustive-deps enforced\n\n### FlashList Rules\n\n```typescript\n// Define OUTSIDE component\nconst renderItem = ({ item }) => <Item {...item} />\n\n// Use with memoized extraData\n<FlashList\n  renderItem={renderItem}\n  extraData={useMemo(() => ({ selected }), [selected])}\n/>\n```\n\n---\n\n## CI/CD\n\n| Workflow      | Trigger      | Purpose                 |\n| ------------- | ------------ | ----------------------- |\n| **pr-checks** | PR           | Lint + dependency check |\n| **build**     | Manual/merge | EAS Android build       |\n| **nightly**   | Manual/daily | Dev build distribution  |\n| **update**    | Manual       | OTA update + Sentry     |\n| **wiki**      | Push to dev  | Docs sync               |\n\n---\n\n## NOTES\n\n### Development Build Required\n\nExpo Go won't work - native modules (orpheus, image-theme-colors) require custom dev build:\n\n```bash\ncd apps/mobile\nVERSION_CODE=$(git rev-list --count HEAD) \\\n  eas build --profile dev --platform android --local\n```\n\n### Rozenite Metro Plugins\n\nCustom Metro config uses `@rozenite/*` plugins for:\n\n- MMKV optimization\n- TanStack Query profiling\n- Bundle analysis\n\n### Firebase Config\n\n- Mock configs included (safe to use)\n- Real configs: `apps/mobile/assets/config/google-services/`\n  - `google-services.real.json`\n  - `GoogleService-Info.real.plist`\n\n### iOS Limitations\n\nMany features Android-only:\n\n- Desktop lyrics (impossible)\n- Spectrum visualizer\n- Seamless playback\n- Loudness normalization\n- Cover download for offline\n\n### Proto Files\n\nMobile has protobuf build step in `prepare` script:\n\n```bash\npbjs -t static-module ... dm.proto\npbts -o dm.d.ts dm.js\n```\n\n---\n\n## AGENT RULES\n\nProject-specific AI agent rules in `.agent/rules/`:\n\n- `changelog.md` - Changelog conventions\n- `measure-layout.md` - Layout measurement patterns\n\nAgent skills in `.agent/skills/`:\n\n- `react-doctor/` - React code analysis\n- `react-native-ease-refactor/` - RN refactoring\n- `gesture-handler-3-migration/` - RNGH migration\n- `upgrading-expo/` - Expo upgrade guide\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Roitium.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "PRIVACY.md",
    "content": "**Privacy Policy**\n\nThis privacy policy applies to the BBPlayer app (hereby referred to as \"Application\") for mobile devices that was created by Roitium (hereby referred to as \"Service Provider\") as an Open Source service. This service is intended for use \"AS IS\".\n\n**Information Collection and Use**\n\nThe Application collects information when you download and use it. This information may include information such as\n\n- Your device's Internet Protocol address (e.g. IP address)\n- The pages of the Application that you visit, the time and date of your visit, the time spent on those pages\n- The time spent on the Application\n- The operating system you use on your mobile device\n\nThe Application does not gather precise information about the location of your mobile device.\n\nThe Application collects your device's location, which helps the Service Provider determine your approximate geographical location and make use of in below ways:\n\n- Geolocation Services: The Service Provider utilizes location data to provide features such as personalized content, relevant recommendations, and location-based services.\n- Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user behavior, identify trends, and improve the overall performance and functionality of the Application.\n- Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external services. These services assist them in enhancing the Application and optimizing their offerings.\n\nThe Service Provider may use the information you provided to contact you from time to time to provide you with important information, required notices and marketing promotions.\n\nFor a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information. The information that the Service Provider request will be retained by them and used as described in this privacy policy.\n\n**Third Party Access**\n\nOnly aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement.\n\nPlease note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application:\n\n- [Expo](https://expo.io/privacy)\n- [Sentry](https://sentry.io/privacy/)\n- [Google / Firebase Analytics](https://policies.google.com/privacy)\n\nThe Service Provider may disclose User Provided and Automatically Collected Information:\n\n- as required by law, such as to comply with a subpoena, or similar legal process;\n- when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or some safety of others, investigate fraud, or respond to a government request;\n- with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement.\n\n**In-App Opt-Out**\n\nThe Application provides an in-app toggle to stop all anonymous data collection (Crash reports and Analytics). You can find this under \"Settings\" -> \"General\" -> \"Share Data (Crash Reports & Anonymous Stats)\".\n\n**Opt-Out Rights**\n\nYou can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network.\n\n**Data Retention Policy**\n\nThe Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please contact them at me@roitium.com and they will respond in a reasonable time.\n\n**Children**\n\nThe Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13.\n\nThe Application does not address anyone under the age of 13\\. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (me@roitium.com) so that they will be able to take the necessary actions.\n\n**Security**\n\nThe Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and maintains.\n\n**Changes**\n\nThis Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.\n\nThis privacy policy is effective as of 2026-01-25\n\n**Your Consent**\n\nBy using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by us.\n\n**Contact Us**\n\nIf you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at me@roitium.com.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\"./apps/mobile/assets/images/icon_large.png\" alt=\"logo\" width=\"50\" />\n<h1>BBPlayer</h1>\n\n一款使用 React Native 构建的本地优先的 Bilibili 音频播放器。更轻量 & 舒服的听歌体验，远离臃肿卡顿的 Bilibili 客户端。\n\n[![GitHub Release](https://img.shields.io/github/v/release/yanyao2333/bbplayer?style=flat-square)](https://github.com/bbplayer-app/bbplayer/releases)\n![React Native](https://img.shields.io/badge/React%20Native-20232A?style=flat-square&logo=react&logoColor=sky)\n[![Website](https://img.shields.io/badge/Website-bbplayer.roitium.com-blue?style=flat-square)](https://bbplayer.roitium.com)\n\n</div>\n\n---\n\n**[前往官网查看更多详情和上手指南 ➔](https://bbplayer.roitium.com)**\n\n## 屏幕截图\n\n|                  首页                  |                   播放器                   |                    播放列表                    |                     下载页                     |                    库页面                    |\n| :------------------------------------: | :----------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | :------------------------------------------: |\n| ![home](./assets/screenshots/home.jpg) | ![player](./assets/screenshots/player.jpg) | ![playlist](./assets/screenshots/playlist.jpg) | ![download](./assets/screenshots/download.jpg) | ![library](./assets/screenshots/library.jpg) |\n\n## 主要功能\n\n### 核心播放体验\n\n- **Bilibili 登录**: 支持通过**扫码**、**手机号（短信验证码）**或手动设置 Cookie 登录。\n- **播放源**: 自由添加本地播放列表，登录账号后也可直接访问账号内收藏夹、订阅合集等，兼顾快速与方便。\n- **导入外部歌单**: 支持从 **网易云音乐** 和 **QQ 音乐** 的歌单自动匹配到 B 站视频并保存为播放列表。\n- **全功能播放器**: 提供播放/暂停、循环、随机、播放队列、响度均衡、断点续播、启动自动播放等功能。\n- **弹幕**: 在播放器页面直接展示视频弹幕，还原最原汁原味的 B 站体验。\n- **搜索**: 智能搜索，支持 BV/AV 号、b23.tv 短链解析。同时提供收藏夹和本地播放列表内搜索。\n\n### 歌词系统\n\n- **支持 SPL**: 基于 [SPL 规范](https://bbplayer.roitium.com/SPL)，支持**逐字进度**、**罗马音注音**及**翻译歌词**展示。\n- **智能获取**: 支持自动匹配歌词（网易云/QQ 音乐/酷狗音乐），并支持手动搜索、粘贴 LRC/SPL 文本及偏移量调整。\n- **多样展示**: 支持桌面歌词（悬浮窗）、状态栏歌词。\n\n### 其他特性\n\n- **下载与导出**: 支持缓存歌曲并离线播放，提供简单实用的下载管理。同时支持将已缓存的歌曲导出为带封面、元数据、内嵌歌词的 `.m4a` 文件到本地存储。\n- **UI**: 支持浅色/深色模式，UI 深度适配 Material Design 3 且支持莫奈取色。\n- **实用工具**: 提供定时关闭、播放历史统计（排行榜）等功能。\n\n还有更多功能和惊喜，欢迎到[官网](https://bbplayer.roitium.com)查看喵！\n\n## 技术栈\n\n- **框架**: React Native, Expo\n- **状态管理**: Zustand\n- **数据请求**: React Query\n- **UI**: Material Design 3 (React Native Paper)\n- **播放库**: [@bbplayer/orpheus](./packages/orpheus) (基于 Media3)\n- **ORM**: Drizzle ORM\n\n## 项目结构 (Monorepo)\n\n- **[apps/mobile](./apps/mobile)**: BBPlayer 移动端应用核心代码。\n- **[apps/docs](./apps/docs)**: 项目文档站点。\n- **[packages/](./packages)**: 共享库与工具包。\n  - **[@bbplayer/splash](./packages/splash)**: 歌词解析与转换核心库。\n  - **[@bbplayer/eslint-plugin](./packages/eslint-plugin)**: BBPlayer 专用 ESLint 规则。\n  - **[@bbplayer/orpheus](./packages/orpheus)**: 基于 Orpheus 的 Expo 音频播放模块。\n  - **[@bbplayer/logs](./packages/logs)**: 日志库。\n  - **[@bbplayer/image-theme-colors](./packages/image-theme-colors)**: 封面颜色提取工具。\n\n## IOS 支持\n\n曾经对 IOS 进行了基础适配，但现在重心依旧在 Android 端上，IOS 端没有同步开发，不保证可以编译成功。\n\n## 隐私与数据统计\n\n为了持续改进 BBPlayer，应用内集成了一套轻量级的匿名数据收集系统（包含 Firebase Analytics 和 Sentry）。\n\n### 我们收集什么？\n\n1. **使用数据**：功能使用频率、播放会话时长等。\n2. **崩溃报告**：应用崩溃时的堆栈信息，帮助我们修复 Bug。\n\n### 隐私承诺\n\n- **匿名**：所有数据均**不包含个人身份信息**。\n- **透明**：我们不会收集任何与账号隐私相关的信息（如 Cookie 内容、浏览历史明细等）。所有统计代码均开源可见。\n- **控制权**：你可以随时在「设置 -> 通用设置」中关闭「分享数据（崩溃报告 & 匿名统计）」开关，完全停止数据上传。\n\n## 捐赠支持\n\n如果你觉得 BBPlayer 对你有所帮助，欢迎考虑捐赠支持，你的所有捐赠都将用于让 Roitium 吃顿疯狂星期四或是买一部 GalGame！\n\n<table>\n<tr>\n<td align=\"center\">\n<details>\n<summary>微信支付</summary>\n<br />\n<img src=\"./apps/mobile/assets/images/wechat.png\" alt=\"WeChat Donation\" width=\"200\" />\n</details>\n</td>\n<td align=\"center\">\n<details>\n<summary>支付宝</summary>\n<br />\n<img src=\"./apps/mobile/assets/images/alipay.jpg\" alt=\"Alipay Donation\" width=\"200\" />\n</details>\n</td>\n</tr>\n</table>\n\n## 感谢\n\n本项目开发过程中很多功能和设计的灵感都来自前辈们，包括但不限于：\n\n- [AzusaPlayer](https://github.com/lovegaoshi/azusa-player-mobile)\n- [BiliSound](https://github.com/bilisound/client-mobile)\n- [Salt Player](https://github.com/Moriafly/SaltPlayerSource)\n- [Spotify](https://spotify.com)\n\n以及最重要的：[Bilibili](https://www.bilibili.com/)\n\n在此表示感谢！（鞠躬）\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=bbplayer-app/bbplayer&type=date&legend=top-left)](https://www.star-history.com/#bbplayer-app/bbplayer&type=date&legend=top-left)\n\n## 开源许可\n\n本项目采用 MIT 许可。\n"
  },
  {
    "path": "apps/README.md",
    "content": ""
  },
  {
    "path": "apps/backend/.dev.vars.example",
    "content": "DATABASE_URL=postgres://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres\n\n# You can generate a random secret using: openssl rand -base64 32\nJWT_SECRET=your-local-dev-secret-at-least-32-chars\n"
  },
  {
    "path": "apps/backend/.gitignore",
    "content": ".dev.vars\n.wrangler/\nnode_modules/\ndist/\ndrizzle/\n"
  },
  {
    "path": "apps/backend/drizzle.config.ts",
    "content": "import { defineConfig } from 'drizzle-kit'\n\nif (!process.env.DATABASE_URL) {\n\tthrow new Error('DATABASE_URL is missing')\n}\n\nexport default defineConfig({\n\tdialect: 'postgresql',\n\tschema: './src/db/schema.ts',\n\tout: './drizzle',\n\tdbCredentials: {\n\t\turl: process.env.DATABASE_URL,\n\t},\n})\n"
  },
  {
    "path": "apps/backend/mise.toml",
    "content": "[env]\n_.file = { path = \".dev.vars\", redact = true }\n"
  },
  {
    "path": "apps/backend/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/backend\",\n\t\"version\": \"0.0.1\",\n\t\"private\": true,\n\t\"types\": \"./src/index.ts\",\n\t\"exports\": {\n\t\t\".\": {\n\t\t\t\"types\": \"./src/index.ts\",\n\t\t\t\"default\": \"./src/index.ts\"\n\t\t}\n\t},\n\t\"scripts\": {\n\t\t\"cf-typegen\": \"wrangler types\",\n\t\t\"db:generate\": \"drizzle-kit generate\",\n\t\t\"db:migrate\": \"drizzle-kit migrate\",\n\t\t\"db:push\": \"drizzle-kit push\",\n\t\t\"db:studio\": \"drizzle-kit studio\",\n\t\t\"deploy\": \"wrangler deploy\",\n\t\t\"dev\": \"wrangler dev\"\n\t},\n\t\"dependencies\": {\n\t\t\"@hono/arktype-validator\": \"^2.0.1\",\n\t\t\"arktype\": \"^2.1.29\",\n\t\t\"drizzle-orm\": \"^0.44.7\",\n\t\t\"hono\": \"^4.12.2\",\n\t\t\"pg\": \"^8.19.0\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/pg\": \"^8.16.0\",\n\t\t\"drizzle-kit\": \"^0.31.9\",\n\t\t\"typescript\": \"~5.9.3\",\n\t\t\"wrangler\": \"^4.4.0\"\n\t}\n}\n"
  },
  {
    "path": "apps/backend/src/db/index.ts",
    "content": "import { drizzle } from 'drizzle-orm/node-postgres'\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres'\nimport { Client } from 'pg'\n\nimport * as schema from './schema'\n\nexport type DbConnection = {\n\tdb: NodePgDatabase<typeof schema>\n\tclient: Client\n}\nexport type DrizzleDb = DbConnection['db']\n\nexport async function createDb(\n\tconnectionString: string,\n): Promise<DbConnection> {\n\tconst client = new Client({\n\t\tconnectionString,\n\t})\n\n\tawait client.connect()\n\n\tconst db = drizzle(client, { schema })\n\n\treturn { db, client }\n}\n"
  },
  {
    "path": "apps/backend/src/db/schema.ts",
    "content": "import { sql } from 'drizzle-orm'\nimport {\n\tindex,\n\tinteger,\n\tpgTable,\n\tprimaryKey,\n\ttext,\n\ttimestamp,\n\tuniqueIndex,\n\tuuid,\n} from 'drizzle-orm/pg-core'\n\nexport const users = pgTable('users', {\n\t/** B 站 mid */\n\tmid: text('mid').primaryKey(),\n\tname: text('name').notNull(),\n\tface: text('face'),\n\tlastLoginAt: timestamp('last_login_at', { withTimezone: true })\n\t\t.notNull()\n\t\t.default(sql`now()`),\n})\n\nexport const sharedPlaylists = pgTable(\n\t'shared_playlists',\n\t{\n\t\tid: uuid('id')\n\t\t\t.primaryKey()\n\t\t\t.default(sql`gen_random_uuid()`),\n\t\townerMid: text('owner_mid')\n\t\t\t.notNull()\n\t\t\t.references(() => users.mid, { onDelete: 'cascade' }),\n\t\ttitle: text('title').notNull(),\n\t\tdescription: text('description'),\n\t\tcoverUrl: text('cover_url'),\n\t\t/** 编辑者邀请码（明文存储，旋转后旧码失效） */\n\t\teditorInviteCode: text('editor_invite_code'),\n\t\tcreatedAt: timestamp('created_at', { withTimezone: true })\n\t\t\t.notNull()\n\t\t\t.default(sql`now()`),\n\t\tupdatedAt: timestamp('updated_at', { withTimezone: true })\n\t\t\t.notNull()\n\t\t\t.default(sql`now()`),\n\t\t/** 软删除；非 null 表示已删除 */\n\t\tdeletedAt: timestamp('deleted_at', { withTimezone: true }),\n\t},\n\t(t) => [\n\t\tuniqueIndex('editor_invite_code_unq')\n\t\t\t.on(t.editorInviteCode)\n\t\t\t.where(sql`${t.editorInviteCode} IS NOT NULL`),\n\t],\n)\n\nexport const playlistMembers = pgTable(\n\t'playlist_members',\n\t{\n\t\tplaylistId: uuid('playlist_id')\n\t\t\t.notNull()\n\t\t\t.references(() => sharedPlaylists.id, { onDelete: 'cascade' }),\n\t\tmid: text('mid')\n\t\t\t.notNull()\n\t\t\t.references(() => users.mid, { onDelete: 'cascade' }),\n\t\trole: text('role', { enum: ['owner', 'editor', 'subscriber'] }).notNull(),\n\t\tjoinedAt: timestamp('joined_at', { withTimezone: true })\n\t\t\t.notNull()\n\t\t\t.default(sql`now()`),\n\t},\n\t(t) => [primaryKey({ columns: [t.playlistId, t.mid] })],\n)\n\nexport const sharedTracks = pgTable('shared_tracks', {\n\tuniqueKey: text('unique_key').primaryKey(),\n\ttitle: text('title').notNull(),\n\t/** 反归一化，简化查询 */\n\tartistName: text('artist_name'),\n\t/** 可能是 mid 或其他标识 */\n\tartistId: text('artist_id'),\n\tcoverUrl: text('cover_url'),\n\tduration: integer('duration'),\n\tbilibiliBvid: text('bilibili_bvid').notNull(),\n\tbilibiliCid: text('bilibili_cid'),\n\tcreatedAt: timestamp('created_at', { withTimezone: true })\n\t\t.notNull()\n\t\t.default(sql`now()`),\n\tupdatedAt: timestamp('updated_at', { withTimezone: true })\n\t\t.notNull()\n\t\t.default(sql`now()`),\n})\n\nexport const sharedPlaylistTracks = pgTable(\n\t'shared_playlist_tracks',\n\t{\n\t\tplaylistId: uuid('playlist_id')\n\t\t\t.notNull()\n\t\t\t.references(() => sharedPlaylists.id, { onDelete: 'cascade' }),\n\t\ttrackUniqueKey: text('track_unique_key')\n\t\t\t.notNull()\n\t\t\t.references(() => sharedTracks.uniqueKey, { onDelete: 'cascade' }),\n\t\tsortKey: text('sort_key').notNull(),\n\t\taddedByMid: text('added_by_mid').references(() => users.mid, {\n\t\t\tonDelete: 'set null',\n\t\t}),\n\t\tcreatedAt: timestamp('created_at', { withTimezone: true })\n\t\t\t.notNull()\n\t\t\t.default(sql`now()`),\n\t\t/** reorder 时也更新此字段；LWW 以此为基准 */\n\t\tupdatedAt: timestamp('updated_at', { withTimezone: true })\n\t\t\t.notNull()\n\t\t\t.default(sql`now()`),\n\t\t/** 软删除；驱动增量同步的 delete 事件 */\n\t\tdeletedAt: timestamp('deleted_at', { withTimezone: true }),\n\t},\n\t(t) => [\n\t\tprimaryKey({ columns: [t.playlistId, t.trackUniqueKey] }),\n\t\tindex('spt_playlist_updated_idx').on(t.playlistId, t.updatedAt),\n\t\tindex('spt_playlist_deleted_idx').on(t.playlistId, t.deletedAt),\n\t],\n)\n\nexport type User = typeof users.$inferSelect\nexport type SharedPlaylist = typeof sharedPlaylists.$inferSelect\nexport type PlaylistMember = typeof playlistMembers.$inferSelect\nexport type SharedTrack = typeof sharedTracks.$inferSelect\nexport type SharedPlaylistTrack = typeof sharedPlaylistTracks.$inferSelect\n"
  },
  {
    "path": "apps/backend/src/index.ts",
    "content": "import { Hono } from 'hono'\nimport { cors } from 'hono/cors'\n\nimport authRoute from './routes/auth'\nimport meRoute from './routes/me'\nimport playlistsRoute from './routes/playlists'\n\nconst healthRoute = new Hono<{ Bindings: Env }>().get('/', (c) =>\n\tc.json({ status: 'ok', timestamp: Date.now() }),\n)\n\nconst updateRoute = new Hono<{ Bindings: Env }>().get('/', async (c) => {\n\tconst manifest = await c.env.KV.get('update_json')\n\tif (!manifest) {\n\t\treturn c.json({ error: 'Manifest not found' }, 404)\n\t}\n\t// manifest 应该是 JSON 字符串，直接返回并设置 content-type\n\treturn c.text(manifest, { headers: { 'Content-Type': 'application/json' } })\n})\n\nconst app = new Hono<{ Bindings: Env }>()\n\t// .use('*', logger())\n\t.use(\n\t\t'*',\n\t\tcors({\n\t\t\torigin: [\n\t\t\t\t'https://bbplayer.roitium.com',\n\t\t\t\t'http://localhost:3000',\n\t\t\t\t'https://bbplayer-backend.roitium.workers.dev',\n\t\t\t\t'http://localhost:5173',\n\t\t\t],\n\t\t\tallowHeaders: ['Authorization', 'Content-Type'],\n\t\t\tallowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],\n\t\t}),\n\t)\n\t.route('/auth', authRoute)\n\t.route('/me', meRoute)\n\t.route('/health', healthRoute)\n\t.route('/playlists', playlistsRoute)\n\t.route('/update.json', updateRoute)\n\nexport default app\nexport type AppType = typeof app\n"
  },
  {
    "path": "apps/backend/src/middleware/auth.ts",
    "content": "import { createMiddleware } from 'hono/factory'\nimport { verify } from 'hono/jwt'\n\nimport type { JwtTokenPayload } from '../types'\n\n/**\n * JWT 鉴权中间件。\n * 校验通过后将 payload 注入 `c.var.jwtPayload`，\n * 路由层通过 `c.var.jwtPayload` 读取 mid 及 jwtVersion。\n */\nexport const authMiddleware = createMiddleware<{\n\tBindings: Env\n\tVariables: { jwtPayload: JwtTokenPayload }\n}>(async (c, next) => {\n\tconst authHeader = c.req.header('Authorization')\n\tif (!authHeader?.startsWith('Bearer ')) {\n\t\treturn c.json({ error: 'Unauthorized' }, 401)\n\t}\n\tconst token = authHeader.slice(7)\n\ttry {\n\t\tconst payload = await verify(token, c.env.JWT_SECRET, 'HS256')\n\t\tif (typeof payload.sub !== 'string') {\n\t\t\treturn c.json({ error: 'Invalid token payload' }, 401)\n\t\t}\n\t\tc.set('jwtPayload', payload as unknown as JwtTokenPayload)\n\t} catch {\n\t\treturn c.json({ error: 'Invalid or expired token' }, 401)\n\t}\n\tawait next()\n})\n"
  },
  {
    "path": "apps/backend/src/routes/auth.ts",
    "content": "import { arktypeValidator } from '@hono/arktype-validator'\nimport { eq } from 'drizzle-orm'\nimport { Hono } from 'hono'\nimport { sign } from 'hono/jwt'\n\nimport { createDb } from '../db'\nimport { users } from '../db/schema'\nimport { loginRequestSchema } from '../validators/auth'\n\n/**\n * POST /api/auth/login\n * Body: { cookie: string }  — 客户端传入 B 站 SESSDATA cookie\n *\n * 流程：\n *  1. 用 cookie 请求 B 站 nav API 验证身份\n *  2. upsert users 表\n *  3. 签发 JWT（sub=mid, jwtVersion=当前值）\n */\nconst authRoute = new Hono<{ Bindings: Env }>().post(\n\t'/login',\n\tarktypeValidator('json', loginRequestSchema, (result, c) => {\n\t\tif (!result.success) {\n\t\t\treturn c.json(\n\t\t\t\t{ error: 'invalid_body', summary: result.errors.summary },\n\t\t\t\t400,\n\t\t\t)\n\t\t}\n\t}),\n\tasync (c) => {\n\t\tconst { cookie } = c.req.valid('json')\n\n\t\t// -----------------------------------------------------------------------\n\t\t// 1. 向 B 站验证 cookie\n\t\t// -----------------------------------------------------------------------\n\t\tconst controller = new AbortController()\n\t\tconst timeoutId = setTimeout(() => controller.abort(), 10000) // 10s timeout\n\n\t\tconst biliRes = await fetch(\n\t\t\t'https://api.bilibili.com/x/web-interface/nav',\n\t\t\t{\n\t\t\t\theaders: { Cookie: cookie },\n\t\t\t\tsignal: controller.signal,\n\t\t\t},\n\t\t).finally(() => clearTimeout(timeoutId))\n\t\tconst biliJson = (await biliRes.json()) as {\n\t\t\tcode: number\n\t\t\tmessage?: string\n\t\t\tdata?: {\n\t\t\t\tisLogin: boolean\n\t\t\t\tmid: number\n\t\t\t\tuname: string\n\t\t\t\tface: string\n\t\t\t}\n\t\t}\n\n\t\tif (biliJson.code !== 0 || !biliJson.data?.isLogin) {\n\t\t\treturn c.json({ error: 'Invalid Bilibili cookie' }, 401)\n\t\t}\n\n\t\tconst { mid, uname, face } = biliJson.data\n\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\t\ttry {\n\t\t\tconst existing = await db\n\t\t\t\t.select({ mid: users.mid })\n\t\t\t\t.from(users)\n\t\t\t\t.where(eq(users.mid, String(mid)))\n\t\t\t\t.limit(1)\n\n\t\t\tif (existing.length === 0) {\n\t\t\t\tawait db.insert(users).values({\n\t\t\t\t\tmid: String(mid),\n\t\t\t\t\tname: uname,\n\t\t\t\t\tface,\n\t\t\t\t\tlastLoginAt: new Date(),\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tawait db\n\t\t\t\t\t.update(users)\n\t\t\t\t\t.set({\n\t\t\t\t\t\tname: uname,\n\t\t\t\t\t\tface,\n\t\t\t\t\t\tlastLoginAt: new Date(),\n\t\t\t\t\t})\n\t\t\t\t\t.where(eq(users.mid, String(mid)))\n\t\t\t}\n\n\t\t\t// Generate JWT\n\t\t\tconst token = await sign(\n\t\t\t\t{\n\t\t\t\t\tsub: String(mid),\n\t\t\t\t\trole: 'user',\n\t\t\t\t\texp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days\n\t\t\t\t},\n\t\t\t\tc.env.JWT_SECRET,\n\t\t\t)\n\n\t\t\treturn c.json({ token, mid: String(mid), name: uname, face })\n\t\t} catch {\n\t\t\treturn c.json({ error: 'Internal server error' }, 500)\n\t\t}\n\t},\n)\n\nexport default authRoute\n"
  },
  {
    "path": "apps/backend/src/routes/me.ts",
    "content": "import { and, desc, eq, isNull } from 'drizzle-orm'\nimport { Hono } from 'hono'\n\nimport { createDb } from '../db'\nimport { playlistMembers, sharedPlaylists } from '../db/schema'\nimport { authMiddleware } from '../middleware/auth'\nimport type { JwtTokenPayload } from '../types'\n\n/**\n * GET /api/me/playlists\n * 返回当前用户参与（owner / editor / subscriber）的所有未删除歌单。\n * 用于换设备后的全量恢复入口。\n */\nconst meRoute = new Hono<{\n\tBindings: Env\n\tVariables: { jwtPayload: JwtTokenPayload }\n}>()\n\t.use('*', authMiddleware)\n\t.get('/playlists', async (c) => {\n\t\tconst { sub } = c.var.jwtPayload\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst rows = await db\n\t\t\t.select({\n\t\t\t\tid: sharedPlaylists.id,\n\t\t\t\ttitle: sharedPlaylists.title,\n\t\t\t\tdescription: sharedPlaylists.description,\n\t\t\t\tcoverUrl: sharedPlaylists.coverUrl,\n\t\t\t\tupdatedAt: sharedPlaylists.updatedAt,\n\t\t\t\trole: playlistMembers.role,\n\t\t\t\tjoinedAt: playlistMembers.joinedAt,\n\t\t\t})\n\t\t\t.from(playlistMembers)\n\t\t\t.innerJoin(\n\t\t\t\tsharedPlaylists,\n\t\t\t\tand(\n\t\t\t\t\teq(playlistMembers.playlistId, sharedPlaylists.id),\n\t\t\t\t\tisNull(sharedPlaylists.deletedAt),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.where(eq(playlistMembers.mid, sub))\n\t\t\t.orderBy(desc(playlistMembers.joinedAt))\n\n\t\treturn c.json({ playlists: rows })\n\t})\n\nexport default meRoute\n"
  },
  {
    "path": "apps/backend/src/routes/playlists.ts",
    "content": "import { arktypeValidator } from '@hono/arktype-validator'\nimport {\n\tand,\n\tasc,\n\tdesc,\n\teq,\n\tgt,\n\tisNotNull,\n\tisNull,\n\tlt,\n\tor,\n\tsql,\n} from 'drizzle-orm'\nimport { Hono } from 'hono'\n\nimport { createDb } from '../db'\nimport type { DrizzleDb } from '../db'\nimport {\n\tplaylistMembers,\n\tsharedPlaylists,\n\tsharedPlaylistTracks,\n\tsharedTracks,\n\tusers,\n} from '../db/schema'\nimport { authMiddleware } from '../middleware/auth'\nimport type { ChangeEvent, JwtTokenPayload, TrackInput } from '../types'\nimport {\n\tcreatePlaylistRequestSchema,\n\tgetPlaylistChangesRequestSchema,\n\tplaylistChangesRequestSchema,\n\tsubscribePlaylistRequestSchema,\n\tupdatePlaylistRequestSchema,\n} from '../validators/playlists'\n\nconst validationHook: Parameters<typeof arktypeValidator>[2] = (result, c) => {\n\tif (!result.success) {\n\t\treturn c.json(\n\t\t\t{ error: 'invalid_body', summary: result.errors.summary },\n\t\t\t400,\n\t\t)\n\t}\n}\n\nconst PLAYLIST_PREVIEW_LIMIT = 30\n\ntype HonoEnv = {\n\tBindings: Env\n\tVariables: { jwtPayload: JwtTokenPayload }\n}\n\nconst playlistsRoute = new Hono<HonoEnv>()\n\t// 无需鉴权的公开接口\n\t.get('/:id/preview', async (c) => {\n\t\tconst playlistId = c.req.param('id')\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst [playlist] = await db\n\t\t\t.select({\n\t\t\t\tid: sharedPlaylists.id,\n\t\t\t\ttitle: sharedPlaylists.title,\n\t\t\t\tdescription: sharedPlaylists.description,\n\t\t\t\tcoverUrl: sharedPlaylists.coverUrl,\n\t\t\t\townerMid: sharedPlaylists.ownerMid,\n\t\t\t\tcreatedAt: sharedPlaylists.createdAt,\n\t\t\t\tupdatedAt: sharedPlaylists.updatedAt,\n\t\t\t})\n\t\t\t.from(sharedPlaylists)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(sharedPlaylists.id, playlistId),\n\t\t\t\t\tisNull(sharedPlaylists.deletedAt),\n\t\t\t\t),\n\t\t\t)\n\n\t\tif (!playlist) {\n\t\t\treturn c.json({ error: 'Playlist not found' }, 404)\n\t\t}\n\n\t\tconst [owner] = await db\n\t\t\t.select({\n\t\t\t\tmid: users.mid,\n\t\t\t\tname: users.name,\n\t\t\t\tavatarUrl: users.face,\n\t\t\t})\n\t\t\t.from(users)\n\t\t\t.where(eq(users.mid, playlist.ownerMid))\n\n\t\tconst [{ count: trackCount }] = await db\n\t\t\t.select({ count: sql<number>`count(*)` })\n\t\t\t.from(sharedPlaylistTracks)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(sharedPlaylistTracks.playlistId, playlistId),\n\t\t\t\t\tisNull(sharedPlaylistTracks.deletedAt),\n\t\t\t\t),\n\t\t\t)\n\n\t\tconst rows = await db\n\t\t\t.select({\n\t\t\t\ttrackUniqueKey: sharedPlaylistTracks.trackUniqueKey,\n\t\t\t\tsortKey: sharedPlaylistTracks.sortKey,\n\t\t\t\ttrack: sharedTracks,\n\t\t\t})\n\t\t\t.from(sharedPlaylistTracks)\n\t\t\t.leftJoin(\n\t\t\t\tsharedTracks,\n\t\t\t\teq(sharedPlaylistTracks.trackUniqueKey, sharedTracks.uniqueKey),\n\t\t\t)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(sharedPlaylistTracks.playlistId, playlistId),\n\t\t\t\t\tisNull(sharedPlaylistTracks.deletedAt),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.orderBy(desc(sharedPlaylistTracks.sortKey))\n\t\t\t.limit(PLAYLIST_PREVIEW_LIMIT)\n\n\t\tconst tracks = rows\n\t\t\t.filter((row) => row.track)\n\t\t\t.map((row) => {\n\t\t\t\tconst t = row.track!\n\t\t\t\treturn {\n\t\t\t\t\tunique_key: t.uniqueKey,\n\t\t\t\t\ttitle: t.title,\n\t\t\t\t\tartist_name: t.artistName ?? undefined,\n\t\t\t\t\tartist_id: t.artistId ?? undefined,\n\t\t\t\t\tcover_url: t.coverUrl ?? undefined,\n\t\t\t\t\tduration: t.duration ?? undefined,\n\t\t\t\t\tbilibili_bvid: t.bilibiliBvid,\n\t\t\t\t\tbilibili_cid: t.bilibiliCid ?? undefined,\n\t\t\t\t\tsort_key: row.sortKey,\n\t\t\t\t}\n\t\t\t})\n\n\t\treturn c.json({\n\t\t\tplaylist: {\n\t\t\t\tid: playlist.id,\n\t\t\t\ttitle: playlist.title,\n\t\t\t\tdescription: playlist.description,\n\t\t\t\tcover_url: playlist.coverUrl,\n\t\t\t\tcreated_at: playlist.createdAt.getTime(),\n\t\t\t\tupdated_at: playlist.updatedAt.getTime(),\n\t\t\t\ttrack_count: Number(trackCount ?? 0),\n\t\t\t},\n\t\t\towner: owner\n\t\t\t\t? {\n\t\t\t\t\t\tmid: owner.mid,\n\t\t\t\t\t\tname: owner.name,\n\t\t\t\t\t\tavatar_url: owner.avatarUrl,\n\t\t\t\t\t}\n\t\t\t\t: null,\n\t\t\ttracks,\n\t\t\tpreview_limit: PLAYLIST_PREVIEW_LIMIT,\n\t\t})\n\t})\n\t// 以下需要鉴权\n\t.use('*', authMiddleware)\n\t.post(\n\t\t'/',\n\t\tarktypeValidator('json', createPlaylistRequestSchema, validationHook),\n\t\tasync (c) => {\n\t\t\tconst { sub } = c.var.jwtPayload\n\t\t\tconst mid = sub\n\t\t\tconst body = c.req.valid('json')\n\t\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\t\t// 三步操作（创建歌单 → 写入 owner 成员 → 可选初始曲目）作为原子事务\n\t\t\tconst playlist = await db.transaction(async (tx) => {\n\t\t\t\t// 1. 创建歌单\n\t\t\t\tconst [newPlaylist] = await tx\n\t\t\t\t\t.insert(sharedPlaylists)\n\t\t\t\t\t.values({\n\t\t\t\t\t\townerMid: mid,\n\t\t\t\t\t\ttitle: body.title,\n\t\t\t\t\t\tdescription: body.description,\n\t\t\t\t\t\tcoverUrl: body.cover_url,\n\t\t\t\t\t})\n\t\t\t\t\t.returning()\n\n\t\t\t\t// 2. 将创建者写入 playlist_members（role=owner）\n\t\t\t\tawait tx.insert(playlistMembers).values({\n\t\t\t\t\tplaylistId: newPlaylist.id,\n\t\t\t\t\tmid,\n\t\t\t\t\trole: 'owner',\n\t\t\t\t})\n\n\t\t\t\t// 3. 可选：携带初始曲目\n\t\t\t\tif (body.tracks?.length) {\n\t\t\t\t\tawait upsertTracks(tx, newPlaylist.id, mid, body.tracks)\n\t\t\t\t}\n\n\t\t\t\treturn newPlaylist\n\t\t\t})\n\n\t\t\treturn c.json({ playlist }, 201)\n\t\t},\n\t)\n\t.patch(\n\t\t'/:id',\n\t\tarktypeValidator('json', updatePlaylistRequestSchema, validationHook),\n\t\tasync (c) => {\n\t\t\tconst { sub } = c.var.jwtPayload\n\t\t\tconst mid = sub\n\t\t\tconst playlistId = c.req.param('id')\n\t\t\tconst body = c.req.valid('json')\n\t\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\t\t// 权限校验\n\t\t\tconst member = await getMember(db, playlistId, mid)\n\t\t\tif (!member || member.role !== 'owner') {\n\t\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t\t}\n\n\t\t\tconst [updated] = await db\n\t\t\t\t.update(sharedPlaylists)\n\t\t\t\t.set({\n\t\t\t\t\t...(body.title !== undefined ? { title: body.title } : {}),\n\t\t\t\t\t...(body.description !== undefined\n\t\t\t\t\t\t? { description: body.description }\n\t\t\t\t\t\t: {}),\n\t\t\t\t\t...(body.cover_url !== undefined ? { coverUrl: body.cover_url } : {}),\n\t\t\t\t\tupdatedAt: new Date(),\n\t\t\t\t})\n\t\t\t\t.where(eq(sharedPlaylists.id, playlistId))\n\t\t\t\t.returning()\n\n\t\t\treturn c.json({ playlist: updated })\n\t\t},\n\t)\n\t.post(\n\t\t'/:id/changes',\n\t\tarktypeValidator('json', playlistChangesRequestSchema, validationHook),\n\t\tasync (c) => {\n\t\t\tconst { sub } = c.var.jwtPayload\n\t\t\tconst mid = sub\n\t\t\tconst playlistId = c.req.param('id')\n\t\t\tconst { changes } = c.req.valid('json')\n\t\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\t\tconst member = await getMember(db, playlistId, mid)\n\t\t\tif (!member || member.role === 'subscriber') {\n\t\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t\t}\n\n\t\t\tif (changes.length === 0) {\n\t\t\t\treturn c.json({ error: 'changes array is required' }, 400)\n\t\t\t}\n\n\t\t\t// 按 operation_at 升序排列，确保 LWW 顺序正确\n\t\t\tconst sorted = [...changes].sort(\n\t\t\t\t(a, b) => a.operation_at - b.operation_at,\n\t\t\t)\n\n\t\t\tconst upsertChanges = sorted.filter((c) => c.op === 'upsert')\n\t\t\tconst removeChanges = sorted.filter((c) => c.op === 'remove')\n\t\t\tconst reorderChanges = sorted.filter((c) => c.op === 'reorder')\n\n\t\t\tawait db.transaction(async (tx) => {\n\t\t\t\t// 1. 批量 upsert shared_tracks（资源池）\n\t\t\t\tif (upsertChanges.length > 0) {\n\t\t\t\t\tawait tx\n\t\t\t\t\t\t.insert(sharedTracks)\n\t\t\t\t\t\t.values(\n\t\t\t\t\t\t\tupsertChanges.map((c) => ({\n\t\t\t\t\t\t\t\tuniqueKey: c.track.unique_key,\n\t\t\t\t\t\t\t\ttitle: c.track.title,\n\t\t\t\t\t\t\t\tartistName: c.track.artist_name,\n\t\t\t\t\t\t\t\tartistId: c.track.artist_id,\n\t\t\t\t\t\t\t\tcoverUrl: c.track.cover_url,\n\t\t\t\t\t\t\t\tduration: c.track.duration,\n\t\t\t\t\t\t\t\tbilibiliBvid: c.track.bilibili_bvid,\n\t\t\t\t\t\t\t\tbilibiliCid: c.track.bilibili_cid,\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.onConflictDoUpdate({\n\t\t\t\t\t\t\ttarget: sharedTracks.uniqueKey,\n\t\t\t\t\t\t\tset: {\n\t\t\t\t\t\t\t\ttitle: sql`excluded.title`,\n\t\t\t\t\t\t\t\tartistName: sql`excluded.artist_name`,\n\t\t\t\t\t\t\t\tcoverUrl: sql`excluded.cover_url`,\n\t\t\t\t\t\t\t\tupdatedAt: sql`excluded.updated_at`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\n\t\t\t\t\t// 2. 批量 upsert shared_playlist_tracks（LWW：用 excluded.updated_at 逐行比较）\n\t\t\t\t\tawait tx\n\t\t\t\t\t\t.insert(sharedPlaylistTracks)\n\t\t\t\t\t\t.values(\n\t\t\t\t\t\t\tupsertChanges.map((c) => ({\n\t\t\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\t\t\ttrackUniqueKey: c.track.unique_key,\n\t\t\t\t\t\t\t\tsortKey: c.sort_key,\n\t\t\t\t\t\t\t\taddedByMid: mid,\n\t\t\t\t\t\t\t\tupdatedAt: new Date(c.operation_at),\n\t\t\t\t\t\t\t\tdeletedAt: null,\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.onConflictDoUpdate({\n\t\t\t\t\t\t\ttarget: [\n\t\t\t\t\t\t\t\tsharedPlaylistTracks.playlistId,\n\t\t\t\t\t\t\t\tsharedPlaylistTracks.trackUniqueKey,\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tset: {\n\t\t\t\t\t\t\t\tsortKey: sql`excluded.sort_key`,\n\t\t\t\t\t\t\t\tupdatedAt: sql`excluded.updated_at`,\n\t\t\t\t\t\t\t\tdeletedAt: null,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tsetWhere: lt(\n\t\t\t\t\t\t\t\tsharedPlaylistTracks.updatedAt,\n\t\t\t\t\t\t\t\tsql`excluded.updated_at`,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// 3. remove（LWW 软删除）- 同步更新 updatedAt，确保后续 LWW 冲突判断正确\n\t\t\t\tawait Promise.all(\n\t\t\t\t\tremoveChanges.map((change) =>\n\t\t\t\t\t\ttx\n\t\t\t\t\t\t\t.update(sharedPlaylistTracks)\n\t\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\t\tdeletedAt: new Date(change.operation_at),\n\t\t\t\t\t\t\t\tupdatedAt: new Date(change.operation_at),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.where(\n\t\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\t\teq(sharedPlaylistTracks.playlistId, playlistId),\n\t\t\t\t\t\t\t\t\teq(\n\t\t\t\t\t\t\t\t\t\tsharedPlaylistTracks.trackUniqueKey,\n\t\t\t\t\t\t\t\t\t\tchange.track_unique_key,\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\tlt(\n\t\t\t\t\t\t\t\t\t\tsharedPlaylistTracks.updatedAt,\n\t\t\t\t\t\t\t\t\t\tnew Date(change.operation_at),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\t// 4. reorder（LWW）- 使用 Batch Upsert 优化 N+1\n\t\t\t\t// 这里利用 INSERT ... ON CONFLICT DO UPDATE 实现批量更新\n\t\t\t\t// 仅需确保 payload 中包含复合主键 (playlistId, trackUniqueKey) 和非空字段 (sortKey)\n\t\t\t\tif (reorderChanges.length > 0) {\n\t\t\t\t\tawait tx\n\t\t\t\t\t\t.insert(sharedPlaylistTracks)\n\t\t\t\t\t\t.values(\n\t\t\t\t\t\t\treorderChanges.map((change) => ({\n\t\t\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\t\t\ttrackUniqueKey: change.track_unique_key,\n\t\t\t\t\t\t\t\tsortKey: change.sort_key,\n\t\t\t\t\t\t\t\tupdatedAt: new Date(change.operation_at),\n\t\t\t\t\t\t\t\t// addedByMid 是 nullable，新建时若无信息可暂空，或填当前操作者\n\t\t\t\t\t\t\t\taddedByMid: mid,\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.onConflictDoUpdate({\n\t\t\t\t\t\t\ttarget: [\n\t\t\t\t\t\t\t\tsharedPlaylistTracks.playlistId,\n\t\t\t\t\t\t\t\tsharedPlaylistTracks.trackUniqueKey,\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tset: {\n\t\t\t\t\t\t\t\tsortKey: sql`excluded.sort_key`,\n\t\t\t\t\t\t\t\tupdatedAt: sql`excluded.updated_at`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t// LWW 逻辑: 只有新操作时间更晚才执行更新\n\t\t\t\t\t\t\tsetWhere: lt(\n\t\t\t\t\t\t\t\tsharedPlaylistTracks.updatedAt,\n\t\t\t\t\t\t\t\tsql`excluded.updated_at`,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tconst appliedAt = Date.now()\n\t\t\treturn c.json({ applied_at: appliedAt })\n\t\t},\n\t)\n\t.get(\n\t\t'/:id/changes',\n\t\tarktypeValidator('query', getPlaylistChangesRequestSchema),\n\t\tasync (c) => {\n\t\t\tconst { sub } = c.var.jwtPayload\n\t\t\tconst mid = sub\n\t\t\tconst playlistId = c.req.param('id')\n\t\t\tconst sinceMs = c.req.valid('query').since\n\t\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\t\t// 先判断歌单是否存在且未被删除\n\t\t\tconst [playlist] = await db\n\t\t\t\t.select()\n\t\t\t\t.from(sharedPlaylists)\n\t\t\t\t.where(\n\t\t\t\t\tand(\n\t\t\t\t\t\teq(sharedPlaylists.id, playlistId),\n\t\t\t\t\t\tisNull(sharedPlaylists.deletedAt),\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\tif (!playlist) {\n\t\t\t\treturn c.json({ error: 'Playlist not found' }, 404)\n\t\t\t}\n\n\t\t\t// 歌单存在时再校验成员关系\n\t\t\tconst member = await getMember(db, playlistId, mid)\n\t\t\tif (!member) {\n\t\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t\t}\n\n\t\t\tconst sinceDate = new Date(sinceMs)\n\t\t\tconst serverTime = Date.now()\n\n\t\t\t// 元数据变更\n\t\t\tconst metadata =\n\t\t\t\tplaylist.updatedAt > sinceDate\n\t\t\t\t\t? {\n\t\t\t\t\t\t\ttitle: playlist.title,\n\t\t\t\t\t\t\tdescription: playlist.description,\n\t\t\t\t\t\t\tcover_url: playlist.coverUrl,\n\t\t\t\t\t\t\tupdated_at: playlist.updatedAt.getTime(),\n\t\t\t\t\t\t}\n\t\t\t\t\t: null\n\n\t\t\t// 曲目变化（updatedAt 或 deletedAt > since）\n\t\t\tconst changedRows = await db\n\t\t\t\t.select({\n\t\t\t\t\ttrackUniqueKey: sharedPlaylistTracks.trackUniqueKey,\n\t\t\t\t\tsortKey: sharedPlaylistTracks.sortKey,\n\t\t\t\t\tupdatedAt: sharedPlaylistTracks.updatedAt,\n\t\t\t\t\tdeletedAt: sharedPlaylistTracks.deletedAt,\n\t\t\t\t\ttrack: sharedTracks,\n\t\t\t\t})\n\t\t\t\t.from(sharedPlaylistTracks)\n\t\t\t\t.leftJoin(\n\t\t\t\t\tsharedTracks,\n\t\t\t\t\teq(sharedPlaylistTracks.trackUniqueKey, sharedTracks.uniqueKey),\n\t\t\t\t)\n\t\t\t\t.where(\n\t\t\t\t\tand(\n\t\t\t\t\t\teq(sharedPlaylistTracks.playlistId, playlistId),\n\t\t\t\t\t\tor(\n\t\t\t\t\t\t\tgt(sharedPlaylistTracks.updatedAt, sinceDate),\n\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\tisNotNull(sharedPlaylistTracks.deletedAt),\n\t\t\t\t\t\t\t\tgt(sharedPlaylistTracks.deletedAt, sinceDate),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\tconst tracks: ChangeEvent[] = changedRows.map((row) => {\n\t\t\t\tif (row.deletedAt && row.deletedAt > sinceDate) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\top: 'delete',\n\t\t\t\t\t\ttrack_unique_key: row.trackUniqueKey,\n\t\t\t\t\t\tdeleted_at: row.deletedAt.getTime(),\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst t = row.track!\n\t\t\t\treturn {\n\t\t\t\t\top: 'upsert',\n\t\t\t\t\ttrack: {\n\t\t\t\t\t\tunique_key: t.uniqueKey,\n\t\t\t\t\t\ttitle: t.title,\n\t\t\t\t\t\tartist_name: t.artistName ?? undefined,\n\t\t\t\t\t\tartist_id: t.artistId ?? undefined,\n\t\t\t\t\t\tcover_url: t.coverUrl ?? undefined,\n\t\t\t\t\t\tduration: t.duration ?? undefined,\n\t\t\t\t\t\tbilibili_bvid: t.bilibiliBvid,\n\t\t\t\t\t\tbilibili_cid: t.bilibiliCid ?? undefined,\n\t\t\t\t\t},\n\t\t\t\t\tsort_key: row.sortKey,\n\t\t\t\t\tupdated_at: row.updatedAt.getTime(),\n\t\t\t\t}\n\t\t\t})\n\n\t\t\t// 成员列表（仅 owner + editor）\n\t\t\tconst members = await db\n\t\t\t\t.select({\n\t\t\t\t\tmid: playlistMembers.mid,\n\t\t\t\t\trole: playlistMembers.role,\n\t\t\t\t\tname: users.name,\n\t\t\t\t\tavatar_url: users.face,\n\t\t\t\t})\n\t\t\t\t.from(playlistMembers)\n\t\t\t\t.innerJoin(users, eq(users.mid, playlistMembers.mid))\n\t\t\t\t.where(\n\t\t\t\t\tand(\n\t\t\t\t\t\teq(playlistMembers.playlistId, playlistId),\n\t\t\t\t\t\tor(\n\t\t\t\t\t\t\teq(playlistMembers.role, 'owner'),\n\t\t\t\t\t\t\teq(playlistMembers.role, 'editor'),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\treturn c.json({\n\t\t\t\tmetadata,\n\t\t\t\ttracks,\n\t\t\t\tmembers: members.map((m) => ({\n\t\t\t\t\t...m,\n\t\t\t\t\tmid: Number(m.mid),\n\t\t\t\t})),\n\t\t\t\thas_more: false,\n\t\t\t\tserver_time: serverTime,\n\t\t\t})\n\t\t},\n\t)\n\t.post(\n\t\t'/:id/subscribe',\n\t\tarktypeValidator('json', subscribePlaylistRequestSchema, validationHook),\n\t\tasync (c) => {\n\t\t\tconst { sub } = c.var.jwtPayload\n\t\t\tconst mid = sub\n\t\t\tconst playlistId = c.req.param('id')\n\t\t\tconst body = c.req.valid('json') ?? {}\n\t\t\tconst inviteCode =\n\t\t\t\ttypeof body?.invite_code === 'string'\n\t\t\t\t\t? body.invite_code.trim()\n\t\t\t\t\t: undefined\n\t\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\t\t// 歌单必须存在且未删除\n\t\t\tconst [playlist] = await db\n\t\t\t\t.select()\n\t\t\t\t.from(sharedPlaylists)\n\t\t\t\t.where(\n\t\t\t\t\tand(\n\t\t\t\t\t\teq(sharedPlaylists.id, playlistId),\n\t\t\t\t\t\tisNull(sharedPlaylists.deletedAt),\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\tif (!playlist) {\n\t\t\t\treturn c.json({ error: 'Playlist not found' }, 404)\n\t\t\t}\n\n\t\t\t// 已是成员：owner/editor 直接返回；subscriber 在邀请码匹配时升级\n\t\t\tconst existing = await getMember(db, playlistId, mid)\n\t\t\tif (existing) {\n\t\t\t\tif (existing.role === 'subscriber') {\n\t\t\t\t\tconst shouldUpgrade =\n\t\t\t\t\t\tinviteCode && playlist.editorInviteCode === inviteCode\n\t\t\t\t\tif (shouldUpgrade) {\n\t\t\t\t\t\tawait db\n\t\t\t\t\t\t\t.update(playlistMembers)\n\t\t\t\t\t\t\t.set({ role: 'editor' })\n\t\t\t\t\t\t\t.where(\n\t\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\t\teq(playlistMembers.playlistId, playlistId),\n\t\t\t\t\t\t\t\t\teq(playlistMembers.mid, mid),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\treturn c.json({\n\t\t\t\t\t\t\trole: 'editor',\n\t\t\t\t\t\t\talready_member: true,\n\t\t\t\t\t\t\tupgraded: true,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn c.json({ role: existing.role, already_member: true })\n\t\t\t}\n\n\t\t\t// 新成员：邀请码匹配则授予 editor，否则为 subscriber\n\t\t\tconst newRole =\n\t\t\t\tinviteCode && playlist.editorInviteCode === inviteCode\n\t\t\t\t\t? 'editor'\n\t\t\t\t\t: 'subscriber'\n\t\t\tawait db.insert(playlistMembers).values({\n\t\t\t\tplaylistId,\n\t\t\t\tmid,\n\t\t\t\trole: newRole,\n\t\t\t})\n\n\t\t\treturn c.json({ role: newRole, already_member: false }, 201)\n\t\t},\n\t)\n\t.get('/:id/invite', async (c) => {\n\t\tconst { sub } = c.var.jwtPayload\n\t\tconst playlistId = c.req.param('id')\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst [playlist] = await db\n\t\t\t.select({\n\t\t\t\townerMid: sharedPlaylists.ownerMid,\n\t\t\t\teditorInviteCode: sharedPlaylists.editorInviteCode,\n\t\t\t})\n\t\t\t.from(sharedPlaylists)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(sharedPlaylists.id, playlistId),\n\t\t\t\t\tisNull(sharedPlaylists.deletedAt),\n\t\t\t\t),\n\t\t\t)\n\n\t\tif (!playlist) {\n\t\t\treturn c.json({ error: 'Playlist not found' }, 404)\n\t\t}\n\t\tif (playlist.ownerMid !== sub) {\n\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t}\n\n\t\treturn c.json({ editor_invite_code: playlist.editorInviteCode ?? null })\n\t})\n\t.post('/:id/invite/rotate', async (c) => {\n\t\tconst { sub } = c.var.jwtPayload\n\t\tconst playlistId = c.req.param('id')\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst [playlist] = await db\n\t\t\t.select({ ownerMid: sharedPlaylists.ownerMid })\n\t\t\t.from(sharedPlaylists)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(sharedPlaylists.id, playlistId),\n\t\t\t\t\tisNull(sharedPlaylists.deletedAt),\n\t\t\t\t),\n\t\t\t)\n\n\t\tif (!playlist) {\n\t\t\treturn c.json({ error: 'Playlist not found' }, 404)\n\t\t}\n\t\tif (playlist.ownerMid !== sub) {\n\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t}\n\n\t\tfor (let attempt = 0; attempt < MAX_INVITE_ROTATE_ATTEMPTS; attempt++) {\n\t\t\tconst newCode = generateInviteCode()\n\t\t\ttry {\n\t\t\t\tawait db\n\t\t\t\t\t.update(sharedPlaylists)\n\t\t\t\t\t.set({ editorInviteCode: newCode })\n\t\t\t\t\t.where(eq(sharedPlaylists.id, playlistId))\n\n\t\t\t\treturn c.json({ editor_invite_code: newCode })\n\t\t\t} catch (err) {\n\t\t\t\tif (isUniqueConstraintViolation(err)) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tthrow err\n\t\t\t}\n\t\t}\n\n\t\treturn c.json({ error: 'Invite code collision, please retry later' }, 503)\n\t})\n\t/**\n\t * DELETE /playlists/:id\n\t * owner 专用：软删除共享歌单（设置 deletedAt）。\n\t * 其他成员若再拉取或订阅此歌单会收到 404。\n\t */\n\t.delete('/:id', async (c) => {\n\t\tconst { sub } = c.var.jwtPayload\n\t\tconst mid = sub\n\t\tconst playlistId = c.req.param('id')\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst member = await getMember(db, playlistId, mid)\n\t\tif (!member || member.role !== 'owner') {\n\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t}\n\n\t\tawait db\n\t\t\t.update(sharedPlaylists)\n\t\t\t.set({ deletedAt: new Date() })\n\t\t\t.where(eq(sharedPlaylists.id, playlistId))\n\n\t\t// 清理成员关系，确保后续请求无法再命中\n\t\tawait db\n\t\t\t.delete(playlistMembers)\n\t\t\t.where(eq(playlistMembers.playlistId, playlistId))\n\n\t\treturn c.json({ deleted: true })\n\t})\n\t/**\n\t * GET /playlists/:id/members\n\t * 获取歌单的所有成员（owner, editor, subscriber）。\n\t * 仅 owner 和 editor 有权限调用。\n\t */\n\t.get('/:id/members', async (c) => {\n\t\tconst { sub } = c.var.jwtPayload\n\t\tconst mid = sub\n\t\tconst playlistId = c.req.param('id')\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst member = await getMember(db, playlistId, mid)\n\t\tif (!member || (member.role !== 'owner' && member.role !== 'editor')) {\n\t\t\treturn c.json({ error: 'Forbidden' }, 403)\n\t\t}\n\n\t\tconst members = await db\n\t\t\t.select({\n\t\t\t\tmid: playlistMembers.mid,\n\t\t\t\trole: playlistMembers.role,\n\t\t\t\tname: users.name,\n\t\t\t\tavatar_url: users.face,\n\t\t\t\tjoined_at: playlistMembers.joinedAt,\n\t\t\t})\n\t\t\t.from(playlistMembers)\n\t\t\t.innerJoin(users, eq(users.mid, playlistMembers.mid))\n\t\t\t.where(eq(playlistMembers.playlistId, playlistId))\n\t\t\t.orderBy(asc(playlistMembers.joinedAt))\n\n\t\treturn c.json({\n\t\t\tmembers: members.map((m) => ({\n\t\t\t\t...m,\n\t\t\t\tmid: Number(m.mid),\n\t\t\t\tjoined_at: m.joined_at.getTime(),\n\t\t\t})),\n\t\t})\n\t})\n\n\t/**\n\t * DELETE /playlists/:id/members/me\n\n\t * subscriber / editor 专用：从 playlist_members 中移除自己，解除与该歌单的关联。\n\t * 幂等：若已不是成员，直接返回成功。\n\t * owner 不能调用此接口（应使用 DELETE /playlists/:id）。\n\t */\n\t.delete('/:id/members/me', async (c) => {\n\t\tconst { sub } = c.var.jwtPayload\n\t\tconst mid = sub\n\t\tconst playlistId = c.req.param('id')\n\t\tconst { db } = await createDb(c.env.DATABASE_URL)\n\n\t\tconst member = await getMember(db, playlistId, mid)\n\t\tif (!member) {\n\t\t\t// 已不是成员，幂等返回成功\n\t\t\treturn c.json({ removed: true })\n\t\t}\n\t\tif (member.role === 'owner') {\n\t\t\treturn c.json(\n\t\t\t\t{ error: 'Owner cannot leave; use DELETE /:id to delete the playlist' },\n\t\t\t\t400,\n\t\t\t)\n\t\t}\n\n\t\tawait db\n\t\t\t.delete(playlistMembers)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(playlistMembers.playlistId, playlistId),\n\t\t\t\t\teq(playlistMembers.mid, mid),\n\t\t\t\t),\n\t\t\t)\n\n\t\treturn c.json({ removed: true })\n\t})\n\n// ---------------------------------------------------------------------------\n// 工具函数\n// ---------------------------------------------------------------------------\nasync function getMember(db: DrizzleDb, playlistId: string, mid: string) {\n\tconst [member] = await db\n\t\t.select()\n\t\t.from(playlistMembers)\n\t\t.where(\n\t\t\tand(\n\t\t\t\teq(playlistMembers.playlistId, playlistId),\n\t\t\t\teq(playlistMembers.mid, mid),\n\t\t\t),\n\t\t)\n\treturn member ?? null\n}\n\nasync function upsertTracks(\n\tdb: DrizzleDb,\n\tplaylistId: string,\n\tmid: string,\n\ttracks: Array<{ track: TrackInput; sort_key: string }>,\n) {\n\tawait db\n\t\t.insert(sharedTracks)\n\t\t.values(\n\t\t\ttracks.map(({ track }) => ({\n\t\t\t\tuniqueKey: track.unique_key,\n\t\t\t\ttitle: track.title,\n\t\t\t\tartistName: track.artist_name,\n\t\t\t\tartistId: track.artist_id,\n\t\t\t\tcoverUrl: track.cover_url,\n\t\t\t\tduration: track.duration,\n\t\t\t\tbilibiliBvid: track.bilibili_bvid,\n\t\t\t\tbilibiliCid: track.bilibili_cid,\n\t\t\t})),\n\t\t)\n\t\t.onConflictDoNothing()\n\n\tawait db\n\t\t.insert(sharedPlaylistTracks)\n\t\t.values(\n\t\t\ttracks.map(({ track, sort_key }) => ({\n\t\t\t\tplaylistId,\n\t\t\t\ttrackUniqueKey: track.unique_key,\n\t\t\t\tsortKey: sort_key,\n\t\t\t\taddedByMid: mid,\n\t\t\t})),\n\t\t)\n\t\t.onConflictDoNothing()\n}\n\nfunction generateInviteCode(): string {\n\tconst chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'\n\tlet out = ''\n\tfor (let i = 0; i < 12; i++) {\n\t\tconst idx = Math.floor(Math.random() * chars.length)\n\t\tout += chars[idx]\n\t}\n\n\treturn 'BBP-' + out\n}\n\nfunction isUniqueConstraintViolation(err: unknown): boolean {\n\tif (!err || typeof err !== 'object') return false\n\tconst code = (err as { code?: unknown }).code\n\treturn code === '23505'\n}\n\nconst MAX_INVITE_ROTATE_ATTEMPTS = 5\n\nexport default playlistsRoute\n"
  },
  {
    "path": "apps/backend/src/types.ts",
    "content": "/** JWT payload 结构 */\nexport interface JwtTokenPayload {\n\tsub: string // B 站 mid（text 存储，避免大数精度丢失）\n\tjwtVersion?: number\n\tiat?: number\n\texp?: number\n\trole?: string\n}\n\n/** POST /api/playlists/:id/changes — 请求体单条变更 */\nexport type ChangeOperation =\n\t| {\n\t\t\top: 'upsert'\n\t\t\ttrack: TrackInput\n\t\t\tsort_key: string\n\t\t\toperation_at: number\n\t  }\n\t| {\n\t\t\top: 'remove'\n\t\t\ttrack_unique_key: string\n\t\t\toperation_at: number\n\t  }\n\t| {\n\t\t\top: 'reorder'\n\t\t\ttrack_unique_key: string\n\t\t\tsort_key: string\n\t\t\toperation_at: number\n\t  }\n\nexport interface TrackInput {\n\tunique_key: string\n\ttitle: string\n\tartist_name?: string\n\tartist_id?: string\n\tcover_url?: string\n\tduration?: number\n\tbilibili_bvid: string\n\tbilibili_cid?: string\n}\n\n/** GET /api/playlists/:id/changes — 响应体单条变更 */\nexport type ChangeEvent =\n\t| {\n\t\t\top: 'upsert'\n\t\t\ttrack: TrackInput\n\t\t\tsort_key: string\n\t\t\tupdated_at: number\n\t  }\n\t| {\n\t\t\top: 'delete'\n\t\t\ttrack_unique_key: string\n\t\t\tdeleted_at: number\n\t  }\n\nexport interface PlaylistMemberInfo {\n\tmid: number\n\tname: string\n\tavatar_url?: string | null\n\trole: 'owner' | 'editor'\n}\n"
  },
  {
    "path": "apps/backend/src/validators/auth.ts",
    "content": "import { type as arkType } from 'arktype'\n\nexport const loginRequestSchema = arkType({\n\tcookie: 'string',\n})\n"
  },
  {
    "path": "apps/backend/src/validators/playlists.ts",
    "content": "import { type as arkType } from 'arktype'\n\nconst trackInputSchema = arkType({\n\tunique_key: 'string',\n\ttitle: 'string',\n\t'artist_name?': 'string',\n\t'artist_id?': 'string',\n\t'cover_url?': 'string',\n\t'duration?': 'number',\n\tbilibili_bvid: 'string',\n\t'bilibili_cid?': 'string',\n})\n\nconst trackWithSortSchema = arkType({\n\ttrack: trackInputSchema,\n\tsort_key: 'string',\n})\n\nconst upsertChangeSchema = arkType({\n\top: \"'upsert'\",\n\ttrack: trackInputSchema,\n\tsort_key: 'string',\n\toperation_at: 'number',\n})\n\nconst removeChangeSchema = arkType({\n\top: \"'remove'\",\n\ttrack_unique_key: 'string',\n\toperation_at: 'number',\n})\n\nconst reorderChangeSchema = arkType({\n\top: \"'reorder'\",\n\ttrack_unique_key: 'string',\n\tsort_key: 'string',\n\toperation_at: 'number',\n})\n\nconst changeOperationSchema = upsertChangeSchema\n\t.or(removeChangeSchema)\n\t.or(reorderChangeSchema)\n\nexport const createPlaylistRequestSchema = arkType({\n\ttitle: 'string',\n\t'description?': 'string',\n\t'cover_url?': 'string',\n\t'tracks?': trackWithSortSchema.array(),\n})\n\nexport const updatePlaylistRequestSchema = arkType({\n\t'title?': 'string',\n\t'description?': 'string',\n\t'cover_url?': 'string',\n})\n\nexport const playlistChangesRequestSchema = arkType({\n\tchanges: changeOperationSchema.array(),\n})\n\nexport const getPlaylistChangesRequestSchema = arkType({\n\tsince: 'string.integer.parse',\n})\n\nexport const subscribePlaylistRequestSchema = arkType({\n\t'invite_code?': 'string',\n})\n"
  },
  {
    "path": "apps/backend/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"module\": \"ESNext\",\n\t\t\"moduleResolution\": \"Bundler\",\n\t\t\"lib\": [\"ESNext\"],\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"noEmit\": true\n\t},\n\t\"include\": [\"src\", \"worker-configuration.d.ts\"]\n}\n"
  },
  {
    "path": "apps/backend/worker-configuration.d.ts",
    "content": "/* eslint-disable */\n// Generated by Wrangler by running `wrangler types` (hash: 88348ad3312309862e1f03e26b965c16)\n// Runtime types generated with workerd@1.20260302.0 2025-02-01 nodejs_compat\ndeclare namespace Cloudflare {\n\tinterface GlobalProps {\n\t\tmainModule: typeof import('./src/index')\n\t}\n\tinterface Env {\n\t\tKV: KVNamespace\n\t\tDATABASE_URL: string\n\t\tJWT_SECRET: string\n\t}\n}\ninterface Env extends Cloudflare.Env {}\n\n// Begin runtime types\n/*! *****************************************************************************\nCopyright (c) Cloudflare. All rights reserved.\nCopyright (c) Microsoft Corporation. All rights reserved.\n\nLicensed under the Apache License, Version 2.0 (the \"License\"); you may not use\nthis file except in compliance with the License. You may obtain a copy of the\nLicense at http://www.apache.org/licenses/LICENSE-2.0\nTHIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\nKIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED\nWARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,\nMERCHANTABLITY OR NON-INFRINGEMENT.\nSee the Apache Version 2.0 License for specific language governing permissions\nand limitations under the License.\n***************************************************************************** */\n/* eslint-disable */\n// noinspection JSUnusedGlobalSymbols\ndeclare var onmessage: never\n/**\n * The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)\n */\ndeclare class DOMException extends Error {\n\tconstructor(message?: string, name?: string)\n\t/**\n\t * The **`message`** read-only property of the a message or description associated with the given error name.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message)\n\t */\n\treadonly message: string\n\t/**\n\t * The **`name`** read-only property of the one of the strings associated with an error name.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name)\n\t */\n\treadonly name: string\n\t/**\n\t * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match.\n\t * @deprecated\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)\n\t */\n\treadonly code: number\n\tstatic readonly INDEX_SIZE_ERR: number\n\tstatic readonly DOMSTRING_SIZE_ERR: number\n\tstatic readonly HIERARCHY_REQUEST_ERR: number\n\tstatic readonly WRONG_DOCUMENT_ERR: number\n\tstatic readonly INVALID_CHARACTER_ERR: number\n\tstatic readonly NO_DATA_ALLOWED_ERR: number\n\tstatic readonly NO_MODIFICATION_ALLOWED_ERR: number\n\tstatic readonly NOT_FOUND_ERR: number\n\tstatic readonly NOT_SUPPORTED_ERR: number\n\tstatic readonly INUSE_ATTRIBUTE_ERR: number\n\tstatic readonly INVALID_STATE_ERR: number\n\tstatic readonly SYNTAX_ERR: number\n\tstatic readonly INVALID_MODIFICATION_ERR: number\n\tstatic readonly NAMESPACE_ERR: number\n\tstatic readonly INVALID_ACCESS_ERR: number\n\tstatic readonly VALIDATION_ERR: number\n\tstatic readonly TYPE_MISMATCH_ERR: number\n\tstatic readonly SECURITY_ERR: number\n\tstatic readonly NETWORK_ERR: number\n\tstatic readonly ABORT_ERR: number\n\tstatic readonly URL_MISMATCH_ERR: number\n\tstatic readonly QUOTA_EXCEEDED_ERR: number\n\tstatic readonly TIMEOUT_ERR: number\n\tstatic readonly INVALID_NODE_TYPE_ERR: number\n\tstatic readonly DATA_CLONE_ERR: number\n\tget stack(): any\n\tset stack(value: any)\n}\ntype WorkerGlobalScopeEventMap = {\n\tfetch: FetchEvent\n\tscheduled: ScheduledEvent\n\tqueue: QueueEvent\n\tunhandledrejection: PromiseRejectionEvent\n\trejectionhandled: PromiseRejectionEvent\n}\ndeclare abstract class WorkerGlobalScope extends EventTarget<WorkerGlobalScopeEventMap> {\n\tEventTarget: typeof EventTarget\n}\n/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). *\n * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox).\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console)\n */\ninterface Console {\n\t'assert'(condition?: boolean, ...data: any[]): void\n\t/**\n\t * The **`console.clear()`** static method clears the console if possible.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static)\n\t */\n\tclear(): void\n\t/**\n\t * The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static)\n\t */\n\tcount(label?: string): void\n\t/**\n\t * The **`console.countReset()`** static method resets counter used with console/count_static.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static)\n\t */\n\tcountReset(label?: string): void\n\t/**\n\t * The **`console.debug()`** static method outputs a message to the console at the 'debug' log level.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static)\n\t */\n\tdebug(...data: any[]): void\n\t/**\n\t * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static)\n\t */\n\tdir(item?: any, options?: any): void\n\t/**\n\t * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static)\n\t */\n\tdirxml(...data: any[]): void\n\t/**\n\t * The **`console.error()`** static method outputs a message to the console at the 'error' log level.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)\n\t */\n\terror(...data: any[]): void\n\t/**\n\t * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static)\n\t */\n\tgroup(...data: any[]): void\n\t/**\n\t * The **`console.groupCollapsed()`** static method creates a new inline group in the console.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static)\n\t */\n\tgroupCollapsed(...data: any[]): void\n\t/**\n\t * The **`console.groupEnd()`** static method exits the current inline group in the console.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static)\n\t */\n\tgroupEnd(): void\n\t/**\n\t * The **`console.info()`** static method outputs a message to the console at the 'info' log level.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)\n\t */\n\tinfo(...data: any[]): void\n\t/**\n\t * The **`console.log()`** static method outputs a message to the console.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)\n\t */\n\tlog(...data: any[]): void\n\t/**\n\t * The **`console.table()`** static method displays tabular data as a table.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static)\n\t */\n\ttable(tabularData?: any, properties?: string[]): void\n\t/**\n\t * The **`console.time()`** static method starts a timer you can use to track how long an operation takes.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static)\n\t */\n\ttime(label?: string): void\n\t/**\n\t * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static)\n\t */\n\ttimeEnd(label?: string): void\n\t/**\n\t * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static)\n\t */\n\ttimeLog(label?: string, ...data: any[]): void\n\ttimeStamp(label?: string): void\n\t/**\n\t * The **`console.trace()`** static method outputs a stack trace to the console.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static)\n\t */\n\ttrace(...data: any[]): void\n\t/**\n\t * The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)\n\t */\n\twarn(...data: any[]): void\n}\ndeclare const console: Console\ntype BufferSource = ArrayBufferView | ArrayBuffer\ntype TypedArray =\n\t| Int8Array\n\t| Uint8Array\n\t| Uint8ClampedArray\n\t| Int16Array\n\t| Uint16Array\n\t| Int32Array\n\t| Uint32Array\n\t| Float32Array\n\t| Float64Array\n\t| BigInt64Array\n\t| BigUint64Array\ndeclare namespace WebAssembly {\n\tclass CompileError extends Error {\n\t\tconstructor(message?: string)\n\t}\n\tclass RuntimeError extends Error {\n\t\tconstructor(message?: string)\n\t}\n\ttype ValueType =\n\t\t| 'anyfunc'\n\t\t| 'externref'\n\t\t| 'f32'\n\t\t| 'f64'\n\t\t| 'i32'\n\t\t| 'i64'\n\t\t| 'v128'\n\tinterface GlobalDescriptor {\n\t\tvalue: ValueType\n\t\tmutable?: boolean\n\t}\n\tclass Global {\n\t\tconstructor(descriptor: GlobalDescriptor, value?: any)\n\t\tvalue: any\n\t\tvalueOf(): any\n\t}\n\ttype ImportValue = ExportValue | number\n\ttype ModuleImports = Record<string, ImportValue>\n\ttype Imports = Record<string, ModuleImports>\n\ttype ExportValue = Function | Global | Memory | Table\n\ttype Exports = Record<string, ExportValue>\n\tclass Instance {\n\t\tconstructor(module: Module, imports?: Imports)\n\t\treadonly exports: Exports\n\t}\n\tinterface MemoryDescriptor {\n\t\tinitial: number\n\t\tmaximum?: number\n\t\tshared?: boolean\n\t}\n\tclass Memory {\n\t\tconstructor(descriptor: MemoryDescriptor)\n\t\treadonly buffer: ArrayBuffer\n\t\tgrow(delta: number): number\n\t}\n\ttype ImportExportKind = 'function' | 'global' | 'memory' | 'table'\n\tinterface ModuleExportDescriptor {\n\t\tkind: ImportExportKind\n\t\tname: string\n\t}\n\tinterface ModuleImportDescriptor {\n\t\tkind: ImportExportKind\n\t\tmodule: string\n\t\tname: string\n\t}\n\tabstract class Module {\n\t\tstatic customSections(module: Module, sectionName: string): ArrayBuffer[]\n\t\tstatic exports(module: Module): ModuleExportDescriptor[]\n\t\tstatic imports(module: Module): ModuleImportDescriptor[]\n\t}\n\ttype TableKind = 'anyfunc' | 'externref'\n\tinterface TableDescriptor {\n\t\telement: TableKind\n\t\tinitial: number\n\t\tmaximum?: number\n\t}\n\tclass Table {\n\t\tconstructor(descriptor: TableDescriptor, value?: any)\n\t\treadonly length: number\n\t\tget(index: number): any\n\t\tgrow(delta: number, value?: any): number\n\t\tset(index: number, value?: any): void\n\t}\n\tfunction instantiate(module: Module, imports?: Imports): Promise<Instance>\n\tfunction validate(bytes: BufferSource): boolean\n}\n/**\n * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker.\n * Available only in secure contexts.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)\n */\ninterface ServiceWorkerGlobalScope extends WorkerGlobalScope {\n\tDOMException: typeof DOMException\n\tWorkerGlobalScope: typeof WorkerGlobalScope\n\tbtoa(data: string): string\n\tatob(data: string): string\n\tsetTimeout(callback: (...args: any[]) => void, msDelay?: number): number\n\tsetTimeout<Args extends any[]>(\n\t\tcallback: (...args: Args) => void,\n\t\tmsDelay?: number,\n\t\t...args: Args\n\t): number\n\tclearTimeout(timeoutId: number | null): void\n\tsetInterval(callback: (...args: any[]) => void, msDelay?: number): number\n\tsetInterval<Args extends any[]>(\n\t\tcallback: (...args: Args) => void,\n\t\tmsDelay?: number,\n\t\t...args: Args\n\t): number\n\tclearInterval(timeoutId: number | null): void\n\tqueueMicrotask(task: Function): void\n\tstructuredClone<T>(value: T, options?: StructuredSerializeOptions): T\n\treportError(error: any): void\n\tfetch(\n\t\tinput: RequestInfo | URL,\n\t\tinit?: RequestInit<RequestInitCfProperties>,\n\t): Promise<Response>\n\tself: ServiceWorkerGlobalScope\n\tcrypto: Crypto\n\tcaches: CacheStorage\n\tscheduler: Scheduler\n\tperformance: Performance\n\tCloudflare: Cloudflare\n\treadonly origin: string\n\tEvent: typeof Event\n\tExtendableEvent: typeof ExtendableEvent\n\tCustomEvent: typeof CustomEvent\n\tPromiseRejectionEvent: typeof PromiseRejectionEvent\n\tFetchEvent: typeof FetchEvent\n\tTailEvent: typeof TailEvent\n\tTraceEvent: typeof TailEvent\n\tScheduledEvent: typeof ScheduledEvent\n\tMessageEvent: typeof MessageEvent\n\tCloseEvent: typeof CloseEvent\n\tReadableStreamDefaultReader: typeof ReadableStreamDefaultReader\n\tReadableStreamBYOBReader: typeof ReadableStreamBYOBReader\n\tReadableStream: typeof ReadableStream\n\tWritableStream: typeof WritableStream\n\tWritableStreamDefaultWriter: typeof WritableStreamDefaultWriter\n\tTransformStream: typeof TransformStream\n\tByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy\n\tCountQueuingStrategy: typeof CountQueuingStrategy\n\tErrorEvent: typeof ErrorEvent\n\tEventSource: typeof EventSource\n\tReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest\n\tReadableStreamDefaultController: typeof ReadableStreamDefaultController\n\tReadableByteStreamController: typeof ReadableByteStreamController\n\tWritableStreamDefaultController: typeof WritableStreamDefaultController\n\tTransformStreamDefaultController: typeof TransformStreamDefaultController\n\tCompressionStream: typeof CompressionStream\n\tDecompressionStream: typeof DecompressionStream\n\tTextEncoderStream: typeof TextEncoderStream\n\tTextDecoderStream: typeof TextDecoderStream\n\tHeaders: typeof Headers\n\tBody: typeof Body\n\tRequest: typeof Request\n\tResponse: typeof Response\n\tWebSocket: typeof WebSocket\n\tWebSocketPair: typeof WebSocketPair\n\tWebSocketRequestResponsePair: typeof WebSocketRequestResponsePair\n\tAbortController: typeof AbortController\n\tAbortSignal: typeof AbortSignal\n\tTextDecoder: typeof TextDecoder\n\tTextEncoder: typeof TextEncoder\n\tnavigator: Navigator\n\tNavigator: typeof Navigator\n\tURL: typeof URL\n\tURLSearchParams: typeof URLSearchParams\n\tURLPattern: typeof URLPattern\n\tBlob: typeof Blob\n\tFile: typeof File\n\tFormData: typeof FormData\n\tCrypto: typeof Crypto\n\tSubtleCrypto: typeof SubtleCrypto\n\tCryptoKey: typeof CryptoKey\n\tCacheStorage: typeof CacheStorage\n\tCache: typeof Cache\n\tFixedLengthStream: typeof FixedLengthStream\n\tIdentityTransformStream: typeof IdentityTransformStream\n\tHTMLRewriter: typeof HTMLRewriter\n}\ndeclare function addEventListener<Type extends keyof WorkerGlobalScopeEventMap>(\n\ttype: Type,\n\thandler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>,\n\toptions?: EventTargetAddEventListenerOptions | boolean,\n): void\ndeclare function removeEventListener<\n\tType extends keyof WorkerGlobalScopeEventMap,\n>(\n\ttype: Type,\n\thandler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>,\n\toptions?: EventTargetEventListenerOptions | boolean,\n): void\n/**\n * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)\n */\ndeclare function dispatchEvent(\n\tevent: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap],\n): boolean\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */\ndeclare function btoa(data: string): string\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */\ndeclare function atob(data: string): string\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */\ndeclare function setTimeout(\n\tcallback: (...args: any[]) => void,\n\tmsDelay?: number,\n): number\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */\ndeclare function setTimeout<Args extends any[]>(\n\tcallback: (...args: Args) => void,\n\tmsDelay?: number,\n\t...args: Args\n): number\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */\ndeclare function clearTimeout(timeoutId: number | null): void\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */\ndeclare function setInterval(\n\tcallback: (...args: any[]) => void,\n\tmsDelay?: number,\n): number\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */\ndeclare function setInterval<Args extends any[]>(\n\tcallback: (...args: Args) => void,\n\tmsDelay?: number,\n\t...args: Args\n): number\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */\ndeclare function clearInterval(timeoutId: number | null): void\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */\ndeclare function queueMicrotask(task: Function): void\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */\ndeclare function structuredClone<T>(\n\tvalue: T,\n\toptions?: StructuredSerializeOptions,\n): T\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */\ndeclare function reportError(error: any): void\n/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */\ndeclare function fetch(\n\tinput: RequestInfo | URL,\n\tinit?: RequestInit<RequestInitCfProperties>,\n): Promise<Response>\ndeclare const self: ServiceWorkerGlobalScope\n/**\n * The Web Crypto API provides a set of low-level functions for common cryptographic tasks.\n * The Workers runtime implements the full surface of this API, but with some differences in\n * the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)\n * compared to those implemented in most browsers.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)\n */\ndeclare const crypto: Crypto\n/**\n * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)\n */\ndeclare const caches: CacheStorage\ndeclare const scheduler: Scheduler\n/**\n * The Workers runtime supports a subset of the Performance API, used to measure timing and performance,\n * as well as timing of subrequests and other operations.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)\n */\ndeclare const performance: Performance\ndeclare const Cloudflare: Cloudflare\ndeclare const origin: string\ndeclare const navigator: Navigator\ninterface TestController {}\ninterface ExecutionContext<Props = unknown> {\n\twaitUntil(promise: Promise<any>): void\n\tpassThroughOnException(): void\n\treadonly props: Props\n}\ntype ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown> = (\n\trequest: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>,\n\tenv: Env,\n\tctx: ExecutionContext,\n) => Response | Promise<Response>\ntype ExportedHandlerTailHandler<Env = unknown> = (\n\tevents: TraceItem[],\n\tenv: Env,\n\tctx: ExecutionContext,\n) => void | Promise<void>\ntype ExportedHandlerTraceHandler<Env = unknown> = (\n\ttraces: TraceItem[],\n\tenv: Env,\n\tctx: ExecutionContext,\n) => void | Promise<void>\ntype ExportedHandlerTailStreamHandler<Env = unknown> = (\n\tevent: TailStream.TailEvent<TailStream.Onset>,\n\tenv: Env,\n\tctx: ExecutionContext,\n) => TailStream.TailEventHandlerType | Promise<TailStream.TailEventHandlerType>\ntype ExportedHandlerScheduledHandler<Env = unknown> = (\n\tcontroller: ScheduledController,\n\tenv: Env,\n\tctx: ExecutionContext,\n) => void | Promise<void>\ntype ExportedHandlerQueueHandler<Env = unknown, Message = unknown> = (\n\tbatch: MessageBatch<Message>,\n\tenv: Env,\n\tctx: ExecutionContext,\n) => void | Promise<void>\ntype ExportedHandlerTestHandler<Env = unknown> = (\n\tcontroller: TestController,\n\tenv: Env,\n\tctx: ExecutionContext,\n) => void | Promise<void>\ninterface ExportedHandler<\n\tEnv = unknown,\n\tQueueHandlerMessage = unknown,\n\tCfHostMetadata = unknown,\n> {\n\tfetch?: ExportedHandlerFetchHandler<Env, CfHostMetadata>\n\ttail?: ExportedHandlerTailHandler<Env>\n\ttrace?: ExportedHandlerTraceHandler<Env>\n\ttailStream?: ExportedHandlerTailStreamHandler<Env>\n\tscheduled?: ExportedHandlerScheduledHandler<Env>\n\ttest?: ExportedHandlerTestHandler<Env>\n\temail?: EmailExportedHandler<Env>\n\tqueue?: ExportedHandlerQueueHandler<Env, QueueHandlerMessage>\n}\ninterface StructuredSerializeOptions {\n\ttransfer?: any[]\n}\ndeclare abstract class Navigator {\n\tsendBeacon(url: string, body?: BodyInit): boolean\n\treadonly userAgent: string\n\treadonly hardwareConcurrency: number\n}\ninterface AlarmInvocationInfo {\n\treadonly isRetry: boolean\n\treadonly retryCount: number\n}\ninterface Cloudflare {\n\treadonly compatibilityFlags: Record<string, boolean>\n}\ninterface DurableObject {\n\tfetch(request: Request): Response | Promise<Response>\n\talarm?(alarmInfo?: AlarmInvocationInfo): void | Promise<void>\n\twebSocketMessage?(\n\t\tws: WebSocket,\n\t\tmessage: string | ArrayBuffer,\n\t): void | Promise<void>\n\twebSocketClose?(\n\t\tws: WebSocket,\n\t\tcode: number,\n\t\treason: string,\n\t\twasClean: boolean,\n\t): void | Promise<void>\n\twebSocketError?(ws: WebSocket, error: unknown): void | Promise<void>\n}\ntype DurableObjectStub<\n\tT extends Rpc.DurableObjectBranded | undefined = undefined,\n> = Fetcher<\n\tT,\n\t'alarm' | 'webSocketMessage' | 'webSocketClose' | 'webSocketError'\n> & {\n\treadonly id: DurableObjectId\n\treadonly name?: string\n}\ninterface DurableObjectId {\n\ttoString(): string\n\tequals(other: DurableObjectId): boolean\n\treadonly name?: string\n}\ndeclare abstract class DurableObjectNamespace<\n\tT extends Rpc.DurableObjectBranded | undefined = undefined,\n> {\n\tnewUniqueId(\n\t\toptions?: DurableObjectNamespaceNewUniqueIdOptions,\n\t): DurableObjectId\n\tidFromName(name: string): DurableObjectId\n\tidFromString(id: string): DurableObjectId\n\tget(\n\t\tid: DurableObjectId,\n\t\toptions?: DurableObjectNamespaceGetDurableObjectOptions,\n\t): DurableObjectStub<T>\n\tgetByName(\n\t\tname: string,\n\t\toptions?: DurableObjectNamespaceGetDurableObjectOptions,\n\t): DurableObjectStub<T>\n\tjurisdiction(\n\t\tjurisdiction: DurableObjectJurisdiction,\n\t): DurableObjectNamespace<T>\n}\ntype DurableObjectJurisdiction = 'eu' | 'fedramp' | 'fedramp-high'\ninterface DurableObjectNamespaceNewUniqueIdOptions {\n\tjurisdiction?: DurableObjectJurisdiction\n}\ntype DurableObjectLocationHint =\n\t| 'wnam'\n\t| 'enam'\n\t| 'sam'\n\t| 'weur'\n\t| 'eeur'\n\t| 'apac'\n\t| 'oc'\n\t| 'afr'\n\t| 'me'\ntype DurableObjectRoutingMode = 'primary-only'\ninterface DurableObjectNamespaceGetDurableObjectOptions {\n\tlocationHint?: DurableObjectLocationHint\n\troutingMode?: DurableObjectRoutingMode\n}\ninterface DurableObjectClass<\n\t_T extends Rpc.DurableObjectBranded | undefined = undefined,\n> {}\ninterface DurableObjectState<Props = unknown> {\n\twaitUntil(promise: Promise<any>): void\n\treadonly props: Props\n\treadonly id: DurableObjectId\n\treadonly storage: DurableObjectStorage\n\tcontainer?: Container\n\tblockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>\n\tacceptWebSocket(ws: WebSocket, tags?: string[]): void\n\tgetWebSockets(tag?: string): WebSocket[]\n\tsetWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void\n\tgetWebSocketAutoResponse(): WebSocketRequestResponsePair | null\n\tgetWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null\n\tsetHibernatableWebSocketEventTimeout(timeoutMs?: number): void\n\tgetHibernatableWebSocketEventTimeout(): number | null\n\tgetTags(ws: WebSocket): string[]\n\tabort(reason?: string): void\n}\ninterface DurableObjectTransaction {\n\tget<T = unknown>(\n\t\tkey: string,\n\t\toptions?: DurableObjectGetOptions,\n\t): Promise<T | undefined>\n\tget<T = unknown>(\n\t\tkeys: string[],\n\t\toptions?: DurableObjectGetOptions,\n\t): Promise<Map<string, T>>\n\tlist<T = unknown>(options?: DurableObjectListOptions): Promise<Map<string, T>>\n\tput<T>(\n\t\tkey: string,\n\t\tvalue: T,\n\t\toptions?: DurableObjectPutOptions,\n\t): Promise<void>\n\tput<T>(\n\t\tentries: Record<string, T>,\n\t\toptions?: DurableObjectPutOptions,\n\t): Promise<void>\n\tdelete(key: string, options?: DurableObjectPutOptions): Promise<boolean>\n\tdelete(keys: string[], options?: DurableObjectPutOptions): Promise<number>\n\trollback(): void\n\tgetAlarm(options?: DurableObjectGetAlarmOptions): Promise<number | null>\n\tsetAlarm(\n\t\tscheduledTime: number | Date,\n\t\toptions?: DurableObjectSetAlarmOptions,\n\t): Promise<void>\n\tdeleteAlarm(options?: DurableObjectSetAlarmOptions): Promise<void>\n}\ninterface DurableObjectStorage {\n\tget<T = unknown>(\n\t\tkey: string,\n\t\toptions?: DurableObjectGetOptions,\n\t): Promise<T | undefined>\n\tget<T = unknown>(\n\t\tkeys: string[],\n\t\toptions?: DurableObjectGetOptions,\n\t): Promise<Map<string, T>>\n\tlist<T = unknown>(options?: DurableObjectListOptions): Promise<Map<string, T>>\n\tput<T>(\n\t\tkey: string,\n\t\tvalue: T,\n\t\toptions?: DurableObjectPutOptions,\n\t): Promise<void>\n\tput<T>(\n\t\tentries: Record<string, T>,\n\t\toptions?: DurableObjectPutOptions,\n\t): Promise<void>\n\tdelete(key: string, options?: DurableObjectPutOptions): Promise<boolean>\n\tdelete(keys: string[], options?: DurableObjectPutOptions): Promise<number>\n\tdeleteAll(options?: DurableObjectPutOptions): Promise<void>\n\ttransaction<T>(\n\t\tclosure: (txn: DurableObjectTransaction) => Promise<T>,\n\t): Promise<T>\n\tgetAlarm(options?: DurableObjectGetAlarmOptions): Promise<number | null>\n\tsetAlarm(\n\t\tscheduledTime: number | Date,\n\t\toptions?: DurableObjectSetAlarmOptions,\n\t): Promise<void>\n\tdeleteAlarm(options?: DurableObjectSetAlarmOptions): Promise<void>\n\tsync(): Promise<void>\n\tsql: SqlStorage\n\tkv: SyncKvStorage\n\ttransactionSync<T>(closure: () => T): T\n\tgetCurrentBookmark(): Promise<string>\n\tgetBookmarkForTime(timestamp: number | Date): Promise<string>\n\tonNextSessionRestoreBookmark(bookmark: string): Promise<string>\n}\ninterface DurableObjectListOptions {\n\tstart?: string\n\tstartAfter?: string\n\tend?: string\n\tprefix?: string\n\treverse?: boolean\n\tlimit?: number\n\tallowConcurrency?: boolean\n\tnoCache?: boolean\n}\ninterface DurableObjectGetOptions {\n\tallowConcurrency?: boolean\n\tnoCache?: boolean\n}\ninterface DurableObjectGetAlarmOptions {\n\tallowConcurrency?: boolean\n}\ninterface DurableObjectPutOptions {\n\tallowConcurrency?: boolean\n\tallowUnconfirmed?: boolean\n\tnoCache?: boolean\n}\ninterface DurableObjectSetAlarmOptions {\n\tallowConcurrency?: boolean\n\tallowUnconfirmed?: boolean\n}\ndeclare class WebSocketRequestResponsePair {\n\tconstructor(request: string, response: string)\n\tget request(): string\n\tget response(): string\n}\ninterface AnalyticsEngineDataset {\n\twriteDataPoint(event?: AnalyticsEngineDataPoint): void\n}\ninterface AnalyticsEngineDataPoint {\n\tindexes?: ((ArrayBuffer | string) | null)[]\n\tdoubles?: number[]\n\tblobs?: ((ArrayBuffer | string) | null)[]\n}\n/**\n * The **`Event`** interface represents an event which takes place on an `EventTarget`.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event)\n */\ndeclare class Event {\n\tconstructor(type: string, init?: EventInit)\n\t/**\n\t * The **`type`** read-only property of the Event interface returns a string containing the event's type.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type)\n\t */\n\tget type(): string\n\t/**\n\t * The **`eventPhase`** read-only property of the being evaluated.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase)\n\t */\n\tget eventPhase(): number\n\t/**\n\t * The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed)\n\t */\n\tget composed(): boolean\n\t/**\n\t * The **`bubbles`** read-only property of the Event interface indicates whether the event bubbles up through the DOM tree or not.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles)\n\t */\n\tget bubbles(): boolean\n\t/**\n\t * The **`cancelable`** read-only property of the Event interface indicates whether the event can be canceled, and therefore prevented as if the event never happened.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable)\n\t */\n\tget cancelable(): boolean\n\t/**\n\t * The **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented)\n\t */\n\tget defaultPrevented(): boolean\n\t/**\n\t * The Event property **`returnValue`** indicates whether the default action for this event has been prevented or not.\n\t * @deprecated\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue)\n\t */\n\tget returnValue(): boolean\n\t/**\n\t * The **`currentTarget`** read-only property of the Event interface identifies the element to which the event handler has been attached.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget)\n\t */\n\tget currentTarget(): EventTarget | undefined\n\t/**\n\t * The read-only **`target`** property of the dispatched.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target)\n\t */\n\tget target(): EventTarget | undefined\n\t/**\n\t * The deprecated **`Event.srcElement`** is an alias for the Event.target property.\n\t * @deprecated\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)\n\t */\n\tget srcElement(): EventTarget | undefined\n\t/**\n\t * The **`timeStamp`** read-only property of the Event interface returns the time (in milliseconds) at which the event was created.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp)\n\t */\n\tget timeStamp(): number\n\t/**\n\t * The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted)\n\t */\n\tget isTrusted(): boolean\n\t/**\n\t * The **`cancelBubble`** property of the Event interface is deprecated.\n\t * @deprecated\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)\n\t */\n\tget cancelBubble(): boolean\n\t/**\n\t * The **`cancelBubble`** property of the Event interface is deprecated.\n\t * @deprecated\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)\n\t */\n\tset cancelBubble(value: boolean)\n\t/**\n\t * The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation)\n\t */\n\tstopImmediatePropagation(): void\n\t/**\n\t * The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault)\n\t */\n\tpreventDefault(): void\n\t/**\n\t * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation)\n\t */\n\tstopPropagation(): void\n\t/**\n\t * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath)\n\t */\n\tcomposedPath(): EventTarget[]\n\tstatic readonly NONE: number\n\tstatic readonly CAPTURING_PHASE: number\n\tstatic readonly AT_TARGET: number\n\tstatic readonly BUBBLING_PHASE: number\n}\ninterface EventInit {\n\tbubbles?: boolean\n\tcancelable?: boolean\n\tcomposed?: boolean\n}\ntype EventListener<EventType extends Event = Event> = (event: EventType) => void\ninterface EventListenerObject<EventType extends Event = Event> {\n\thandleEvent(event: EventType): void\n}\ntype EventListenerOrEventListenerObject<EventType extends Event = Event> =\n\t| EventListener<EventType>\n\t| EventListenerObject<EventType>\n/**\n * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget)\n */\ndeclare class EventTarget<\n\tEventMap extends Record<string, Event> = Record<string, Event>,\n> {\n\tconstructor()\n\t/**\n\t * The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)\n\t */\n\taddEventListener<Type extends keyof EventMap>(\n\t\ttype: Type,\n\t\thandler: EventListenerOrEventListenerObject<EventMap[Type]>,\n\t\toptions?: EventTargetAddEventListenerOptions | boolean,\n\t): void\n\t/**\n\t * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)\n\t */\n\tremoveEventListener<Type extends keyof EventMap>(\n\t\ttype: Type,\n\t\thandler: EventListenerOrEventListenerObject<EventMap[Type]>,\n\t\toptions?: EventTargetEventListenerOptions | boolean,\n\t): void\n\t/**\n\t * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)\n\t */\n\tdispatchEvent(event: EventMap[keyof EventMap]): boolean\n}\ninterface EventTargetEventListenerOptions {\n\tcapture?: boolean\n}\ninterface EventTargetAddEventListenerOptions {\n\tcapture?: boolean\n\tpassive?: boolean\n\tonce?: boolean\n\tsignal?: AbortSignal\n}\ninterface EventTargetHandlerObject {\n\thandleEvent: (event: Event) => any | undefined\n}\n/**\n * The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)\n */\ndeclare class AbortController {\n\tconstructor()\n\t/**\n\t * The **`signal`** read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)\n\t */\n\tget signal(): AbortSignal\n\t/**\n\t * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)\n\t */\n\tabort(reason?: any): void\n}\n/**\n * The **`AbortSignal`** interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal)\n */\ndeclare abstract class AbortSignal extends EventTarget {\n\t/**\n\t * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event).\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static)\n\t */\n\tstatic abort(reason?: any): AbortSignal\n\t/**\n\t * The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static)\n\t */\n\tstatic timeout(delay: number): AbortSignal\n\t/**\n\t * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static)\n\t */\n\tstatic any(signals: AbortSignal[]): AbortSignal\n\t/**\n\t * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`).\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)\n\t */\n\tget aborted(): boolean\n\t/**\n\t * The **`reason`** read-only property returns a JavaScript value that indicates the abort reason.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason)\n\t */\n\tget reason(): any\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */\n\tget onabort(): any | null\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */\n\tset onabort(value: any | null)\n\t/**\n\t * The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted)\n\t */\n\tthrowIfAborted(): void\n}\ninterface Scheduler {\n\twait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>\n}\ninterface SchedulerWaitOptions {\n\tsignal?: AbortSignal\n}\n/**\n * The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent)\n */\ndeclare abstract class ExtendableEvent extends Event {\n\t/**\n\t * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil)\n\t */\n\twaitUntil(promise: Promise<any>): void\n}\n/**\n * The **`CustomEvent`** interface represents events initialized by an application for any purpose.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent)\n */\ndeclare class CustomEvent<T = any> extends Event {\n\tconstructor(type: string, init?: CustomEventCustomEventInit)\n\t/**\n\t * The read-only **`detail`** property of the CustomEvent interface returns any data passed when initializing the event.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail)\n\t */\n\tget detail(): T\n}\ninterface CustomEventCustomEventInit {\n\tbubbles?: boolean\n\tcancelable?: boolean\n\tcomposed?: boolean\n\tdetail?: any\n}\n/**\n * The **`Blob`** interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob)\n */\ndeclare class Blob {\n\tconstructor(\n\t\ttype?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[],\n\t\toptions?: BlobOptions,\n\t)\n\t/**\n\t * The **`size`** read-only property of the Blob interface returns the size of the Blob or File in bytes.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size)\n\t */\n\tget size(): number\n\t/**\n\t * The **`type`** read-only property of the Blob interface returns the MIME type of the file.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type)\n\t */\n\tget type(): string\n\t/**\n\t * The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice)\n\t */\n\tslice(start?: number, end?: number, type?: string): Blob\n\t/**\n\t * The **`arrayBuffer()`** method of the Blob interface returns a Promise that resolves with the contents of the blob as binary data contained in an ArrayBuffer.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer)\n\t */\n\tarrayBuffer(): Promise<ArrayBuffer>\n\t/**\n\t * The **`bytes()`** method of the Blob interface returns a Promise that resolves with a Uint8Array containing the contents of the blob as an array of bytes.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes)\n\t */\n\tbytes(): Promise<Uint8Array>\n\t/**\n\t * The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text)\n\t */\n\ttext(): Promise<string>\n\t/**\n\t * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream)\n\t */\n\tstream(): ReadableStream\n}\ninterface BlobOptions {\n\ttype?: string\n}\n/**\n * The **`File`** interface provides information about files and allows JavaScript in a web page to access their content.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File)\n */\ndeclare class File extends Blob {\n\tconstructor(\n\t\tbits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined,\n\t\tname: string,\n\t\toptions?: FileOptions,\n\t)\n\t/**\n\t * The **`name`** read-only property of the File interface returns the name of the file represented by a File object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name)\n\t */\n\tget name(): string\n\t/**\n\t * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight).\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified)\n\t */\n\tget lastModified(): number\n}\ninterface FileOptions {\n\ttype?: string\n\tlastModified?: number\n}\n/**\n * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)\n */\ndeclare abstract class CacheStorage {\n\t/**\n\t * The **`open()`** method of the the Cache object matching the `cacheName`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open)\n\t */\n\topen(cacheName: string): Promise<Cache>\n\treadonly default: Cache\n}\n/**\n * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)\n */\ndeclare abstract class Cache {\n\t/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */\n\tdelete(\n\t\trequest: RequestInfo | URL,\n\t\toptions?: CacheQueryOptions,\n\t): Promise<boolean>\n\t/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */\n\tmatch(\n\t\trequest: RequestInfo | URL,\n\t\toptions?: CacheQueryOptions,\n\t): Promise<Response | undefined>\n\t/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */\n\tput(request: RequestInfo | URL, response: Response): Promise<void>\n}\ninterface CacheQueryOptions {\n\tignoreMethod?: boolean\n}\n/**\n * The Web Crypto API provides a set of low-level functions for common cryptographic tasks.\n * The Workers runtime implements the full surface of this API, but with some differences in\n * the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)\n * compared to those implemented in most browsers.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)\n */\ndeclare abstract class Crypto {\n\t/**\n\t * The **`Crypto.subtle`** read-only property returns a cryptographic operations.\n\t * Available only in secure contexts.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)\n\t */\n\tget subtle(): SubtleCrypto\n\t/**\n\t * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues)\n\t */\n\tgetRandomValues<\n\t\tT extends\n\t\t\t| Int8Array\n\t\t\t| Uint8Array\n\t\t\t| Int16Array\n\t\t\t| Uint16Array\n\t\t\t| Int32Array\n\t\t\t| Uint32Array\n\t\t\t| BigInt64Array\n\t\t\t| BigUint64Array,\n\t>(buffer: T): T\n\t/**\n\t * The **`randomUUID()`** method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.\n\t * Available only in secure contexts.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID)\n\t */\n\trandomUUID(): string\n\tDigestStream: typeof DigestStream\n}\n/**\n * The **`SubtleCrypto`** interface of the Web Crypto API provides a number of low-level cryptographic functions.\n * Available only in secure contexts.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto)\n */\ndeclare abstract class SubtleCrypto {\n\t/**\n\t * The **`encrypt()`** method of the SubtleCrypto interface encrypts data.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt)\n\t */\n\tencrypt(\n\t\talgorithm: string | SubtleCryptoEncryptAlgorithm,\n\t\tkey: CryptoKey,\n\t\tplainText: ArrayBuffer | ArrayBufferView,\n\t): Promise<ArrayBuffer>\n\t/**\n\t * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt)\n\t */\n\tdecrypt(\n\t\talgorithm: string | SubtleCryptoEncryptAlgorithm,\n\t\tkey: CryptoKey,\n\t\tcipherText: ArrayBuffer | ArrayBufferView,\n\t): Promise<ArrayBuffer>\n\t/**\n\t * The **`sign()`** method of the SubtleCrypto interface generates a digital signature.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign)\n\t */\n\tsign(\n\t\talgorithm: string | SubtleCryptoSignAlgorithm,\n\t\tkey: CryptoKey,\n\t\tdata: ArrayBuffer | ArrayBufferView,\n\t): Promise<ArrayBuffer>\n\t/**\n\t * The **`verify()`** method of the SubtleCrypto interface verifies a digital signature.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify)\n\t */\n\tverify(\n\t\talgorithm: string | SubtleCryptoSignAlgorithm,\n\t\tkey: CryptoKey,\n\t\tsignature: ArrayBuffer | ArrayBufferView,\n\t\tdata: ArrayBuffer | ArrayBufferView,\n\t): Promise<boolean>\n\t/**\n\t * The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest)\n\t */\n\tdigest(\n\t\talgorithm: string | SubtleCryptoHashAlgorithm,\n\t\tdata: ArrayBuffer | ArrayBufferView,\n\t): Promise<ArrayBuffer>\n\t/**\n\t * The **`generateKey()`** method of the SubtleCrypto interface is used to generate a new key (for symmetric algorithms) or key pair (for public-key algorithms).\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey)\n\t */\n\tgenerateKey(\n\t\talgorithm: string | SubtleCryptoGenerateKeyAlgorithm,\n\t\textractable: boolean,\n\t\tkeyUsages: string[],\n\t): Promise<CryptoKey | CryptoKeyPair>\n\t/**\n\t * The **`deriveKey()`** method of the SubtleCrypto interface can be used to derive a secret key from a master key.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey)\n\t */\n\tderiveKey(\n\t\talgorithm: string | SubtleCryptoDeriveKeyAlgorithm,\n\t\tbaseKey: CryptoKey,\n\t\tderivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm,\n\t\textractable: boolean,\n\t\tkeyUsages: string[],\n\t): Promise<CryptoKey>\n\t/**\n\t * The **`deriveBits()`** method of the key.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits)\n\t */\n\tderiveBits(\n\t\talgorithm: string | SubtleCryptoDeriveKeyAlgorithm,\n\t\tbaseKey: CryptoKey,\n\t\tlength?: number | null,\n\t): Promise<ArrayBuffer>\n\t/**\n\t * The **`importKey()`** method of the SubtleCrypto interface imports a key: that is, it takes as input a key in an external, portable format and gives you a CryptoKey object that you can use in the Web Crypto API.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey)\n\t */\n\timportKey(\n\t\tformat: string,\n\t\tkeyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey,\n\t\talgorithm: string | SubtleCryptoImportKeyAlgorithm,\n\t\textractable: boolean,\n\t\tkeyUsages: string[],\n\t): Promise<CryptoKey>\n\t/**\n\t * The **`exportKey()`** method of the SubtleCrypto interface exports a key: that is, it takes as input a CryptoKey object and gives you the key in an external, portable format.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey)\n\t */\n\texportKey(format: string, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>\n\t/**\n\t * The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey)\n\t */\n\twrapKey(\n\t\tformat: string,\n\t\tkey: CryptoKey,\n\t\twrappingKey: CryptoKey,\n\t\twrapAlgorithm: string | SubtleCryptoEncryptAlgorithm,\n\t): Promise<ArrayBuffer>\n\t/**\n\t * The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey)\n\t */\n\tunwrapKey(\n\t\tformat: string,\n\t\twrappedKey: ArrayBuffer | ArrayBufferView,\n\t\tunwrappingKey: CryptoKey,\n\t\tunwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm,\n\t\tunwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm,\n\t\textractable: boolean,\n\t\tkeyUsages: string[],\n\t): Promise<CryptoKey>\n\ttimingSafeEqual(\n\t\ta: ArrayBuffer | ArrayBufferView,\n\t\tb: ArrayBuffer | ArrayBufferView,\n\t): boolean\n}\n/**\n * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey.\n * Available only in secure contexts.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey)\n */\ndeclare abstract class CryptoKey {\n\t/**\n\t * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type)\n\t */\n\treadonly type: string\n\t/**\n\t * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable)\n\t */\n\treadonly extractable: boolean\n\t/**\n\t * The read-only **`algorithm`** property of the CryptoKey interface returns an object describing the algorithm for which this key can be used, and any associated extra parameters.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm)\n\t */\n\treadonly algorithm:\n\t\t| CryptoKeyKeyAlgorithm\n\t\t| CryptoKeyAesKeyAlgorithm\n\t\t| CryptoKeyHmacKeyAlgorithm\n\t\t| CryptoKeyRsaKeyAlgorithm\n\t\t| CryptoKeyEllipticKeyAlgorithm\n\t\t| CryptoKeyArbitraryKeyAlgorithm\n\t/**\n\t * The read-only **`usages`** property of the CryptoKey interface indicates what can be done with the key.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages)\n\t */\n\treadonly usages: string[]\n}\ninterface CryptoKeyPair {\n\tpublicKey: CryptoKey\n\tprivateKey: CryptoKey\n}\ninterface JsonWebKey {\n\tkty: string\n\tuse?: string\n\tkey_ops?: string[]\n\talg?: string\n\text?: boolean\n\tcrv?: string\n\tx?: string\n\ty?: string\n\td?: string\n\tn?: string\n\te?: string\n\tp?: string\n\tq?: string\n\tdp?: string\n\tdq?: string\n\tqi?: string\n\toth?: RsaOtherPrimesInfo[]\n\tk?: string\n}\ninterface RsaOtherPrimesInfo {\n\tr?: string\n\td?: string\n\tt?: string\n}\ninterface SubtleCryptoDeriveKeyAlgorithm {\n\tname: string\n\tsalt?: ArrayBuffer | ArrayBufferView\n\titerations?: number\n\thash?: string | SubtleCryptoHashAlgorithm\n\t$public?: CryptoKey\n\tinfo?: ArrayBuffer | ArrayBufferView\n}\ninterface SubtleCryptoEncryptAlgorithm {\n\tname: string\n\tiv?: ArrayBuffer | ArrayBufferView\n\tadditionalData?: ArrayBuffer | ArrayBufferView\n\ttagLength?: number\n\tcounter?: ArrayBuffer | ArrayBufferView\n\tlength?: number\n\tlabel?: ArrayBuffer | ArrayBufferView\n}\ninterface SubtleCryptoGenerateKeyAlgorithm {\n\tname: string\n\thash?: string | SubtleCryptoHashAlgorithm\n\tmodulusLength?: number\n\tpublicExponent?: ArrayBuffer | ArrayBufferView\n\tlength?: number\n\tnamedCurve?: string\n}\ninterface SubtleCryptoHashAlgorithm {\n\tname: string\n}\ninterface SubtleCryptoImportKeyAlgorithm {\n\tname: string\n\thash?: string | SubtleCryptoHashAlgorithm\n\tlength?: number\n\tnamedCurve?: string\n\tcompressed?: boolean\n}\ninterface SubtleCryptoSignAlgorithm {\n\tname: string\n\thash?: string | SubtleCryptoHashAlgorithm\n\tdataLength?: number\n\tsaltLength?: number\n}\ninterface CryptoKeyKeyAlgorithm {\n\tname: string\n}\ninterface CryptoKeyAesKeyAlgorithm {\n\tname: string\n\tlength: number\n}\ninterface CryptoKeyHmacKeyAlgorithm {\n\tname: string\n\thash: CryptoKeyKeyAlgorithm\n\tlength: number\n}\ninterface CryptoKeyRsaKeyAlgorithm {\n\tname: string\n\tmodulusLength: number\n\tpublicExponent: ArrayBuffer | ArrayBufferView\n\thash?: CryptoKeyKeyAlgorithm\n}\ninterface CryptoKeyEllipticKeyAlgorithm {\n\tname: string\n\tnamedCurve: string\n}\ninterface CryptoKeyArbitraryKeyAlgorithm {\n\tname: string\n\thash?: CryptoKeyKeyAlgorithm\n\tnamedCurve?: string\n\tlength?: number\n}\ndeclare class DigestStream extends WritableStream<\n\tArrayBuffer | ArrayBufferView\n> {\n\tconstructor(algorithm: string | SubtleCryptoHashAlgorithm)\n\treadonly digest: Promise<ArrayBuffer>\n\tget bytesWritten(): number | bigint\n}\n/**\n * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder)\n */\ndeclare class TextDecoder {\n\tconstructor(label?: string, options?: TextDecoderConstructorOptions)\n\t/**\n\t * The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)\n\t */\n\tdecode(\n\t\tinput?: ArrayBuffer | ArrayBufferView,\n\t\toptions?: TextDecoderDecodeOptions,\n\t): string\n\tget encoding(): string\n\tget fatal(): boolean\n\tget ignoreBOM(): boolean\n}\n/**\n * The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)\n */\ndeclare class TextEncoder {\n\tconstructor()\n\t/**\n\t * The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)\n\t */\n\tencode(input?: string): Uint8Array\n\t/**\n\t * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto)\n\t */\n\tencodeInto(input: string, buffer: Uint8Array): TextEncoderEncodeIntoResult\n\tget encoding(): string\n}\ninterface TextDecoderConstructorOptions {\n\tfatal: boolean\n\tignoreBOM: boolean\n}\ninterface TextDecoderDecodeOptions {\n\tstream: boolean\n}\ninterface TextEncoderEncodeIntoResult {\n\tread: number\n\twritten: number\n}\n/**\n * The **`ErrorEvent`** interface represents events providing information related to errors in scripts or in files.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent)\n */\ndeclare class ErrorEvent extends Event {\n\tconstructor(type: string, init?: ErrorEventErrorEventInit)\n\t/**\n\t * The **`filename`** read-only property of the ErrorEvent interface returns a string containing the name of the script file in which the error occurred.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename)\n\t */\n\tget filename(): string\n\t/**\n\t * The **`message`** read-only property of the ErrorEvent interface returns a string containing a human-readable error message describing the problem.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message)\n\t */\n\tget message(): string\n\t/**\n\t * The **`lineno`** read-only property of the ErrorEvent interface returns an integer containing the line number of the script file on which the error occurred.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno)\n\t */\n\tget lineno(): number\n\t/**\n\t * The **`colno`** read-only property of the ErrorEvent interface returns an integer containing the column number of the script file on which the error occurred.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno)\n\t */\n\tget colno(): number\n\t/**\n\t * The **`error`** read-only property of the ErrorEvent interface returns a JavaScript value, such as an Error or DOMException, representing the error associated with this event.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error)\n\t */\n\tget error(): any\n}\ninterface ErrorEventErrorEventInit {\n\tmessage?: string\n\tfilename?: string\n\tlineno?: number\n\tcolno?: number\n\terror?: any\n}\n/**\n * The **`MessageEvent`** interface represents a message received by a target object.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)\n */\ndeclare class MessageEvent extends Event {\n\tconstructor(type: string, initializer: MessageEventInit)\n\t/**\n\t * The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data)\n\t */\n\treadonly data: any\n\t/**\n\t * The **`origin`** read-only property of the origin of the message emitter.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin)\n\t */\n\treadonly origin: string | null\n\t/**\n\t * The **`lastEventId`** read-only property of the unique ID for the event.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId)\n\t */\n\treadonly lastEventId: string\n\t/**\n\t * The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source)\n\t */\n\treadonly source: MessagePort | null\n\t/**\n\t * The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports)\n\t */\n\treadonly ports: MessagePort[]\n}\ninterface MessageEventInit {\n\tdata: ArrayBuffer | string\n}\n/**\n * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent)\n */\ndeclare abstract class PromiseRejectionEvent extends Event {\n\t/**\n\t * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise)\n\t */\n\treadonly promise: Promise<any>\n\t/**\n\t * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject().\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason)\n\t */\n\treadonly reason: any\n}\n/**\n * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData)\n */\ndeclare class FormData {\n\tconstructor()\n\t/**\n\t * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)\n\t */\n\tappend(name: string, value: string | Blob): void\n\t/**\n\t * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)\n\t */\n\tappend(name: string, value: string): void\n\t/**\n\t * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)\n\t */\n\tappend(name: string, value: Blob, filename?: string): void\n\t/**\n\t * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete)\n\t */\n\tdelete(name: string): void\n\t/**\n\t * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get)\n\t */\n\tget(name: string): (File | string) | null\n\t/**\n\t * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll)\n\t */\n\tgetAll(name: string): (File | string)[]\n\t/**\n\t * The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has)\n\t */\n\thas(name: string): boolean\n\t/**\n\t * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)\n\t */\n\tset(name: string, value: string | Blob): void\n\t/**\n\t * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)\n\t */\n\tset(name: string, value: string): void\n\t/**\n\t * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)\n\t */\n\tset(name: string, value: Blob, filename?: string): void\n\t/* Returns an array of key, value pairs for every entry in the list. */\n\tentries(): IterableIterator<[key: string, value: File | string]>\n\t/* Returns a list of keys in the list. */\n\tkeys(): IterableIterator<string>\n\t/* Returns a list of values in the list. */\n\tvalues(): IterableIterator<File | string>\n\tforEach<This = unknown>(\n\t\tcallback: (\n\t\t\tthis: This,\n\t\t\tvalue: File | string,\n\t\t\tkey: string,\n\t\t\tparent: FormData,\n\t\t) => void,\n\t\tthisArg?: This,\n\t): void\n\t[Symbol.iterator](): IterableIterator<[key: string, value: File | string]>\n}\ninterface ContentOptions {\n\thtml?: boolean\n}\ndeclare class HTMLRewriter {\n\tconstructor()\n\ton(\n\t\tselector: string,\n\t\thandlers: HTMLRewriterElementContentHandlers,\n\t): HTMLRewriter\n\tonDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter\n\ttransform(response: Response): Response\n}\ninterface HTMLRewriterElementContentHandlers {\n\telement?(element: Element): void | Promise<void>\n\tcomments?(comment: Comment): void | Promise<void>\n\ttext?(element: Text): void | Promise<void>\n}\ninterface HTMLRewriterDocumentContentHandlers {\n\tdoctype?(doctype: Doctype): void | Promise<void>\n\tcomments?(comment: Comment): void | Promise<void>\n\ttext?(text: Text): void | Promise<void>\n\tend?(end: DocumentEnd): void | Promise<void>\n}\ninterface Doctype {\n\treadonly name: string | null\n\treadonly publicId: string | null\n\treadonly systemId: string | null\n}\ninterface Element {\n\ttagName: string\n\treadonly attributes: IterableIterator<string[]>\n\treadonly removed: boolean\n\treadonly namespaceURI: string\n\tgetAttribute(name: string): string | null\n\thasAttribute(name: string): boolean\n\tsetAttribute(name: string, value: string): Element\n\tremoveAttribute(name: string): Element\n\tbefore(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Element\n\tafter(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Element\n\tprepend(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Element\n\tappend(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Element\n\treplace(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Element\n\tremove(): Element\n\tremoveAndKeepContent(): Element\n\tsetInnerContent(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Element\n\tonEndTag(handler: (tag: EndTag) => void | Promise<void>): void\n}\ninterface EndTag {\n\tname: string\n\tbefore(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): EndTag\n\tafter(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): EndTag\n\tremove(): EndTag\n}\ninterface Comment {\n\ttext: string\n\treadonly removed: boolean\n\tbefore(content: string, options?: ContentOptions): Comment\n\tafter(content: string, options?: ContentOptions): Comment\n\treplace(content: string, options?: ContentOptions): Comment\n\tremove(): Comment\n}\ninterface Text {\n\treadonly text: string\n\treadonly lastInTextNode: boolean\n\treadonly removed: boolean\n\tbefore(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Text\n\tafter(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Text\n\treplace(\n\t\tcontent: string | ReadableStream | Response,\n\t\toptions?: ContentOptions,\n\t): Text\n\tremove(): Text\n}\ninterface DocumentEnd {\n\tappend(content: string, options?: ContentOptions): DocumentEnd\n}\n/**\n * This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent)\n */\ndeclare abstract class FetchEvent extends ExtendableEvent {\n\t/**\n\t * The **`request`** read-only property of the the event handler.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request)\n\t */\n\treadonly request: Request\n\t/**\n\t * The **`respondWith()`** method of allows you to provide a promise for a Response yourself.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith)\n\t */\n\trespondWith(promise: Response | Promise<Response>): void\n\tpassThroughOnException(): void\n}\ntype HeadersInit = Headers | Iterable<Iterable<string>> | Record<string, string>\n/**\n * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers)\n */\ndeclare class Headers {\n\tconstructor(init?: HeadersInit)\n\t/**\n\t * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get)\n\t */\n\tget(name: string): string | null\n\tgetAll(name: string): string[]\n\t/**\n\t * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie)\n\t */\n\tgetSetCookie(): string[]\n\t/**\n\t * The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has)\n\t */\n\thas(name: string): boolean\n\t/**\n\t * The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set)\n\t */\n\tset(name: string, value: string): void\n\t/**\n\t * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append)\n\t */\n\tappend(name: string, value: string): void\n\t/**\n\t * The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete)\n\t */\n\tdelete(name: string): void\n\tforEach<This = unknown>(\n\t\tcallback: (this: This, value: string, key: string, parent: Headers) => void,\n\t\tthisArg?: This,\n\t): void\n\t/* Returns an iterator allowing to go through all key/value pairs contained in this object. */\n\tentries(): IterableIterator<[key: string, value: string]>\n\t/* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */\n\tkeys(): IterableIterator<string>\n\t/* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */\n\tvalues(): IterableIterator<string>\n\t[Symbol.iterator](): IterableIterator<[key: string, value: string]>\n}\ntype BodyInit =\n\t| ReadableStream<Uint8Array>\n\t| string\n\t| ArrayBuffer\n\t| ArrayBufferView\n\t| Blob\n\t| URLSearchParams\n\t| FormData\ndeclare abstract class Body {\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */\n\tget body(): ReadableStream | null\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */\n\tget bodyUsed(): boolean\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */\n\tarrayBuffer(): Promise<ArrayBuffer>\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */\n\tbytes(): Promise<Uint8Array>\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */\n\ttext(): Promise<string>\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */\n\tjson<T>(): Promise<T>\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */\n\tformData(): Promise<FormData>\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */\n\tblob(): Promise<Blob>\n}\n/**\n * The **`Response`** interface of the Fetch API represents the response to a request.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)\n */\ndeclare var Response: {\n\tprototype: Response\n\tnew (body?: BodyInit | null, init?: ResponseInit): Response\n\terror(): Response\n\tredirect(url: string, status?: number): Response\n\tjson(any: any, maybeInit?: ResponseInit | Response): Response\n}\n/**\n * The **`Response`** interface of the Fetch API represents the response to a request.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)\n */\ninterface Response extends Body {\n\t/**\n\t * The **`clone()`** method of the Response interface creates a clone of a response object, identical in every way, but stored in a different variable.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone)\n\t */\n\tclone(): Response\n\t/**\n\t * The **`status`** read-only property of the Response interface contains the HTTP status codes of the response.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status)\n\t */\n\tstatus: number\n\t/**\n\t * The **`statusText`** read-only property of the Response interface contains the status message corresponding to the HTTP status code in Response.status.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText)\n\t */\n\tstatusText: string\n\t/**\n\t * The **`headers`** read-only property of the with the response.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers)\n\t */\n\theaders: Headers\n\t/**\n\t * The **`ok`** read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok)\n\t */\n\tok: boolean\n\t/**\n\t * The **`redirected`** read-only property of the Response interface indicates whether or not the response is the result of a request you made which was redirected.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected)\n\t */\n\tredirected: boolean\n\t/**\n\t * The **`url`** read-only property of the Response interface contains the URL of the response.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url)\n\t */\n\turl: string\n\twebSocket: WebSocket | null\n\tcf: any | undefined\n\t/**\n\t * The **`type`** read-only property of the Response interface contains the type of the response.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type)\n\t */\n\ttype: 'default' | 'error'\n}\ninterface ResponseInit {\n\tstatus?: number\n\tstatusText?: string\n\theaders?: HeadersInit\n\tcf?: any\n\twebSocket?: WebSocket | null\n\tencodeBody?: 'automatic' | 'manual'\n}\ntype RequestInfo<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> =\n\t| Request<CfHostMetadata, Cf>\n\t| string\n/**\n * The **`Request`** interface of the Fetch API represents a resource request.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request)\n */\ndeclare var Request: {\n\tprototype: Request\n\tnew <CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>>(\n\t\tinput: RequestInfo<CfProperties> | URL,\n\t\tinit?: RequestInit<Cf>,\n\t): Request<CfHostMetadata, Cf>\n}\n/**\n * The **`Request`** interface of the Fetch API represents a resource request.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request)\n */\ninterface Request<\n\tCfHostMetadata = unknown,\n\tCf = CfProperties<CfHostMetadata>,\n> extends Body {\n\t/**\n\t * The **`clone()`** method of the Request interface creates a copy of the current `Request` object.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone)\n\t */\n\tclone(): Request<CfHostMetadata, Cf>\n\t/**\n\t * The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method)\n\t */\n\tmethod: string\n\t/**\n\t * The **`url`** read-only property of the Request interface contains the URL of the request.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url)\n\t */\n\turl: string\n\t/**\n\t * The **`headers`** read-only property of the with the request.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers)\n\t */\n\theaders: Headers\n\t/**\n\t * The **`redirect`** read-only property of the Request interface contains the mode for how redirects are handled.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect)\n\t */\n\tredirect: string\n\tfetcher: Fetcher | null\n\t/**\n\t * The read-only **`signal`** property of the Request interface returns the AbortSignal associated with the request.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal)\n\t */\n\tsignal: AbortSignal\n\tcf?: Cf\n\t/**\n\t * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity)\n\t */\n\tintegrity: string\n\t/**\n\t * The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive)\n\t */\n\tkeepalive: boolean\n\t/**\n\t * The **`cache`** read-only property of the Request interface contains the cache mode of the request.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache)\n\t */\n\tcache?: 'no-store'\n}\ninterface RequestInit<Cf = CfProperties> {\n\t/* A string to set request's method. */\n\tmethod?: string\n\t/* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */\n\theaders?: HeadersInit\n\t/* A BodyInit object or null to set request's body. */\n\tbody?: BodyInit | null\n\t/* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */\n\tredirect?: string\n\tfetcher?: Fetcher | null\n\tcf?: Cf\n\t/* A string indicating how the request will interact with the browser's cache to set request's cache. */\n\tcache?: 'no-store'\n\t/* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */\n\tintegrity?: string\n\t/* An AbortSignal to set request's signal. */\n\tsignal?: AbortSignal | null\n\tencodeResponseBody?: 'automatic' | 'manual'\n}\ntype Service<\n\tT extends\n\t\t| (new (...args: any[]) => Rpc.WorkerEntrypointBranded)\n\t\t| Rpc.WorkerEntrypointBranded\n\t\t| ExportedHandler<any, any, any>\n\t\t| undefined = undefined,\n> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded\n\t? Fetcher<InstanceType<T>>\n\t: T extends Rpc.WorkerEntrypointBranded\n\t\t? Fetcher<T>\n\t\t: T extends Exclude<Rpc.EntrypointBranded, Rpc.WorkerEntrypointBranded>\n\t\t\t? never\n\t\t\t: Fetcher<undefined>\ntype Fetcher<\n\tT extends Rpc.EntrypointBranded | undefined = undefined,\n\tReserved extends string = never,\n> = (T extends Rpc.EntrypointBranded\n\t? Rpc.Provider<T, Reserved | 'fetch' | 'connect'>\n\t: unknown) & {\n\tfetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>\n\tconnect(address: SocketAddress | string, options?: SocketOptions): Socket\n}\ninterface KVNamespaceListKey<Metadata, Key extends string = string> {\n\tname: Key\n\texpiration?: number\n\tmetadata?: Metadata\n}\ntype KVNamespaceListResult<Metadata, Key extends string = string> =\n\t| {\n\t\t\tlist_complete: false\n\t\t\tkeys: KVNamespaceListKey<Metadata, Key>[]\n\t\t\tcursor: string\n\t\t\tcacheStatus: string | null\n\t  }\n\t| {\n\t\t\tlist_complete: true\n\t\t\tkeys: KVNamespaceListKey<Metadata, Key>[]\n\t\t\tcacheStatus: string | null\n\t  }\ninterface KVNamespace<Key extends string = string> {\n\tget(\n\t\tkey: Key,\n\t\toptions?: Partial<KVNamespaceGetOptions<undefined>>,\n\t): Promise<string | null>\n\tget(key: Key, type: 'text'): Promise<string | null>\n\tget<ExpectedValue = unknown>(\n\t\tkey: Key,\n\t\ttype: 'json',\n\t): Promise<ExpectedValue | null>\n\tget(key: Key, type: 'arrayBuffer'): Promise<ArrayBuffer | null>\n\tget(key: Key, type: 'stream'): Promise<ReadableStream | null>\n\tget(key: Key, options?: KVNamespaceGetOptions<'text'>): Promise<string | null>\n\tget<ExpectedValue = unknown>(\n\t\tkey: Key,\n\t\toptions?: KVNamespaceGetOptions<'json'>,\n\t): Promise<ExpectedValue | null>\n\tget(\n\t\tkey: Key,\n\t\toptions?: KVNamespaceGetOptions<'arrayBuffer'>,\n\t): Promise<ArrayBuffer | null>\n\tget(\n\t\tkey: Key,\n\t\toptions?: KVNamespaceGetOptions<'stream'>,\n\t): Promise<ReadableStream | null>\n\tget(key: Array<Key>, type: 'text'): Promise<Map<string, string | null>>\n\tget<ExpectedValue = unknown>(\n\t\tkey: Array<Key>,\n\t\ttype: 'json',\n\t): Promise<Map<string, ExpectedValue | null>>\n\tget(\n\t\tkey: Array<Key>,\n\t\toptions?: Partial<KVNamespaceGetOptions<undefined>>,\n\t): Promise<Map<string, string | null>>\n\tget(\n\t\tkey: Array<Key>,\n\t\toptions?: KVNamespaceGetOptions<'text'>,\n\t): Promise<Map<string, string | null>>\n\tget<ExpectedValue = unknown>(\n\t\tkey: Array<Key>,\n\t\toptions?: KVNamespaceGetOptions<'json'>,\n\t): Promise<Map<string, ExpectedValue | null>>\n\tlist<Metadata = unknown>(\n\t\toptions?: KVNamespaceListOptions,\n\t): Promise<KVNamespaceListResult<Metadata, Key>>\n\tput(\n\t\tkey: Key,\n\t\tvalue: string | ArrayBuffer | ArrayBufferView | ReadableStream,\n\t\toptions?: KVNamespacePutOptions,\n\t): Promise<void>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\toptions?: Partial<KVNamespaceGetOptions<undefined>>,\n\t): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\ttype: 'text',\n\t): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>\n\tgetWithMetadata<ExpectedValue = unknown, Metadata = unknown>(\n\t\tkey: Key,\n\t\ttype: 'json',\n\t): Promise<KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\ttype: 'arrayBuffer',\n\t): Promise<KVNamespaceGetWithMetadataResult<ArrayBuffer, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\ttype: 'stream',\n\t): Promise<KVNamespaceGetWithMetadataResult<ReadableStream, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\toptions: KVNamespaceGetOptions<'text'>,\n\t): Promise<KVNamespaceGetWithMetadataResult<string, Metadata>>\n\tgetWithMetadata<ExpectedValue = unknown, Metadata = unknown>(\n\t\tkey: Key,\n\t\toptions: KVNamespaceGetOptions<'json'>,\n\t): Promise<KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\toptions: KVNamespaceGetOptions<'arrayBuffer'>,\n\t): Promise<KVNamespaceGetWithMetadataResult<ArrayBuffer, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Key,\n\t\toptions: KVNamespaceGetOptions<'stream'>,\n\t): Promise<KVNamespaceGetWithMetadataResult<ReadableStream, Metadata>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Array<Key>,\n\t\ttype: 'text',\n\t): Promise<Map<string, KVNamespaceGetWithMetadataResult<string, Metadata>>>\n\tgetWithMetadata<ExpectedValue = unknown, Metadata = unknown>(\n\t\tkey: Array<Key>,\n\t\ttype: 'json',\n\t): Promise<\n\t\tMap<string, KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>\n\t>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Array<Key>,\n\t\toptions?: Partial<KVNamespaceGetOptions<undefined>>,\n\t): Promise<Map<string, KVNamespaceGetWithMetadataResult<string, Metadata>>>\n\tgetWithMetadata<Metadata = unknown>(\n\t\tkey: Array<Key>,\n\t\toptions?: KVNamespaceGetOptions<'text'>,\n\t): Promise<Map<string, KVNamespaceGetWithMetadataResult<string, Metadata>>>\n\tgetWithMetadata<ExpectedValue = unknown, Metadata = unknown>(\n\t\tkey: Array<Key>,\n\t\toptions?: KVNamespaceGetOptions<'json'>,\n\t): Promise<\n\t\tMap<string, KVNamespaceGetWithMetadataResult<ExpectedValue, Metadata>>\n\t>\n\tdelete(key: Key): Promise<void>\n}\ninterface KVNamespaceListOptions {\n\tlimit?: number\n\tprefix?: string | null\n\tcursor?: string | null\n}\ninterface KVNamespaceGetOptions<Type> {\n\ttype: Type\n\tcacheTtl?: number\n}\ninterface KVNamespacePutOptions {\n\texpiration?: number\n\texpirationTtl?: number\n\tmetadata?: any | null\n}\ninterface KVNamespaceGetWithMetadataResult<Value, Metadata> {\n\tvalue: Value | null\n\tmetadata: Metadata | null\n\tcacheStatus: string | null\n}\ntype QueueContentType = 'text' | 'bytes' | 'json' | 'v8'\ninterface Queue<Body = unknown> {\n\tsend(message: Body, options?: QueueSendOptions): Promise<void>\n\tsendBatch(\n\t\tmessages: Iterable<MessageSendRequest<Body>>,\n\t\toptions?: QueueSendBatchOptions,\n\t): Promise<void>\n}\ninterface QueueSendOptions {\n\tcontentType?: QueueContentType\n\tdelaySeconds?: number\n}\ninterface QueueSendBatchOptions {\n\tdelaySeconds?: number\n}\ninterface MessageSendRequest<Body = unknown> {\n\tbody: Body\n\tcontentType?: QueueContentType\n\tdelaySeconds?: number\n}\ninterface QueueRetryOptions {\n\tdelaySeconds?: number\n}\ninterface Message<Body = unknown> {\n\treadonly id: string\n\treadonly timestamp: Date\n\treadonly body: Body\n\treadonly attempts: number\n\tretry(options?: QueueRetryOptions): void\n\tack(): void\n}\ninterface QueueEvent<Body = unknown> extends ExtendableEvent {\n\treadonly messages: readonly Message<Body>[]\n\treadonly queue: string\n\tretryAll(options?: QueueRetryOptions): void\n\tackAll(): void\n}\ninterface MessageBatch<Body = unknown> {\n\treadonly messages: readonly Message<Body>[]\n\treadonly queue: string\n\tretryAll(options?: QueueRetryOptions): void\n\tackAll(): void\n}\ninterface R2Error extends Error {\n\treadonly name: string\n\treadonly code: number\n\treadonly message: string\n\treadonly action: string\n\treadonly stack: any\n}\ninterface R2ListOptions {\n\tlimit?: number\n\tprefix?: string\n\tcursor?: string\n\tdelimiter?: string\n\tstartAfter?: string\n\tinclude?: ('httpMetadata' | 'customMetadata')[]\n}\ndeclare abstract class R2Bucket {\n\thead(key: string): Promise<R2Object | null>\n\tget(\n\t\tkey: string,\n\t\toptions: R2GetOptions & {\n\t\t\tonlyIf: R2Conditional | Headers\n\t\t},\n\t): Promise<R2ObjectBody | R2Object | null>\n\tget(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>\n\tput(\n\t\tkey: string,\n\t\tvalue:\n\t\t\t| ReadableStream\n\t\t\t| ArrayBuffer\n\t\t\t| ArrayBufferView\n\t\t\t| string\n\t\t\t| null\n\t\t\t| Blob,\n\t\toptions?: R2PutOptions & {\n\t\t\tonlyIf: R2Conditional | Headers\n\t\t},\n\t): Promise<R2Object | null>\n\tput(\n\t\tkey: string,\n\t\tvalue:\n\t\t\t| ReadableStream\n\t\t\t| ArrayBuffer\n\t\t\t| ArrayBufferView\n\t\t\t| string\n\t\t\t| null\n\t\t\t| Blob,\n\t\toptions?: R2PutOptions,\n\t): Promise<R2Object>\n\tcreateMultipartUpload(\n\t\tkey: string,\n\t\toptions?: R2MultipartOptions,\n\t): Promise<R2MultipartUpload>\n\tresumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload\n\tdelete(keys: string | string[]): Promise<void>\n\tlist(options?: R2ListOptions): Promise<R2Objects>\n}\ninterface R2MultipartUpload {\n\treadonly key: string\n\treadonly uploadId: string\n\tuploadPart(\n\t\tpartNumber: number,\n\t\tvalue: ReadableStream | (ArrayBuffer | ArrayBufferView) | string | Blob,\n\t\toptions?: R2UploadPartOptions,\n\t): Promise<R2UploadedPart>\n\tabort(): Promise<void>\n\tcomplete(uploadedParts: R2UploadedPart[]): Promise<R2Object>\n}\ninterface R2UploadedPart {\n\tpartNumber: number\n\tetag: string\n}\ndeclare abstract class R2Object {\n\treadonly key: string\n\treadonly version: string\n\treadonly size: number\n\treadonly etag: string\n\treadonly httpEtag: string\n\treadonly checksums: R2Checksums\n\treadonly uploaded: Date\n\treadonly httpMetadata?: R2HTTPMetadata\n\treadonly customMetadata?: Record<string, string>\n\treadonly range?: R2Range\n\treadonly storageClass: string\n\treadonly ssecKeyMd5?: string\n\twriteHttpMetadata(headers: Headers): void\n}\ninterface R2ObjectBody extends R2Object {\n\tget body(): ReadableStream\n\tget bodyUsed(): boolean\n\tarrayBuffer(): Promise<ArrayBuffer>\n\tbytes(): Promise<Uint8Array>\n\ttext(): Promise<string>\n\tjson<T>(): Promise<T>\n\tblob(): Promise<Blob>\n}\ntype R2Range =\n\t| {\n\t\t\toffset: number\n\t\t\tlength?: number\n\t  }\n\t| {\n\t\t\toffset?: number\n\t\t\tlength: number\n\t  }\n\t| {\n\t\t\tsuffix: number\n\t  }\ninterface R2Conditional {\n\tetagMatches?: string\n\tetagDoesNotMatch?: string\n\tuploadedBefore?: Date\n\tuploadedAfter?: Date\n\tsecondsGranularity?: boolean\n}\ninterface R2GetOptions {\n\tonlyIf?: R2Conditional | Headers\n\trange?: R2Range | Headers\n\tssecKey?: ArrayBuffer | string\n}\ninterface R2PutOptions {\n\tonlyIf?: R2Conditional | Headers\n\thttpMetadata?: R2HTTPMetadata | Headers\n\tcustomMetadata?: Record<string, string>\n\tmd5?: (ArrayBuffer | ArrayBufferView) | string\n\tsha1?: (ArrayBuffer | ArrayBufferView) | string\n\tsha256?: (ArrayBuffer | ArrayBufferView) | string\n\tsha384?: (ArrayBuffer | ArrayBufferView) | string\n\tsha512?: (ArrayBuffer | ArrayBufferView) | string\n\tstorageClass?: string\n\tssecKey?: ArrayBuffer | string\n}\ninterface R2MultipartOptions {\n\thttpMetadata?: R2HTTPMetadata | Headers\n\tcustomMetadata?: Record<string, string>\n\tstorageClass?: string\n\tssecKey?: ArrayBuffer | string\n}\ninterface R2Checksums {\n\treadonly md5?: ArrayBuffer\n\treadonly sha1?: ArrayBuffer\n\treadonly sha256?: ArrayBuffer\n\treadonly sha384?: ArrayBuffer\n\treadonly sha512?: ArrayBuffer\n\ttoJSON(): R2StringChecksums\n}\ninterface R2StringChecksums {\n\tmd5?: string\n\tsha1?: string\n\tsha256?: string\n\tsha384?: string\n\tsha512?: string\n}\ninterface R2HTTPMetadata {\n\tcontentType?: string\n\tcontentLanguage?: string\n\tcontentDisposition?: string\n\tcontentEncoding?: string\n\tcacheControl?: string\n\tcacheExpiry?: Date\n}\ntype R2Objects = {\n\tobjects: R2Object[]\n\tdelimitedPrefixes: string[]\n} & (\n\t| {\n\t\t\ttruncated: true\n\t\t\tcursor: string\n\t  }\n\t| {\n\t\t\ttruncated: false\n\t  }\n)\ninterface R2UploadPartOptions {\n\tssecKey?: ArrayBuffer | string\n}\ndeclare abstract class ScheduledEvent extends ExtendableEvent {\n\treadonly scheduledTime: number\n\treadonly cron: string\n\tnoRetry(): void\n}\ninterface ScheduledController {\n\treadonly scheduledTime: number\n\treadonly cron: string\n\tnoRetry(): void\n}\ninterface QueuingStrategy<T = any> {\n\thighWaterMark?: number | bigint\n\tsize?: (chunk: T) => number | bigint\n}\ninterface UnderlyingSink<W = any> {\n\ttype?: string\n\tstart?: (controller: WritableStreamDefaultController) => void | Promise<void>\n\twrite?: (\n\t\tchunk: W,\n\t\tcontroller: WritableStreamDefaultController,\n\t) => void | Promise<void>\n\tabort?: (reason: any) => void | Promise<void>\n\tclose?: () => void | Promise<void>\n}\ninterface UnderlyingByteSource {\n\ttype: 'bytes'\n\tautoAllocateChunkSize?: number\n\tstart?: (controller: ReadableByteStreamController) => void | Promise<void>\n\tpull?: (controller: ReadableByteStreamController) => void | Promise<void>\n\tcancel?: (reason: any) => void | Promise<void>\n}\ninterface UnderlyingSource<R = any> {\n\ttype?: '' | undefined\n\tstart?: (\n\t\tcontroller: ReadableStreamDefaultController<R>,\n\t) => void | Promise<void>\n\tpull?: (\n\t\tcontroller: ReadableStreamDefaultController<R>,\n\t) => void | Promise<void>\n\tcancel?: (reason: any) => void | Promise<void>\n\texpectedLength?: number | bigint\n}\ninterface Transformer<I = any, O = any> {\n\treadableType?: string\n\twritableType?: string\n\tstart?: (\n\t\tcontroller: TransformStreamDefaultController<O>,\n\t) => void | Promise<void>\n\ttransform?: (\n\t\tchunk: I,\n\t\tcontroller: TransformStreamDefaultController<O>,\n\t) => void | Promise<void>\n\tflush?: (\n\t\tcontroller: TransformStreamDefaultController<O>,\n\t) => void | Promise<void>\n\tcancel?: (reason: any) => void | Promise<void>\n\texpectedLength?: number\n}\ninterface StreamPipeOptions {\n\tpreventAbort?: boolean\n\tpreventCancel?: boolean\n\t/**\n\t * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered.\n\t *\n\t * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader.\n\t *\n\t * Errors and closures of the source and destination streams propagate as follows:\n\t *\n\t * An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination.\n\t *\n\t * An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source.\n\t *\n\t * When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error.\n\t *\n\t * If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source.\n\t *\n\t * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set.\n\t */\n\tpreventClose?: boolean\n\tsignal?: AbortSignal\n}\ntype ReadableStreamReadResult<R = any> =\n\t| {\n\t\t\tdone: false\n\t\t\tvalue: R\n\t  }\n\t| {\n\t\t\tdone: true\n\t\t\tvalue?: undefined\n\t  }\n/**\n * The `ReadableStream` interface of the Streams API represents a readable stream of byte data.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)\n */\ninterface ReadableStream<R = any> {\n\t/**\n\t * The **`locked`** read-only property of the ReadableStream interface returns whether or not the readable stream is locked to a reader.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked)\n\t */\n\tget locked(): boolean\n\t/**\n\t * The **`cancel()`** method of the ReadableStream interface returns a Promise that resolves when the stream is canceled.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/cancel)\n\t */\n\tcancel(reason?: any): Promise<void>\n\t/**\n\t * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)\n\t */\n\tgetReader(): ReadableStreamDefaultReader<R>\n\t/**\n\t * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)\n\t */\n\tgetReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader\n\t/**\n\t * The **`pipeThrough()`** method of the ReadableStream interface provides a chainable way of piping the current stream through a transform stream or any other writable/readable pair.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeThrough)\n\t */\n\tpipeThrough<T>(\n\t\ttransform: ReadableWritablePair<T, R>,\n\t\toptions?: StreamPipeOptions,\n\t): ReadableStream<T>\n\t/**\n\t * The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo)\n\t */\n\tpipeTo(\n\t\tdestination: WritableStream<R>,\n\t\toptions?: StreamPipeOptions,\n\t): Promise<void>\n\t/**\n\t * The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee)\n\t */\n\ttee(): [ReadableStream<R>, ReadableStream<R>]\n\tvalues(options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>\n\t[Symbol.asyncIterator](\n\t\toptions?: ReadableStreamValuesOptions,\n\t): AsyncIterableIterator<R>\n}\n/**\n * The `ReadableStream` interface of the Streams API represents a readable stream of byte data.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)\n */\ndeclare const ReadableStream: {\n\tprototype: ReadableStream\n\tnew (\n\t\tunderlyingSource: UnderlyingByteSource,\n\t\tstrategy?: QueuingStrategy<Uint8Array>,\n\t): ReadableStream<Uint8Array>\n\tnew <R = any>(\n\t\tunderlyingSource?: UnderlyingSource<R>,\n\t\tstrategy?: QueuingStrategy<R>,\n\t): ReadableStream<R>\n}\n/**\n * The **`ReadableStreamDefaultReader`** interface of the Streams API represents a default reader that can be used to read stream data supplied from a network (such as a fetch request).\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader)\n */\ndeclare class ReadableStreamDefaultReader<R = any> {\n\tconstructor(stream: ReadableStream)\n\tget closed(): Promise<void>\n\tcancel(reason?: any): Promise<void>\n\t/**\n\t * The **`read()`** method of the ReadableStreamDefaultReader interface returns a Promise providing access to the next chunk in the stream's internal queue.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/read)\n\t */\n\tread(): Promise<ReadableStreamReadResult<R>>\n\t/**\n\t * The **`releaseLock()`** method of the ReadableStreamDefaultReader interface releases the reader's lock on the stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultReader/releaseLock)\n\t */\n\treleaseLock(): void\n}\n/**\n * The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader)\n */\ndeclare class ReadableStreamBYOBReader {\n\tconstructor(stream: ReadableStream)\n\tget closed(): Promise<void>\n\tcancel(reason?: any): Promise<void>\n\t/**\n\t * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read)\n\t */\n\tread<T extends ArrayBufferView>(view: T): Promise<ReadableStreamReadResult<T>>\n\t/**\n\t * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock)\n\t */\n\treleaseLock(): void\n\treadAtLeast<T extends ArrayBufferView>(\n\t\tminElements: number,\n\t\tview: T,\n\t): Promise<ReadableStreamReadResult<T>>\n}\ninterface ReadableStreamBYOBReaderReadableStreamBYOBReaderReadOptions {\n\tmin?: number\n}\ninterface ReadableStreamGetReaderOptions {\n\t/**\n\t * Creates a ReadableStreamBYOBReader and locks the stream to the new reader.\n\t *\n\t * This call behaves the same way as the no-argument variant, except that it only works on readable byte streams, i.e. streams which were constructed specifically with the ability to handle \"bring your own buffer\" reading. The returned BYOB reader provides the ability to directly read individual chunks from the stream via its read() method, into developer-supplied buffers, allowing more precise control over allocation.\n\t */\n\tmode: 'byob'\n}\n/**\n * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues).\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest)\n */\ndeclare abstract class ReadableStreamBYOBRequest {\n\t/**\n\t * The **`view`** getter property of the ReadableStreamBYOBRequest interface returns the current view.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/view)\n\t */\n\tget view(): Uint8Array | null\n\t/**\n\t * The **`respond()`** method of the ReadableStreamBYOBRequest interface is used to signal to the associated readable byte stream that the specified number of bytes were written into the ReadableStreamBYOBRequest.view.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respond)\n\t */\n\trespond(bytesWritten: number): void\n\t/**\n\t * The **`respondWithNewView()`** method of the ReadableStreamBYOBRequest interface specifies a new view that the consumer of the associated readable byte stream should write to instead of ReadableStreamBYOBRequest.view.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest/respondWithNewView)\n\t */\n\trespondWithNewView(view: ArrayBuffer | ArrayBufferView): void\n\tget atLeast(): number | null\n}\n/**\n * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController)\n */\ndeclare abstract class ReadableStreamDefaultController<R = any> {\n\t/**\n\t * The **`desiredSize`** read-only property of the required to fill the stream's internal queue.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize)\n\t */\n\tget desiredSize(): number | null\n\t/**\n\t * The **`close()`** method of the ReadableStreamDefaultController interface closes the associated stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/close)\n\t */\n\tclose(): void\n\t/**\n\t * The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue)\n\t */\n\tenqueue(chunk?: R): void\n\t/**\n\t * The **`error()`** method of the with the associated stream to error.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error)\n\t */\n\terror(reason: any): void\n}\n/**\n * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController)\n */\ndeclare abstract class ReadableByteStreamController {\n\t/**\n\t * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest)\n\t */\n\tget byobRequest(): ReadableStreamBYOBRequest | null\n\t/**\n\t * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize)\n\t */\n\tget desiredSize(): number | null\n\t/**\n\t * The **`close()`** method of the ReadableByteStreamController interface closes the associated stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/close)\n\t */\n\tclose(): void\n\t/**\n\t * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues).\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue)\n\t */\n\tenqueue(chunk: ArrayBuffer | ArrayBufferView): void\n\t/**\n\t * The **`error()`** method of the ReadableByteStreamController interface causes any future interactions with the associated stream to error with the specified reason.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/error)\n\t */\n\terror(reason: any): void\n}\n/**\n * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController)\n */\ndeclare abstract class WritableStreamDefaultController {\n\t/**\n\t * The read-only **`signal`** property of the WritableStreamDefaultController interface returns the AbortSignal associated with the controller.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/signal)\n\t */\n\tget signal(): AbortSignal\n\t/**\n\t * The **`error()`** method of the with the associated stream to error.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error)\n\t */\n\terror(reason?: any): void\n}\n/**\n * The **`TransformStreamDefaultController`** interface of the Streams API provides methods to manipulate the associated ReadableStream and WritableStream.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController)\n */\ndeclare abstract class TransformStreamDefaultController<O = any> {\n\t/**\n\t * The **`desiredSize`** read-only property of the TransformStreamDefaultController interface returns the desired size to fill the queue of the associated ReadableStream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/desiredSize)\n\t */\n\tget desiredSize(): number | null\n\t/**\n\t * The **`enqueue()`** method of the TransformStreamDefaultController interface enqueues the given chunk in the readable side of the stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/enqueue)\n\t */\n\tenqueue(chunk?: O): void\n\t/**\n\t * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error)\n\t */\n\terror(reason: any): void\n\t/**\n\t * The **`terminate()`** method of the TransformStreamDefaultController interface closes the readable side and errors the writable side of the stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/terminate)\n\t */\n\tterminate(): void\n}\ninterface ReadableWritablePair<R = any, W = any> {\n\treadable: ReadableStream<R>\n\t/**\n\t * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use.\n\t *\n\t * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader.\n\t */\n\twritable: WritableStream<W>\n}\n/**\n * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream)\n */\ndeclare class WritableStream<W = any> {\n\tconstructor(\n\t\tunderlyingSink?: UnderlyingSink,\n\t\tqueuingStrategy?: QueuingStrategy,\n\t)\n\t/**\n\t * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked)\n\t */\n\tget locked(): boolean\n\t/**\n\t * The **`abort()`** method of the WritableStream interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/abort)\n\t */\n\tabort(reason?: any): Promise<void>\n\t/**\n\t * The **`close()`** method of the WritableStream interface closes the associated stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close)\n\t */\n\tclose(): Promise<void>\n\t/**\n\t * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter)\n\t */\n\tgetWriter(): WritableStreamDefaultWriter<W>\n}\n/**\n * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter)\n */\ndeclare class WritableStreamDefaultWriter<W = any> {\n\tconstructor(stream: WritableStream)\n\t/**\n\t * The **`closed`** read-only property of the the stream errors or the writer's lock is released.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed)\n\t */\n\tget closed(): Promise<void>\n\t/**\n\t * The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready)\n\t */\n\tget ready(): Promise<void>\n\t/**\n\t * The **`desiredSize`** read-only property of the to fill the stream's internal queue.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize)\n\t */\n\tget desiredSize(): number | null\n\t/**\n\t * The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort)\n\t */\n\tabort(reason?: any): Promise<void>\n\t/**\n\t * The **`close()`** method of the stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close)\n\t */\n\tclose(): Promise<void>\n\t/**\n\t * The **`write()`** method of the operation.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write)\n\t */\n\twrite(chunk?: W): Promise<void>\n\t/**\n\t * The **`releaseLock()`** method of the corresponding stream.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock)\n\t */\n\treleaseLock(): void\n}\n/**\n * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream)\n */\ndeclare class TransformStream<I = any, O = any> {\n\tconstructor(\n\t\ttransformer?: Transformer<I, O>,\n\t\twritableStrategy?: QueuingStrategy<I>,\n\t\treadableStrategy?: QueuingStrategy<O>,\n\t)\n\t/**\n\t * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable)\n\t */\n\tget readable(): ReadableStream<O>\n\t/**\n\t * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable)\n\t */\n\tget writable(): WritableStream<I>\n}\ndeclare class FixedLengthStream extends IdentityTransformStream {\n\tconstructor(\n\t\texpectedLength: number | bigint,\n\t\tqueuingStrategy?: IdentityTransformStreamQueuingStrategy,\n\t)\n}\ndeclare class IdentityTransformStream extends TransformStream<\n\tArrayBuffer | ArrayBufferView,\n\tUint8Array\n> {\n\tconstructor(queuingStrategy?: IdentityTransformStreamQueuingStrategy)\n}\ninterface IdentityTransformStreamQueuingStrategy {\n\thighWaterMark?: number | bigint\n}\ninterface ReadableStreamValuesOptions {\n\tpreventCancel?: boolean\n}\n/**\n * The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream)\n */\ndeclare class CompressionStream extends TransformStream<\n\tArrayBuffer | ArrayBufferView,\n\tUint8Array\n> {\n\tconstructor(format: 'gzip' | 'deflate' | 'deflate-raw')\n}\n/**\n * The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream)\n */\ndeclare class DecompressionStream extends TransformStream<\n\tArrayBuffer | ArrayBufferView,\n\tUint8Array\n> {\n\tconstructor(format: 'gzip' | 'deflate' | 'deflate-raw')\n}\n/**\n * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream)\n */\ndeclare class TextEncoderStream extends TransformStream<string, Uint8Array> {\n\tconstructor()\n\tget encoding(): string\n}\n/**\n * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream)\n */\ndeclare class TextDecoderStream extends TransformStream<\n\tArrayBuffer | ArrayBufferView,\n\tstring\n> {\n\tconstructor(label?: string, options?: TextDecoderStreamTextDecoderStreamInit)\n\tget encoding(): string\n\tget fatal(): boolean\n\tget ignoreBOM(): boolean\n}\ninterface TextDecoderStreamTextDecoderStreamInit {\n\tfatal?: boolean\n\tignoreBOM?: boolean\n}\n/**\n * The **`ByteLengthQueuingStrategy`** interface of the Streams API provides a built-in byte length queuing strategy that can be used when constructing streams.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy)\n */\ndeclare class ByteLengthQueuingStrategy implements QueuingStrategy<ArrayBufferView> {\n\tconstructor(init: QueuingStrategyInit)\n\t/**\n\t * The read-only **`ByteLengthQueuingStrategy.highWaterMark`** property returns the total number of bytes that can be contained in the internal queue before backpressure is applied.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/highWaterMark)\n\t */\n\tget highWaterMark(): number\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy/size) */\n\tget size(): (chunk?: any) => number\n}\n/**\n * The **`CountQueuingStrategy`** interface of the Streams API provides a built-in chunk counting queuing strategy that can be used when constructing streams.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy)\n */\ndeclare class CountQueuingStrategy implements QueuingStrategy {\n\tconstructor(init: QueuingStrategyInit)\n\t/**\n\t * The read-only **`CountQueuingStrategy.highWaterMark`** property returns the total number of chunks that can be contained in the internal queue before backpressure is applied.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/highWaterMark)\n\t */\n\tget highWaterMark(): number\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy/size) */\n\tget size(): (chunk?: any) => number\n}\ninterface QueuingStrategyInit {\n\t/**\n\t * Creates a new ByteLengthQueuingStrategy with the provided high water mark.\n\t *\n\t * Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw.\n\t */\n\thighWaterMark: number\n}\ninterface ScriptVersion {\n\tid?: string\n\ttag?: string\n\tmessage?: string\n}\ndeclare abstract class TailEvent extends ExtendableEvent {\n\treadonly events: TraceItem[]\n\treadonly traces: TraceItem[]\n}\ninterface TraceItem {\n\treadonly event:\n\t\t| (\n\t\t\t\t| TraceItemFetchEventInfo\n\t\t\t\t| TraceItemJsRpcEventInfo\n\t\t\t\t| TraceItemScheduledEventInfo\n\t\t\t\t| TraceItemAlarmEventInfo\n\t\t\t\t| TraceItemQueueEventInfo\n\t\t\t\t| TraceItemEmailEventInfo\n\t\t\t\t| TraceItemTailEventInfo\n\t\t\t\t| TraceItemCustomEventInfo\n\t\t\t\t| TraceItemHibernatableWebSocketEventInfo\n\t\t  )\n\t\t| null\n\treadonly eventTimestamp: number | null\n\treadonly logs: TraceLog[]\n\treadonly exceptions: TraceException[]\n\treadonly diagnosticsChannelEvents: TraceDiagnosticChannelEvent[]\n\treadonly scriptName: string | null\n\treadonly entrypoint?: string\n\treadonly scriptVersion?: ScriptVersion\n\treadonly dispatchNamespace?: string\n\treadonly scriptTags?: string[]\n\treadonly durableObjectId?: string\n\treadonly outcome: string\n\treadonly executionModel: string\n\treadonly truncated: boolean\n\treadonly cpuTime: number\n\treadonly wallTime: number\n}\ninterface TraceItemAlarmEventInfo {\n\treadonly scheduledTime: Date\n}\ninterface TraceItemCustomEventInfo {}\ninterface TraceItemScheduledEventInfo {\n\treadonly scheduledTime: number\n\treadonly cron: string\n}\ninterface TraceItemQueueEventInfo {\n\treadonly queue: string\n\treadonly batchSize: number\n}\ninterface TraceItemEmailEventInfo {\n\treadonly mailFrom: string\n\treadonly rcptTo: string\n\treadonly rawSize: number\n}\ninterface TraceItemTailEventInfo {\n\treadonly consumedEvents: TraceItemTailEventInfoTailItem[]\n}\ninterface TraceItemTailEventInfoTailItem {\n\treadonly scriptName: string | null\n}\ninterface TraceItemFetchEventInfo {\n\treadonly response?: TraceItemFetchEventInfoResponse\n\treadonly request: TraceItemFetchEventInfoRequest\n}\ninterface TraceItemFetchEventInfoRequest {\n\treadonly cf?: any\n\treadonly headers: Record<string, string>\n\treadonly method: string\n\treadonly url: string\n\tgetUnredacted(): TraceItemFetchEventInfoRequest\n}\ninterface TraceItemFetchEventInfoResponse {\n\treadonly status: number\n}\ninterface TraceItemJsRpcEventInfo {\n\treadonly rpcMethod: string\n}\ninterface TraceItemHibernatableWebSocketEventInfo {\n\treadonly getWebSocketEvent:\n\t\t| TraceItemHibernatableWebSocketEventInfoMessage\n\t\t| TraceItemHibernatableWebSocketEventInfoClose\n\t\t| TraceItemHibernatableWebSocketEventInfoError\n}\ninterface TraceItemHibernatableWebSocketEventInfoMessage {\n\treadonly webSocketEventType: string\n}\ninterface TraceItemHibernatableWebSocketEventInfoClose {\n\treadonly webSocketEventType: string\n\treadonly code: number\n\treadonly wasClean: boolean\n}\ninterface TraceItemHibernatableWebSocketEventInfoError {\n\treadonly webSocketEventType: string\n}\ninterface TraceLog {\n\treadonly timestamp: number\n\treadonly level: string\n\treadonly message: any\n}\ninterface TraceException {\n\treadonly timestamp: number\n\treadonly message: string\n\treadonly name: string\n\treadonly stack?: string\n}\ninterface TraceDiagnosticChannelEvent {\n\treadonly timestamp: number\n\treadonly channel: string\n\treadonly message: any\n}\ninterface TraceMetrics {\n\treadonly cpuTime: number\n\treadonly wallTime: number\n}\ninterface UnsafeTraceMetrics {\n\tfromTrace(item: TraceItem): TraceMetrics\n}\n/**\n * The **`URL`** interface is used to parse, construct, normalize, and encode URL.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)\n */\ndeclare class URL {\n\tconstructor(url: string | URL, base?: string | URL)\n\t/**\n\t * The **`origin`** read-only property of the URL interface returns a string containing the Unicode serialization of the origin of the represented URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/origin)\n\t */\n\tget origin(): string\n\t/**\n\t * The **`href`** property of the URL interface is a string containing the whole URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href)\n\t */\n\tget href(): string\n\t/**\n\t * The **`href`** property of the URL interface is a string containing the whole URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/href)\n\t */\n\tset href(value: string)\n\t/**\n\t * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)\n\t */\n\tget protocol(): string\n\t/**\n\t * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)\n\t */\n\tset protocol(value: string)\n\t/**\n\t * The **`username`** property of the URL interface is a string containing the username component of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)\n\t */\n\tget username(): string\n\t/**\n\t * The **`username`** property of the URL interface is a string containing the username component of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)\n\t */\n\tset username(value: string)\n\t/**\n\t * The **`password`** property of the URL interface is a string containing the password component of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)\n\t */\n\tget password(): string\n\t/**\n\t * The **`password`** property of the URL interface is a string containing the password component of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)\n\t */\n\tset password(value: string)\n\t/**\n\t * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)\n\t */\n\tget host(): string\n\t/**\n\t * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)\n\t */\n\tset host(value: string)\n\t/**\n\t * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)\n\t */\n\tget hostname(): string\n\t/**\n\t * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)\n\t */\n\tset hostname(value: string)\n\t/**\n\t * The **`port`** property of the URL interface is a string containing the port number of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)\n\t */\n\tget port(): string\n\t/**\n\t * The **`port`** property of the URL interface is a string containing the port number of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)\n\t */\n\tset port(value: string)\n\t/**\n\t * The **`pathname`** property of the URL interface represents a location in a hierarchical structure.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)\n\t */\n\tget pathname(): string\n\t/**\n\t * The **`pathname`** property of the URL interface represents a location in a hierarchical structure.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)\n\t */\n\tset pathname(value: string)\n\t/**\n\t * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)\n\t */\n\tget search(): string\n\t/**\n\t * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)\n\t */\n\tset search(value: string)\n\t/**\n\t * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)\n\t */\n\tget hash(): string\n\t/**\n\t * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)\n\t */\n\tset hash(value: string)\n\t/**\n\t * The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams)\n\t */\n\tget searchParams(): URLSearchParams\n\t/**\n\t * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON)\n\t */\n\ttoJSON(): string\n\t/*function toString() { [native code] }*/\n\ttoString(): string\n\t/**\n\t * The **`URL.canParse()`** static method of the URL interface returns a boolean indicating whether or not an absolute URL, or a relative URL combined with a base URL, are parsable and valid.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/canParse_static)\n\t */\n\tstatic canParse(url: string, base?: string): boolean\n\t/**\n\t * The **`URL.parse()`** static method of the URL interface returns a newly created URL object representing the URL defined by the parameters.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/parse_static)\n\t */\n\tstatic parse(url: string, base?: string): URL | null\n\t/**\n\t * The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static)\n\t */\n\tstatic createObjectURL(object: File | Blob): string\n\t/**\n\t * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static)\n\t */\n\tstatic revokeObjectURL(object_url: string): void\n}\n/**\n * The **`URLSearchParams`** interface defines utility methods to work with the query string of a URL.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams)\n */\ndeclare class URLSearchParams {\n\tconstructor(\n\t\tinit?: Iterable<Iterable<string>> | Record<string, string> | string,\n\t)\n\t/**\n\t * The **`size`** read-only property of the URLSearchParams interface indicates the total number of search parameter entries.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/size)\n\t */\n\tget size(): number\n\t/**\n\t * The **`append()`** method of the URLSearchParams interface appends a specified key/value pair as a new search parameter.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/append)\n\t */\n\tappend(name: string, value: string): void\n\t/**\n\t * The **`delete()`** method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/delete)\n\t */\n\tdelete(name: string, value?: string): void\n\t/**\n\t * The **`get()`** method of the URLSearchParams interface returns the first value associated to the given search parameter.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/get)\n\t */\n\tget(name: string): string | null\n\t/**\n\t * The **`getAll()`** method of the URLSearchParams interface returns all the values associated with a given search parameter as an array.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/getAll)\n\t */\n\tgetAll(name: string): string[]\n\t/**\n\t * The **`has()`** method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/has)\n\t */\n\thas(name: string, value?: string): boolean\n\t/**\n\t * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set)\n\t */\n\tset(name: string, value: string): void\n\t/**\n\t * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort)\n\t */\n\tsort(): void\n\t/* Returns an array of key, value pairs for every entry in the search params. */\n\tentries(): IterableIterator<[key: string, value: string]>\n\t/* Returns a list of keys in the search params. */\n\tkeys(): IterableIterator<string>\n\t/* Returns a list of values in the search params. */\n\tvalues(): IterableIterator<string>\n\tforEach<This = unknown>(\n\t\tcallback: (\n\t\t\tthis: This,\n\t\t\tvalue: string,\n\t\t\tkey: string,\n\t\t\tparent: URLSearchParams,\n\t\t) => void,\n\t\tthisArg?: This,\n\t): void\n\t/*function toString() { [native code] }*/\n\ttoString(): string\n\t[Symbol.iterator](): IterableIterator<[key: string, value: string]>\n}\ndeclare class URLPattern {\n\tconstructor(\n\t\tinput?: string | URLPatternInit,\n\t\tbaseURL?: string | URLPatternOptions,\n\t\tpatternOptions?: URLPatternOptions,\n\t)\n\tget protocol(): string\n\tget username(): string\n\tget password(): string\n\tget hostname(): string\n\tget port(): string\n\tget pathname(): string\n\tget search(): string\n\tget hash(): string\n\ttest(input?: string | URLPatternInit, baseURL?: string): boolean\n\texec(\n\t\tinput?: string | URLPatternInit,\n\t\tbaseURL?: string,\n\t): URLPatternResult | null\n}\ninterface URLPatternInit {\n\tprotocol?: string\n\tusername?: string\n\tpassword?: string\n\thostname?: string\n\tport?: string\n\tpathname?: string\n\tsearch?: string\n\thash?: string\n\tbaseURL?: string\n}\ninterface URLPatternComponentResult {\n\tinput: string\n\tgroups: Record<string, string>\n}\ninterface URLPatternResult {\n\tinputs: (string | URLPatternInit)[]\n\tprotocol: URLPatternComponentResult\n\tusername: URLPatternComponentResult\n\tpassword: URLPatternComponentResult\n\thostname: URLPatternComponentResult\n\tport: URLPatternComponentResult\n\tpathname: URLPatternComponentResult\n\tsearch: URLPatternComponentResult\n\thash: URLPatternComponentResult\n}\ninterface URLPatternOptions {\n\tignoreCase?: boolean\n}\n/**\n * A `CloseEvent` is sent to clients using WebSockets when the connection is closed.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent)\n */\ndeclare class CloseEvent extends Event {\n\tconstructor(type: string, initializer?: CloseEventInit)\n\t/**\n\t * The **`code`** read-only property of the CloseEvent interface returns a WebSocket connection close code indicating the reason the connection was closed.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/code)\n\t */\n\treadonly code: number\n\t/**\n\t * The **`reason`** read-only property of the CloseEvent interface returns the WebSocket connection close reason the server gave for closing the connection; that is, a concise human-readable prose explanation for the closure.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/reason)\n\t */\n\treadonly reason: string\n\t/**\n\t * The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean)\n\t */\n\treadonly wasClean: boolean\n}\ninterface CloseEventInit {\n\tcode?: number\n\treason?: string\n\twasClean?: boolean\n}\ntype WebSocketEventMap = {\n\tclose: CloseEvent\n\tmessage: MessageEvent\n\topen: Event\n\terror: ErrorEvent\n}\n/**\n * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)\n */\ndeclare var WebSocket: {\n\tprototype: WebSocket\n\tnew (url: string, protocols?: string[] | string): WebSocket\n\treadonly READY_STATE_CONNECTING: number\n\treadonly CONNECTING: number\n\treadonly READY_STATE_OPEN: number\n\treadonly OPEN: number\n\treadonly READY_STATE_CLOSING: number\n\treadonly CLOSING: number\n\treadonly READY_STATE_CLOSED: number\n\treadonly CLOSED: number\n}\n/**\n * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)\n */\ninterface WebSocket extends EventTarget<WebSocketEventMap> {\n\taccept(): void\n\t/**\n\t * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send)\n\t */\n\tsend(message: (ArrayBuffer | ArrayBufferView) | string): void\n\t/**\n\t * The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close)\n\t */\n\tclose(code?: number, reason?: string): void\n\tserializeAttachment(attachment: any): void\n\tdeserializeAttachment(): any | null\n\t/**\n\t * The **`WebSocket.readyState`** read-only property returns the current state of the WebSocket connection.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/readyState)\n\t */\n\treadyState: number\n\t/**\n\t * The **`WebSocket.url`** read-only property returns the absolute URL of the WebSocket as resolved by the constructor.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/url)\n\t */\n\turl: string | null\n\t/**\n\t * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol)\n\t */\n\tprotocol: string | null\n\t/**\n\t * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions)\n\t */\n\textensions: string | null\n}\ndeclare const WebSocketPair: {\n\tnew (): {\n\t\t0: WebSocket\n\t\t1: WebSocket\n\t}\n}\ninterface SqlStorage {\n\texec<T extends Record<string, SqlStorageValue>>(\n\t\tquery: string,\n\t\t...bindings: any[]\n\t): SqlStorageCursor<T>\n\tget databaseSize(): number\n\tCursor: typeof SqlStorageCursor\n\tStatement: typeof SqlStorageStatement\n}\ndeclare abstract class SqlStorageStatement {}\ntype SqlStorageValue = ArrayBuffer | string | number | null\ndeclare abstract class SqlStorageCursor<\n\tT extends Record<string, SqlStorageValue>,\n> {\n\tnext():\n\t\t| {\n\t\t\t\tdone?: false\n\t\t\t\tvalue: T\n\t\t  }\n\t\t| {\n\t\t\t\tdone: true\n\t\t\t\tvalue?: never\n\t\t  }\n\ttoArray(): T[]\n\tone(): T\n\traw<U extends SqlStorageValue[]>(): IterableIterator<U>\n\tcolumnNames: string[]\n\tget rowsRead(): number\n\tget rowsWritten(): number\n\t[Symbol.iterator](): IterableIterator<T>\n}\ninterface Socket {\n\tget readable(): ReadableStream\n\tget writable(): WritableStream\n\tget closed(): Promise<void>\n\tget opened(): Promise<SocketInfo>\n\tget upgraded(): boolean\n\tget secureTransport(): 'on' | 'off' | 'starttls'\n\tclose(): Promise<void>\n\tstartTls(options?: TlsOptions): Socket\n}\ninterface SocketOptions {\n\tsecureTransport?: string\n\tallowHalfOpen: boolean\n\thighWaterMark?: number | bigint\n}\ninterface SocketAddress {\n\thostname: string\n\tport: number\n}\ninterface TlsOptions {\n\texpectedServerHostname?: string\n}\ninterface SocketInfo {\n\tremoteAddress?: string\n\tlocalAddress?: string\n}\n/**\n * The **`EventSource`** interface is web content's interface to server-sent events.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource)\n */\ndeclare class EventSource extends EventTarget {\n\tconstructor(url: string, init?: EventSourceEventSourceInit)\n\t/**\n\t * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close)\n\t */\n\tclose(): void\n\t/**\n\t * The **`url`** read-only property of the URL of the source.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url)\n\t */\n\tget url(): string\n\t/**\n\t * The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials)\n\t */\n\tget withCredentials(): boolean\n\t/**\n\t * The **`readyState`** read-only property of the connection.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState)\n\t */\n\tget readyState(): number\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */\n\tget onopen(): any | null\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */\n\tset onopen(value: any | null)\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */\n\tget onmessage(): any | null\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */\n\tset onmessage(value: any | null)\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */\n\tget onerror(): any | null\n\t/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */\n\tset onerror(value: any | null)\n\tstatic readonly CONNECTING: number\n\tstatic readonly OPEN: number\n\tstatic readonly CLOSED: number\n\tstatic from(stream: ReadableStream): EventSource\n}\ninterface EventSourceEventSourceInit {\n\twithCredentials?: boolean\n\tfetcher?: Fetcher\n}\ninterface Container {\n\tget running(): boolean\n\tstart(options?: ContainerStartupOptions): void\n\tmonitor(): Promise<void>\n\tdestroy(error?: any): Promise<void>\n\tsignal(signo: number): void\n\tgetTcpPort(port: number): Fetcher\n\tsetInactivityTimeout(durationMs: number | bigint): Promise<void>\n}\ninterface ContainerStartupOptions {\n\tentrypoint?: string[]\n\tenableInternet: boolean\n\tenv?: Record<string, string>\n\thardTimeout?: number | bigint\n}\n/**\n * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other.\n *\n * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort)\n */\ndeclare abstract class MessagePort extends EventTarget {\n\t/**\n\t * The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage)\n\t */\n\tpostMessage(data?: any, options?: any[] | MessagePortPostMessageOptions): void\n\t/**\n\t * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close)\n\t */\n\tclose(): void\n\t/**\n\t * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port.\n\t *\n\t * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start)\n\t */\n\tstart(): void\n\tget onmessage(): any | null\n\tset onmessage(value: any | null)\n}\ninterface MessagePortPostMessageOptions {\n\ttransfer?: any[]\n}\ntype LoopbackForExport<\n\tT extends\n\t\t| (new (...args: any[]) => Rpc.EntrypointBranded)\n\t\t| ExportedHandler<any, any, any>\n\t\t| undefined = undefined,\n> = T extends new (...args: any[]) => Rpc.WorkerEntrypointBranded\n\t? LoopbackServiceStub<InstanceType<T>>\n\t: T extends new (...args: any[]) => Rpc.DurableObjectBranded\n\t\t? LoopbackDurableObjectClass<InstanceType<T>>\n\t\t: T extends ExportedHandler<any, any, any>\n\t\t\t? LoopbackServiceStub<undefined>\n\t\t\t: undefined\ntype LoopbackServiceStub<\n\tT extends Rpc.WorkerEntrypointBranded | undefined = undefined,\n> = Fetcher<T> &\n\t(T extends CloudflareWorkersModule.WorkerEntrypoint<any, infer Props>\n\t\t? (opts: { props?: Props }) => Fetcher<T>\n\t\t: (opts: { props?: any }) => Fetcher<T>)\ntype LoopbackDurableObjectClass<\n\tT extends Rpc.DurableObjectBranded | undefined = undefined,\n> = DurableObjectClass<T> &\n\t(T extends CloudflareWorkersModule.DurableObject<any, infer Props>\n\t\t? (opts: { props?: Props }) => DurableObjectClass<T>\n\t\t: (opts: { props?: any }) => DurableObjectClass<T>)\ninterface SyncKvStorage {\n\tget<T = unknown>(key: string): T | undefined\n\tlist<T = unknown>(options?: SyncKvListOptions): Iterable<[string, T]>\n\tput<T>(key: string, value: T): void\n\tdelete(key: string): boolean\n}\ninterface SyncKvListOptions {\n\tstart?: string\n\tstartAfter?: string\n\tend?: string\n\tprefix?: string\n\treverse?: boolean\n\tlimit?: number\n}\ninterface WorkerStub {\n\tgetEntrypoint<T extends Rpc.WorkerEntrypointBranded | undefined>(\n\t\tname?: string,\n\t\toptions?: WorkerStubEntrypointOptions,\n\t): Fetcher<T>\n}\ninterface WorkerStubEntrypointOptions {\n\tprops?: any\n}\ninterface WorkerLoader {\n\tget(\n\t\tname: string | null,\n\t\tgetCode: () => WorkerLoaderWorkerCode | Promise<WorkerLoaderWorkerCode>,\n\t): WorkerStub\n}\ninterface WorkerLoaderModule {\n\tjs?: string\n\tcjs?: string\n\ttext?: string\n\tdata?: ArrayBuffer\n\tjson?: any\n\tpy?: string\n\twasm?: ArrayBuffer\n}\ninterface WorkerLoaderWorkerCode {\n\tcompatibilityDate: string\n\tcompatibilityFlags?: string[]\n\tallowExperimental?: boolean\n\tmainModule: string\n\tmodules: Record<string, WorkerLoaderModule | string>\n\tenv?: any\n\tglobalOutbound?: Fetcher | null\n\ttails?: Fetcher[]\n\tstreamingTails?: Fetcher[]\n}\n/**\n * The Workers runtime supports a subset of the Performance API, used to measure timing and performance,\n * as well as timing of subrequests and other operations.\n *\n * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)\n */\ndeclare abstract class Performance {\n\t/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */\n\tget timeOrigin(): number\n\t/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */\n\tnow(): number\n}\n// AI Search V2 API Error Interfaces\ninterface AiSearchInternalError extends Error {}\ninterface AiSearchNotFoundError extends Error {}\ninterface AiSearchNameNotSetError extends Error {}\n// Filter types (shared with AutoRAG for compatibility)\ntype ComparisonFilter = {\n\tkey: string\n\ttype: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'\n\tvalue: string | number | boolean\n}\ntype CompoundFilter = {\n\ttype: 'and' | 'or'\n\tfilters: ComparisonFilter[]\n}\n// AI Search V2 Request Types\ntype AiSearchSearchRequest = {\n\tmessages: Array<{\n\t\trole: 'system' | 'developer' | 'user' | 'assistant' | 'tool'\n\t\tcontent: string | null\n\t}>\n\tai_search_options?: {\n\t\tretrieval?: {\n\t\t\tretrieval_type?: 'vector' | 'keyword' | 'hybrid'\n\t\t\t/** Match threshold (0-1, default 0.4) */\n\t\t\tmatch_threshold?: number\n\t\t\t/** Maximum number of results (1-50, default 10) */\n\t\t\tmax_num_results?: number\n\t\t\tfilters?: CompoundFilter | ComparisonFilter\n\t\t\t/** Context expansion (0-3, default 0) */\n\t\t\tcontext_expansion?: number\n\t\t\t[key: string]: unknown\n\t\t}\n\t\tquery_rewrite?: {\n\t\t\tenabled?: boolean\n\t\t\tmodel?: string\n\t\t\trewrite_prompt?: string\n\t\t\t[key: string]: unknown\n\t\t}\n\t\treranking?: {\n\t\t\t/** Enable reranking (default false) */\n\t\t\tenabled?: boolean\n\t\t\tmodel?: '@cf/baai/bge-reranker-base' | ''\n\t\t\t/** Match threshold (0-1, default 0.4) */\n\t\t\tmatch_threshold?: number\n\t\t\t[key: string]: unknown\n\t\t}\n\t\t[key: string]: unknown\n\t}\n}\ntype AiSearchChatCompletionsRequest = {\n\tmessages: Array<{\n\t\trole: 'system' | 'developer' | 'user' | 'assistant' | 'tool'\n\t\tcontent: string | null\n\t}>\n\tmodel?: string\n\tstream?: boolean\n\tai_search_options?: {\n\t\tretrieval?: {\n\t\t\tretrieval_type?: 'vector' | 'keyword' | 'hybrid'\n\t\t\tmatch_threshold?: number\n\t\t\tmax_num_results?: number\n\t\t\tfilters?: CompoundFilter | ComparisonFilter\n\t\t\tcontext_expansion?: number\n\t\t\t[key: string]: unknown\n\t\t}\n\t\tquery_rewrite?: {\n\t\t\tenabled?: boolean\n\t\t\tmodel?: string\n\t\t\trewrite_prompt?: string\n\t\t\t[key: string]: unknown\n\t\t}\n\t\treranking?: {\n\t\t\tenabled?: boolean\n\t\t\tmodel?: '@cf/baai/bge-reranker-base' | ''\n\t\t\tmatch_threshold?: number\n\t\t\t[key: string]: unknown\n\t\t}\n\t\t[key: string]: unknown\n\t}\n\t[key: string]: unknown\n}\n// AI Search V2 Response Types\ntype AiSearchSearchResponse = {\n\tsearch_query: string\n\tchunks: Array<{\n\t\tid: string\n\t\ttype: string\n\t\t/** Match score (0-1) */\n\t\tscore: number\n\t\ttext: string\n\t\titem: {\n\t\t\ttimestamp?: number\n\t\t\tkey: string\n\t\t\tmetadata?: Record<string, unknown>\n\t\t}\n\t\tscoring_details?: {\n\t\t\t/** Keyword match score (0-1) */\n\t\t\tkeyword_score?: number\n\t\t\t/** Vector similarity score (0-1) */\n\t\t\tvector_score?: number\n\t\t}\n\t}>\n}\ntype AiSearchListResponse = Array<{\n\tid: string\n\tinternal_id?: string\n\taccount_id?: string\n\taccount_tag?: string\n\t/** Whether the instance is enabled (default true) */\n\tenable?: boolean\n\ttype?: 'r2' | 'web-crawler'\n\tsource?: string\n\t[key: string]: unknown\n}>\ntype AiSearchConfig = {\n\t/** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */\n\tid: string\n\ttype: 'r2' | 'web-crawler'\n\tsource: string\n\tsource_params?: object\n\t/** Token ID (UUID format) */\n\ttoken_id?: string\n\tai_gateway_id?: string\n\t/** Enable query rewriting (default false) */\n\trewrite_query?: boolean\n\t/** Enable reranking (default false) */\n\treranking?: boolean\n\tembedding_model?: string\n\tai_search_model?: string\n}\ntype AiSearchInstance = {\n\tid: string\n\tenable?: boolean\n\ttype?: 'r2' | 'web-crawler'\n\tsource?: string\n\t[key: string]: unknown\n}\n// AI Search Instance Service - Instance-level operations\ndeclare abstract class AiSearchInstanceService {\n\t/**\n\t * Search the AI Search instance for relevant chunks.\n\t * @param params Search request with messages and AI search options\n\t * @returns Search response with matching chunks\n\t */\n\tsearch(params: AiSearchSearchRequest): Promise<AiSearchSearchResponse>\n\t/**\n\t * Generate chat completions with AI Search context.\n\t * @param params Chat completions request with optional streaming\n\t * @returns Response object (if streaming) or chat completion result\n\t */\n\tchatCompletions(\n\t\tparams: AiSearchChatCompletionsRequest,\n\t): Promise<Response | object>\n\t/**\n\t * Delete this AI Search instance.\n\t */\n\tdelete(): Promise<void>\n}\n// AI Search Account Service - Account-level operations\ndeclare abstract class AiSearchAccountService {\n\t/**\n\t * List all AI Search instances in the account.\n\t * @returns Array of AI Search instances\n\t */\n\tlist(): Promise<AiSearchListResponse>\n\t/**\n\t * Get an AI Search instance by ID.\n\t * @param name Instance ID\n\t * @returns Instance service for performing operations\n\t */\n\tget(name: string): AiSearchInstanceService\n\t/**\n\t * Create a new AI Search instance.\n\t * @param config Instance configuration\n\t * @returns Instance service for performing operations\n\t */\n\tcreate(config: AiSearchConfig): Promise<AiSearchInstanceService>\n}\ntype AiImageClassificationInput = {\n\timage: number[]\n}\ntype AiImageClassificationOutput = {\n\tscore?: number\n\tlabel?: string\n}[]\ndeclare abstract class BaseAiImageClassification {\n\tinputs: AiImageClassificationInput\n\tpostProcessedOutputs: AiImageClassificationOutput\n}\ntype AiImageToTextInput = {\n\timage: number[]\n\tprompt?: string\n\tmax_tokens?: number\n\ttemperature?: number\n\ttop_p?: number\n\ttop_k?: number\n\tseed?: number\n\trepetition_penalty?: number\n\tfrequency_penalty?: number\n\tpresence_penalty?: number\n\traw?: boolean\n\tmessages?: RoleScopedChatInput[]\n}\ntype AiImageToTextOutput = {\n\tdescription: string\n}\ndeclare abstract class BaseAiImageToText {\n\tinputs: AiImageToTextInput\n\tpostProcessedOutputs: AiImageToTextOutput\n}\ntype AiImageTextToTextInput = {\n\timage: string\n\tprompt?: string\n\tmax_tokens?: number\n\ttemperature?: number\n\tignore_eos?: boolean\n\ttop_p?: number\n\ttop_k?: number\n\tseed?: number\n\trepetition_penalty?: number\n\tfrequency_penalty?: number\n\tpresence_penalty?: number\n\traw?: boolean\n\tmessages?: RoleScopedChatInput[]\n}\ntype AiImageTextToTextOutput = {\n\tdescription: string\n}\ndeclare abstract class BaseAiImageTextToText {\n\tinputs: AiImageTextToTextInput\n\tpostProcessedOutputs: AiImageTextToTextOutput\n}\ntype AiMultimodalEmbeddingsInput = {\n\timage: string\n\ttext: string[]\n}\ntype AiIMultimodalEmbeddingsOutput = {\n\tdata: number[][]\n\tshape: number[]\n}\ndeclare abstract class BaseAiMultimodalEmbeddings {\n\tinputs: AiImageTextToTextInput\n\tpostProcessedOutputs: AiImageTextToTextOutput\n}\ntype AiObjectDetectionInput = {\n\timage: number[]\n}\ntype AiObjectDetectionOutput = {\n\tscore?: number\n\tlabel?: string\n}[]\ndeclare abstract class BaseAiObjectDetection {\n\tinputs: AiObjectDetectionInput\n\tpostProcessedOutputs: AiObjectDetectionOutput\n}\ntype AiSentenceSimilarityInput = {\n\tsource: string\n\tsentences: string[]\n}\ntype AiSentenceSimilarityOutput = number[]\ndeclare abstract class BaseAiSentenceSimilarity {\n\tinputs: AiSentenceSimilarityInput\n\tpostProcessedOutputs: AiSentenceSimilarityOutput\n}\ntype AiAutomaticSpeechRecognitionInput = {\n\taudio: number[]\n}\ntype AiAutomaticSpeechRecognitionOutput = {\n\ttext?: string\n\twords?: {\n\t\tword: string\n\t\tstart: number\n\t\tend: number\n\t}[]\n\tvtt?: string\n}\ndeclare abstract class BaseAiAutomaticSpeechRecognition {\n\tinputs: AiAutomaticSpeechRecognitionInput\n\tpostProcessedOutputs: AiAutomaticSpeechRecognitionOutput\n}\ntype AiSummarizationInput = {\n\tinput_text: string\n\tmax_length?: number\n}\ntype AiSummarizationOutput = {\n\tsummary: string\n}\ndeclare abstract class BaseAiSummarization {\n\tinputs: AiSummarizationInput\n\tpostProcessedOutputs: AiSummarizationOutput\n}\ntype AiTextClassificationInput = {\n\ttext: string\n}\ntype AiTextClassificationOutput = {\n\tscore?: number\n\tlabel?: string\n}[]\ndeclare abstract class BaseAiTextClassification {\n\tinputs: AiTextClassificationInput\n\tpostProcessedOutputs: AiTextClassificationOutput\n}\ntype AiTextEmbeddingsInput = {\n\ttext: string | string[]\n}\ntype AiTextEmbeddingsOutput = {\n\tshape: number[]\n\tdata: number[][]\n}\ndeclare abstract class BaseAiTextEmbeddings {\n\tinputs: AiTextEmbeddingsInput\n\tpostProcessedOutputs: AiTextEmbeddingsOutput\n}\ntype RoleScopedChatInput = {\n\trole:\n\t\t| 'user'\n\t\t| 'assistant'\n\t\t| 'system'\n\t\t| 'tool'\n\t\t| (string & NonNullable<unknown>)\n\tcontent: string\n\tname?: string\n}\ntype AiTextGenerationToolLegacyInput = {\n\tname: string\n\tdescription: string\n\tparameters?: {\n\t\ttype: 'object' | (string & NonNullable<unknown>)\n\t\tproperties: {\n\t\t\t[key: string]: {\n\t\t\t\ttype: string\n\t\t\t\tdescription?: string\n\t\t\t}\n\t\t}\n\t\trequired: string[]\n\t}\n}\ntype AiTextGenerationToolInput = {\n\ttype: 'function' | (string & NonNullable<unknown>)\n\tfunction: {\n\t\tname: string\n\t\tdescription: string\n\t\tparameters?: {\n\t\t\ttype: 'object' | (string & NonNullable<unknown>)\n\t\t\tproperties: {\n\t\t\t\t[key: string]: {\n\t\t\t\t\ttype: string\n\t\t\t\t\tdescription?: string\n\t\t\t\t}\n\t\t\t}\n\t\t\trequired: string[]\n\t\t}\n\t}\n}\ntype AiTextGenerationFunctionsInput = {\n\tname: string\n\tcode: string\n}\ntype AiTextGenerationResponseFormat = {\n\ttype: string\n\tjson_schema?: any\n}\ntype AiTextGenerationInput = {\n\tprompt?: string\n\traw?: boolean\n\tstream?: boolean\n\tmax_tokens?: number\n\ttemperature?: number\n\ttop_p?: number\n\ttop_k?: number\n\tseed?: number\n\trepetition_penalty?: number\n\tfrequency_penalty?: number\n\tpresence_penalty?: number\n\tmessages?: RoleScopedChatInput[]\n\tresponse_format?: AiTextGenerationResponseFormat\n\ttools?:\n\t\t| AiTextGenerationToolInput[]\n\t\t| AiTextGenerationToolLegacyInput[]\n\t\t| (object & NonNullable<unknown>)\n\tfunctions?: AiTextGenerationFunctionsInput[]\n}\ntype AiTextGenerationToolLegacyOutput = {\n\tname: string\n\targuments: unknown\n}\ntype AiTextGenerationToolOutput = {\n\tid: string\n\ttype: 'function'\n\tfunction: {\n\t\tname: string\n\t\targuments: string\n\t}\n}\ntype UsageTags = {\n\tprompt_tokens: number\n\tcompletion_tokens: number\n\ttotal_tokens: number\n}\ntype AiTextGenerationOutput = {\n\tresponse?: string\n\ttool_calls?: AiTextGenerationToolLegacyOutput[] & AiTextGenerationToolOutput[]\n\tusage?: UsageTags\n}\ndeclare abstract class BaseAiTextGeneration {\n\tinputs: AiTextGenerationInput\n\tpostProcessedOutputs: AiTextGenerationOutput\n}\ntype AiTextToSpeechInput = {\n\tprompt: string\n\tlang?: string\n}\ntype AiTextToSpeechOutput =\n\t| Uint8Array\n\t| {\n\t\t\taudio: string\n\t  }\ndeclare abstract class BaseAiTextToSpeech {\n\tinputs: AiTextToSpeechInput\n\tpostProcessedOutputs: AiTextToSpeechOutput\n}\ntype AiTextToImageInput = {\n\tprompt: string\n\tnegative_prompt?: string\n\theight?: number\n\twidth?: number\n\timage?: number[]\n\timage_b64?: string\n\tmask?: number[]\n\tnum_steps?: number\n\tstrength?: number\n\tguidance?: number\n\tseed?: number\n}\ntype AiTextToImageOutput = ReadableStream<Uint8Array>\ndeclare abstract class BaseAiTextToImage {\n\tinputs: AiTextToImageInput\n\tpostProcessedOutputs: AiTextToImageOutput\n}\ntype AiTranslationInput = {\n\ttext: string\n\ttarget_lang: string\n\tsource_lang?: string\n}\ntype AiTranslationOutput = {\n\ttranslated_text?: string\n}\ndeclare abstract class BaseAiTranslation {\n\tinputs: AiTranslationInput\n\tpostProcessedOutputs: AiTranslationOutput\n}\n/**\n * Workers AI support for OpenAI's Responses API\n * Reference: https://github.com/openai/openai-node/blob/master/src/resources/responses/responses.ts\n *\n * It's a stripped down version from its source.\n * It currently supports basic function calling, json mode and accepts images as input.\n *\n * It does not include types for WebSearch, CodeInterpreter, FileInputs, MCP, CustomTools.\n * We plan to add those incrementally as model + platform capabilities evolve.\n */\ntype ResponsesInput = {\n\tbackground?: boolean | null\n\tconversation?: string | ResponseConversationParam | null\n\tinclude?: Array<ResponseIncludable> | null\n\tinput?: string | ResponseInput\n\tinstructions?: string | null\n\tmax_output_tokens?: number | null\n\tparallel_tool_calls?: boolean | null\n\tprevious_response_id?: string | null\n\tprompt_cache_key?: string\n\treasoning?: Reasoning | null\n\tsafety_identifier?: string\n\tservice_tier?: 'auto' | 'default' | 'flex' | 'scale' | 'priority' | null\n\tstream?: boolean | null\n\tstream_options?: StreamOptions | null\n\ttemperature?: number | null\n\ttext?: ResponseTextConfig\n\ttool_choice?: ToolChoiceOptions | ToolChoiceFunction\n\ttools?: Array<Tool>\n\ttop_p?: number | null\n\ttruncation?: 'auto' | 'disabled' | null\n}\ntype ResponsesOutput = {\n\tid?: string\n\tcreated_at?: number\n\toutput_text?: string\n\terror?: ResponseError | null\n\tincomplete_details?: ResponseIncompleteDetails | null\n\tinstructions?: string | Array<ResponseInputItem> | null\n\tobject?: 'response'\n\toutput?: Array<ResponseOutputItem>\n\tparallel_tool_calls?: boolean\n\ttemperature?: number | null\n\ttool_choice?: ToolChoiceOptions | ToolChoiceFunction\n\ttools?: Array<Tool>\n\ttop_p?: number | null\n\tmax_output_tokens?: number | null\n\tprevious_response_id?: string | null\n\tprompt?: ResponsePrompt | null\n\treasoning?: Reasoning | null\n\tsafety_identifier?: string\n\tservice_tier?: 'auto' | 'default' | 'flex' | 'scale' | 'priority' | null\n\tstatus?: ResponseStatus\n\ttext?: ResponseTextConfig\n\ttruncation?: 'auto' | 'disabled' | null\n\tusage?: ResponseUsage\n}\ntype EasyInputMessage = {\n\tcontent: string | ResponseInputMessageContentList\n\trole: 'user' | 'assistant' | 'system' | 'developer'\n\ttype?: 'message'\n}\ntype ResponsesFunctionTool = {\n\tname: string\n\tparameters: {\n\t\t[key: string]: unknown\n\t} | null\n\tstrict: boolean | null\n\ttype: 'function'\n\tdescription?: string | null\n}\ntype ResponseIncompleteDetails = {\n\treason?: 'max_output_tokens' | 'content_filter'\n}\ntype ResponsePrompt = {\n\tid: string\n\tvariables?: {\n\t\t[key: string]: string | ResponseInputText | ResponseInputImage\n\t} | null\n\tversion?: string | null\n}\ntype Reasoning = {\n\teffort?: ReasoningEffort | null\n\tgenerate_summary?: 'auto' | 'concise' | 'detailed' | null\n\tsummary?: 'auto' | 'concise' | 'detailed' | null\n}\ntype ResponseContent =\n\t| ResponseInputText\n\t| ResponseInputImage\n\t| ResponseOutputText\n\t| ResponseOutputRefusal\n\t| ResponseContentReasoningText\ntype ResponseContentReasoningText = {\n\ttext: string\n\ttype: 'reasoning_text'\n}\ntype ResponseConversationParam = {\n\tid: string\n}\ntype ResponseCreatedEvent = {\n\tresponse: Response\n\tsequence_number: number\n\ttype: 'response.created'\n}\ntype ResponseCustomToolCallOutput = {\n\tcall_id: string\n\toutput: string | Array<ResponseInputText | ResponseInputImage>\n\ttype: 'custom_tool_call_output'\n\tid?: string\n}\ntype ResponseError = {\n\tcode:\n\t\t| 'server_error'\n\t\t| 'rate_limit_exceeded'\n\t\t| 'invalid_prompt'\n\t\t| 'vector_store_timeout'\n\t\t| 'invalid_image'\n\t\t| 'invalid_image_format'\n\t\t| 'invalid_base64_image'\n\t\t| 'invalid_image_url'\n\t\t| 'image_too_large'\n\t\t| 'image_too_small'\n\t\t| 'image_parse_error'\n\t\t| 'image_content_policy_violation'\n\t\t| 'invalid_image_mode'\n\t\t| 'image_file_too_large'\n\t\t| 'unsupported_image_media_type'\n\t\t| 'empty_image_file'\n\t\t| 'failed_to_download_image'\n\t\t| 'image_file_not_found'\n\tmessage: string\n}\ntype ResponseErrorEvent = {\n\tcode: string | null\n\tmessage: string\n\tparam: string | null\n\tsequence_number: number\n\ttype: 'error'\n}\ntype ResponseFailedEvent = {\n\tresponse: Response\n\tsequence_number: number\n\ttype: 'response.failed'\n}\ntype ResponseFormatText = {\n\ttype: 'text'\n}\ntype ResponseFormatJSONObject = {\n\ttype: 'json_object'\n}\ntype ResponseFormatTextConfig =\n\t| ResponseFormatText\n\t| ResponseFormatTextJSONSchemaConfig\n\t| ResponseFormatJSONObject\ntype ResponseFormatTextJSONSchemaConfig = {\n\tname: string\n\tschema: {\n\t\t[key: string]: unknown\n\t}\n\ttype: 'json_schema'\n\tdescription?: string\n\tstrict?: boolean | null\n}\ntype ResponseFunctionCallArgumentsDeltaEvent = {\n\tdelta: string\n\titem_id: string\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.function_call_arguments.delta'\n}\ntype ResponseFunctionCallArgumentsDoneEvent = {\n\targuments: string\n\titem_id: string\n\tname: string\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.function_call_arguments.done'\n}\ntype ResponseFunctionCallOutputItem =\n\t| ResponseInputTextContent\n\t| ResponseInputImageContent\ntype ResponseFunctionCallOutputItemList = Array<ResponseFunctionCallOutputItem>\ntype ResponseFunctionToolCall = {\n\targuments: string\n\tcall_id: string\n\tname: string\n\ttype: 'function_call'\n\tid?: string\n\tstatus?: 'in_progress' | 'completed' | 'incomplete'\n}\ninterface ResponseFunctionToolCallItem extends ResponseFunctionToolCall {\n\tid: string\n}\ntype ResponseFunctionToolCallOutputItem = {\n\tid: string\n\tcall_id: string\n\toutput: string | Array<ResponseInputText | ResponseInputImage>\n\ttype: 'function_call_output'\n\tstatus?: 'in_progress' | 'completed' | 'incomplete'\n}\ntype ResponseIncludable =\n\t| 'message.input_image.image_url'\n\t| 'message.output_text.logprobs'\ntype ResponseIncompleteEvent = {\n\tresponse: Response\n\tsequence_number: number\n\ttype: 'response.incomplete'\n}\ntype ResponseInput = Array<ResponseInputItem>\ntype ResponseInputContent = ResponseInputText | ResponseInputImage\ntype ResponseInputImage = {\n\tdetail: 'low' | 'high' | 'auto'\n\ttype: 'input_image'\n\t/**\n\t * Base64 encoded image\n\t */\n\timage_url?: string | null\n}\ntype ResponseInputImageContent = {\n\ttype: 'input_image'\n\tdetail?: 'low' | 'high' | 'auto' | null\n\t/**\n\t * Base64 encoded image\n\t */\n\timage_url?: string | null\n}\ntype ResponseInputItem =\n\t| EasyInputMessage\n\t| ResponseInputItemMessage\n\t| ResponseOutputMessage\n\t| ResponseFunctionToolCall\n\t| ResponseInputItemFunctionCallOutput\n\t| ResponseReasoningItem\ntype ResponseInputItemFunctionCallOutput = {\n\tcall_id: string\n\toutput: string | ResponseFunctionCallOutputItemList\n\ttype: 'function_call_output'\n\tid?: string | null\n\tstatus?: 'in_progress' | 'completed' | 'incomplete' | null\n}\ntype ResponseInputItemMessage = {\n\tcontent: ResponseInputMessageContentList\n\trole: 'user' | 'system' | 'developer'\n\tstatus?: 'in_progress' | 'completed' | 'incomplete'\n\ttype?: 'message'\n}\ntype ResponseInputMessageContentList = Array<ResponseInputContent>\ntype ResponseInputMessageItem = {\n\tid: string\n\tcontent: ResponseInputMessageContentList\n\trole: 'user' | 'system' | 'developer'\n\tstatus?: 'in_progress' | 'completed' | 'incomplete'\n\ttype?: 'message'\n}\ntype ResponseInputText = {\n\ttext: string\n\ttype: 'input_text'\n}\ntype ResponseInputTextContent = {\n\ttext: string\n\ttype: 'input_text'\n}\ntype ResponseItem =\n\t| ResponseInputMessageItem\n\t| ResponseOutputMessage\n\t| ResponseFunctionToolCallItem\n\t| ResponseFunctionToolCallOutputItem\ntype ResponseOutputItem =\n\t| ResponseOutputMessage\n\t| ResponseFunctionToolCall\n\t| ResponseReasoningItem\ntype ResponseOutputItemAddedEvent = {\n\titem: ResponseOutputItem\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.output_item.added'\n}\ntype ResponseOutputItemDoneEvent = {\n\titem: ResponseOutputItem\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.output_item.done'\n}\ntype ResponseOutputMessage = {\n\tid: string\n\tcontent: Array<ResponseOutputText | ResponseOutputRefusal>\n\trole: 'assistant'\n\tstatus: 'in_progress' | 'completed' | 'incomplete'\n\ttype: 'message'\n}\ntype ResponseOutputRefusal = {\n\trefusal: string\n\ttype: 'refusal'\n}\ntype ResponseOutputText = {\n\ttext: string\n\ttype: 'output_text'\n\tlogprobs?: Array<Logprob>\n}\ntype ResponseReasoningItem = {\n\tid: string\n\tsummary: Array<ResponseReasoningSummaryItem>\n\ttype: 'reasoning'\n\tcontent?: Array<ResponseReasoningContentItem>\n\tencrypted_content?: string | null\n\tstatus?: 'in_progress' | 'completed' | 'incomplete'\n}\ntype ResponseReasoningSummaryItem = {\n\ttext: string\n\ttype: 'summary_text'\n}\ntype ResponseReasoningContentItem = {\n\ttext: string\n\ttype: 'reasoning_text'\n}\ntype ResponseReasoningTextDeltaEvent = {\n\tcontent_index: number\n\tdelta: string\n\titem_id: string\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.reasoning_text.delta'\n}\ntype ResponseReasoningTextDoneEvent = {\n\tcontent_index: number\n\titem_id: string\n\toutput_index: number\n\tsequence_number: number\n\ttext: string\n\ttype: 'response.reasoning_text.done'\n}\ntype ResponseRefusalDeltaEvent = {\n\tcontent_index: number\n\tdelta: string\n\titem_id: string\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.refusal.delta'\n}\ntype ResponseRefusalDoneEvent = {\n\tcontent_index: number\n\titem_id: string\n\toutput_index: number\n\trefusal: string\n\tsequence_number: number\n\ttype: 'response.refusal.done'\n}\ntype ResponseStatus =\n\t| 'completed'\n\t| 'failed'\n\t| 'in_progress'\n\t| 'cancelled'\n\t| 'queued'\n\t| 'incomplete'\ntype ResponseStreamEvent =\n\t| ResponseCompletedEvent\n\t| ResponseCreatedEvent\n\t| ResponseErrorEvent\n\t| ResponseFunctionCallArgumentsDeltaEvent\n\t| ResponseFunctionCallArgumentsDoneEvent\n\t| ResponseFailedEvent\n\t| ResponseIncompleteEvent\n\t| ResponseOutputItemAddedEvent\n\t| ResponseOutputItemDoneEvent\n\t| ResponseReasoningTextDeltaEvent\n\t| ResponseReasoningTextDoneEvent\n\t| ResponseRefusalDeltaEvent\n\t| ResponseRefusalDoneEvent\n\t| ResponseTextDeltaEvent\n\t| ResponseTextDoneEvent\ntype ResponseCompletedEvent = {\n\tresponse: Response\n\tsequence_number: number\n\ttype: 'response.completed'\n}\ntype ResponseTextConfig = {\n\tformat?: ResponseFormatTextConfig\n\tverbosity?: 'low' | 'medium' | 'high' | null\n}\ntype ResponseTextDeltaEvent = {\n\tcontent_index: number\n\tdelta: string\n\titem_id: string\n\tlogprobs: Array<Logprob>\n\toutput_index: number\n\tsequence_number: number\n\ttype: 'response.output_text.delta'\n}\ntype ResponseTextDoneEvent = {\n\tcontent_index: number\n\titem_id: string\n\tlogprobs: Array<Logprob>\n\toutput_index: number\n\tsequence_number: number\n\ttext: string\n\ttype: 'response.output_text.done'\n}\ntype Logprob = {\n\ttoken: string\n\tlogprob: number\n\ttop_logprobs?: Array<TopLogprob>\n}\ntype TopLogprob = {\n\ttoken?: string\n\tlogprob?: number\n}\ntype ResponseUsage = {\n\tinput_tokens: number\n\toutput_tokens: number\n\ttotal_tokens: number\n}\ntype Tool = ResponsesFunctionTool\ntype ToolChoiceFunction = {\n\tname: string\n\ttype: 'function'\n}\ntype ToolChoiceOptions = 'none'\ntype ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | null\ntype StreamOptions = {\n\tinclude_obfuscation?: boolean\n}\ntype Ai_Cf_Baai_Bge_Base_En_V1_5_Input =\n\t| {\n\t\t\ttext: string | string[]\n\t\t\t/**\n\t\t\t * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.\n\t\t\t */\n\t\t\tpooling?: 'mean' | 'cls'\n\t  }\n\t| {\n\t\t\t/**\n\t\t\t * Batch of the embeddings requests to run using async-queue\n\t\t\t */\n\t\t\trequests: {\n\t\t\t\ttext: string | string[]\n\t\t\t\t/**\n\t\t\t\t * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.\n\t\t\t\t */\n\t\t\t\tpooling?: 'mean' | 'cls'\n\t\t\t}[]\n\t  }\ntype Ai_Cf_Baai_Bge_Base_En_V1_5_Output =\n\t| {\n\t\t\tshape?: number[]\n\t\t\t/**\n\t\t\t * Embeddings of the requested text values\n\t\t\t */\n\t\t\tdata?: number[][]\n\t\t\t/**\n\t\t\t * The pooling method used in the embedding process.\n\t\t\t */\n\t\t\tpooling?: 'mean' | 'cls'\n\t  }\n\t| Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse\ninterface Ai_Cf_Baai_Bge_Base_En_V1_5_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Baai_Bge_Base_En_V1_5 {\n\tinputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Input\n\tpostProcessedOutputs: Ai_Cf_Baai_Bge_Base_En_V1_5_Output\n}\ntype Ai_Cf_Openai_Whisper_Input =\n\t| string\n\t| {\n\t\t\t/**\n\t\t\t * An array of integers that represent the audio data constrained to 8-bit unsigned integer values\n\t\t\t */\n\t\t\taudio: number[]\n\t  }\ninterface Ai_Cf_Openai_Whisper_Output {\n\t/**\n\t * The transcription\n\t */\n\ttext: string\n\tword_count?: number\n\twords?: {\n\t\tword?: string\n\t\t/**\n\t\t * The second this word begins in the recording\n\t\t */\n\t\tstart?: number\n\t\t/**\n\t\t * The ending second when the word completes\n\t\t */\n\t\tend?: number\n\t}[]\n\tvtt?: string\n}\ndeclare abstract class Base_Ai_Cf_Openai_Whisper {\n\tinputs: Ai_Cf_Openai_Whisper_Input\n\tpostProcessedOutputs: Ai_Cf_Openai_Whisper_Output\n}\ntype Ai_Cf_Meta_M2M100_1_2B_Input =\n\t| {\n\t\t\t/**\n\t\t\t * The text to be translated\n\t\t\t */\n\t\t\ttext: string\n\t\t\t/**\n\t\t\t * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified\n\t\t\t */\n\t\t\tsource_lang?: string\n\t\t\t/**\n\t\t\t * The language code to translate the text into (e.g., 'es' for Spanish)\n\t\t\t */\n\t\t\ttarget_lang: string\n\t  }\n\t| {\n\t\t\t/**\n\t\t\t * Batch of the embeddings requests to run using async-queue\n\t\t\t */\n\t\t\trequests: {\n\t\t\t\t/**\n\t\t\t\t * The text to be translated\n\t\t\t\t */\n\t\t\t\ttext: string\n\t\t\t\t/**\n\t\t\t\t * The language code of the source text (e.g., 'en' for English). Defaults to 'en' if not specified\n\t\t\t\t */\n\t\t\t\tsource_lang?: string\n\t\t\t\t/**\n\t\t\t\t * The language code to translate the text into (e.g., 'es' for Spanish)\n\t\t\t\t */\n\t\t\t\ttarget_lang: string\n\t\t\t}[]\n\t  }\ntype Ai_Cf_Meta_M2M100_1_2B_Output =\n\t| {\n\t\t\t/**\n\t\t\t * The translated text in the target language\n\t\t\t */\n\t\t\ttranslated_text?: string\n\t  }\n\t| Ai_Cf_Meta_M2M100_1_2B_AsyncResponse\ninterface Ai_Cf_Meta_M2M100_1_2B_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Meta_M2M100_1_2B {\n\tinputs: Ai_Cf_Meta_M2M100_1_2B_Input\n\tpostProcessedOutputs: Ai_Cf_Meta_M2M100_1_2B_Output\n}\ntype Ai_Cf_Baai_Bge_Small_En_V1_5_Input =\n\t| {\n\t\t\ttext: string | string[]\n\t\t\t/**\n\t\t\t * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.\n\t\t\t */\n\t\t\tpooling?: 'mean' | 'cls'\n\t  }\n\t| {\n\t\t\t/**\n\t\t\t * Batch of the embeddings requests to run using async-queue\n\t\t\t */\n\t\t\trequests: {\n\t\t\t\ttext: string | string[]\n\t\t\t\t/**\n\t\t\t\t * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.\n\t\t\t\t */\n\t\t\t\tpooling?: 'mean' | 'cls'\n\t\t\t}[]\n\t  }\ntype Ai_Cf_Baai_Bge_Small_En_V1_5_Output =\n\t| {\n\t\t\tshape?: number[]\n\t\t\t/**\n\t\t\t * Embeddings of the requested text values\n\t\t\t */\n\t\t\tdata?: number[][]\n\t\t\t/**\n\t\t\t * The pooling method used in the embedding process.\n\t\t\t */\n\t\t\tpooling?: 'mean' | 'cls'\n\t  }\n\t| Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse\ninterface Ai_Cf_Baai_Bge_Small_En_V1_5_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Baai_Bge_Small_En_V1_5 {\n\tinputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Input\n\tpostProcessedOutputs: Ai_Cf_Baai_Bge_Small_En_V1_5_Output\n}\ntype Ai_Cf_Baai_Bge_Large_En_V1_5_Input =\n\t| {\n\t\t\ttext: string | string[]\n\t\t\t/**\n\t\t\t * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.\n\t\t\t */\n\t\t\tpooling?: 'mean' | 'cls'\n\t  }\n\t| {\n\t\t\t/**\n\t\t\t * Batch of the embeddings requests to run using async-queue\n\t\t\t */\n\t\t\trequests: {\n\t\t\t\ttext: string | string[]\n\t\t\t\t/**\n\t\t\t\t * The pooling method used in the embedding process. `cls` pooling will generate more accurate embeddings on larger inputs - however, embeddings created with cls pooling are not compatible with embeddings generated with mean pooling. The default pooling method is `mean` in order for this to not be a breaking change, but we highly suggest using the new `cls` pooling for better accuracy.\n\t\t\t\t */\n\t\t\t\tpooling?: 'mean' | 'cls'\n\t\t\t}[]\n\t  }\ntype Ai_Cf_Baai_Bge_Large_En_V1_5_Output =\n\t| {\n\t\t\tshape?: number[]\n\t\t\t/**\n\t\t\t * Embeddings of the requested text values\n\t\t\t */\n\t\t\tdata?: number[][]\n\t\t\t/**\n\t\t\t * The pooling method used in the embedding process.\n\t\t\t */\n\t\t\tpooling?: 'mean' | 'cls'\n\t  }\n\t| Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse\ninterface Ai_Cf_Baai_Bge_Large_En_V1_5_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Baai_Bge_Large_En_V1_5 {\n\tinputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Input\n\tpostProcessedOutputs: Ai_Cf_Baai_Bge_Large_En_V1_5_Output\n}\ntype Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input =\n\t| string\n\t| {\n\t\t\t/**\n\t\t\t * The input text prompt for the model to generate a response.\n\t\t\t */\n\t\t\tprompt?: string\n\t\t\t/**\n\t\t\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t\t\t */\n\t\t\traw?: boolean\n\t\t\t/**\n\t\t\t * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t\t\t */\n\t\t\ttop_p?: number\n\t\t\t/**\n\t\t\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t\t\t */\n\t\t\ttop_k?: number\n\t\t\t/**\n\t\t\t * Random seed for reproducibility of the generation.\n\t\t\t */\n\t\t\tseed?: number\n\t\t\t/**\n\t\t\t * Penalty for repeated tokens; higher values discourage repetition.\n\t\t\t */\n\t\t\trepetition_penalty?: number\n\t\t\t/**\n\t\t\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t\t\t */\n\t\t\tfrequency_penalty?: number\n\t\t\t/**\n\t\t\t * Increases the likelihood of the model introducing new topics.\n\t\t\t */\n\t\t\tpresence_penalty?: number\n\t\t\timage: number[] | (string & NonNullable<unknown>)\n\t\t\t/**\n\t\t\t * The maximum number of tokens to generate in the response.\n\t\t\t */\n\t\t\tmax_tokens?: number\n\t  }\ninterface Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output {\n\tdescription?: string\n}\ndeclare abstract class Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M {\n\tinputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Input\n\tpostProcessedOutputs: Ai_Cf_Unum_Uform_Gen2_Qwen_500M_Output\n}\ntype Ai_Cf_Openai_Whisper_Tiny_En_Input =\n\t| string\n\t| {\n\t\t\t/**\n\t\t\t * An array of integers that represent the audio data constrained to 8-bit unsigned integer values\n\t\t\t */\n\t\t\taudio: number[]\n\t  }\ninterface Ai_Cf_Openai_Whisper_Tiny_En_Output {\n\t/**\n\t * The transcription\n\t */\n\ttext: string\n\tword_count?: number\n\twords?: {\n\t\tword?: string\n\t\t/**\n\t\t * The second this word begins in the recording\n\t\t */\n\t\tstart?: number\n\t\t/**\n\t\t * The ending second when the word completes\n\t\t */\n\t\tend?: number\n\t}[]\n\tvtt?: string\n}\ndeclare abstract class Base_Ai_Cf_Openai_Whisper_Tiny_En {\n\tinputs: Ai_Cf_Openai_Whisper_Tiny_En_Input\n\tpostProcessedOutputs: Ai_Cf_Openai_Whisper_Tiny_En_Output\n}\ninterface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input {\n\t/**\n\t * Base64 encoded value of the audio data.\n\t */\n\taudio: string\n\t/**\n\t * Supported tasks are 'translate' or 'transcribe'.\n\t */\n\ttask?: string\n\t/**\n\t * The language of the audio being transcribed or translated.\n\t */\n\tlanguage?: string\n\t/**\n\t * Preprocess the audio with a voice activity detection model.\n\t */\n\tvad_filter?: boolean\n\t/**\n\t * A text prompt to help provide context to the model on the contents of the audio.\n\t */\n\tinitial_prompt?: string\n\t/**\n\t * The prefix it appended the the beginning of the output of the transcription and can guide the transcription result.\n\t */\n\tprefix?: string\n}\ninterface Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output {\n\ttranscription_info?: {\n\t\t/**\n\t\t * The language of the audio being transcribed or translated.\n\t\t */\n\t\tlanguage?: string\n\t\t/**\n\t\t * The confidence level or probability of the detected language being accurate, represented as a decimal between 0 and 1.\n\t\t */\n\t\tlanguage_probability?: number\n\t\t/**\n\t\t * The total duration of the original audio file, in seconds.\n\t\t */\n\t\tduration?: number\n\t\t/**\n\t\t * The duration of the audio after applying Voice Activity Detection (VAD) to remove silent or irrelevant sections, in seconds.\n\t\t */\n\t\tduration_after_vad?: number\n\t}\n\t/**\n\t * The complete transcription of the audio.\n\t */\n\ttext: string\n\t/**\n\t * The total number of words in the transcription.\n\t */\n\tword_count?: number\n\tsegments?: {\n\t\t/**\n\t\t * The starting time of the segment within the audio, in seconds.\n\t\t */\n\t\tstart?: number\n\t\t/**\n\t\t * The ending time of the segment within the audio, in seconds.\n\t\t */\n\t\tend?: number\n\t\t/**\n\t\t * The transcription of the segment.\n\t\t */\n\t\ttext?: string\n\t\t/**\n\t\t * The temperature used in the decoding process, controlling randomness in predictions. Lower values result in more deterministic outputs.\n\t\t */\n\t\ttemperature?: number\n\t\t/**\n\t\t * The average log probability of the predictions for the words in this segment, indicating overall confidence.\n\t\t */\n\t\tavg_logprob?: number\n\t\t/**\n\t\t * The compression ratio of the input to the output, measuring how much the text was compressed during the transcription process.\n\t\t */\n\t\tcompression_ratio?: number\n\t\t/**\n\t\t * The probability that the segment contains no speech, represented as a decimal between 0 and 1.\n\t\t */\n\t\tno_speech_prob?: number\n\t\twords?: {\n\t\t\t/**\n\t\t\t * The individual word transcribed from the audio.\n\t\t\t */\n\t\t\tword?: string\n\t\t\t/**\n\t\t\t * The starting time of the word within the audio, in seconds.\n\t\t\t */\n\t\t\tstart?: number\n\t\t\t/**\n\t\t\t * The ending time of the word within the audio, in seconds.\n\t\t\t */\n\t\t\tend?: number\n\t\t}[]\n\t}[]\n\t/**\n\t * The transcription in WebVTT format, which includes timing and text information for use in subtitles.\n\t */\n\tvtt?: string\n}\ndeclare abstract class Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo {\n\tinputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Input\n\tpostProcessedOutputs: Ai_Cf_Openai_Whisper_Large_V3_Turbo_Output\n}\ntype Ai_Cf_Baai_Bge_M3_Input =\n\t| Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts\n\t| Ai_Cf_Baai_Bge_M3_Input_Embedding\n\t| {\n\t\t\t/**\n\t\t\t * Batch of the embeddings requests to run using async-queue\n\t\t\t */\n\t\t\trequests: (\n\t\t\t\t| Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1\n\t\t\t\t| Ai_Cf_Baai_Bge_M3_Input_Embedding_1\n\t\t\t)[]\n\t  }\ninterface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts {\n\t/**\n\t * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts\n\t */\n\tquery?: string\n\t/**\n\t * List of provided contexts. Note that the index in this array is important, as the response will refer to it.\n\t */\n\tcontexts: {\n\t\t/**\n\t\t * One of the provided context content\n\t\t */\n\t\ttext?: string\n\t}[]\n\t/**\n\t * When provided with too long context should the model error out or truncate the context to fit?\n\t */\n\ttruncate_inputs?: boolean\n}\ninterface Ai_Cf_Baai_Bge_M3_Input_Embedding {\n\ttext: string | string[]\n\t/**\n\t * When provided with too long context should the model error out or truncate the context to fit?\n\t */\n\ttruncate_inputs?: boolean\n}\ninterface Ai_Cf_Baai_Bge_M3_Input_QueryAnd_Contexts_1 {\n\t/**\n\t * A query you wish to perform against the provided contexts. If no query is provided the model with respond with embeddings for contexts\n\t */\n\tquery?: string\n\t/**\n\t * List of provided contexts. Note that the index in this array is important, as the response will refer to it.\n\t */\n\tcontexts: {\n\t\t/**\n\t\t * One of the provided context content\n\t\t */\n\t\ttext?: string\n\t}[]\n\t/**\n\t * When provided with too long context should the model error out or truncate the context to fit?\n\t */\n\ttruncate_inputs?: boolean\n}\ninterface Ai_Cf_Baai_Bge_M3_Input_Embedding_1 {\n\ttext: string | string[]\n\t/**\n\t * When provided with too long context should the model error out or truncate the context to fit?\n\t */\n\ttruncate_inputs?: boolean\n}\ntype Ai_Cf_Baai_Bge_M3_Output =\n\t| Ai_Cf_Baai_Bge_M3_Ouput_Query\n\t| Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts\n\t| Ai_Cf_Baai_Bge_M3_Ouput_Embedding\n\t| Ai_Cf_Baai_Bge_M3_AsyncResponse\ninterface Ai_Cf_Baai_Bge_M3_Ouput_Query {\n\tresponse?: {\n\t\t/**\n\t\t * Index of the context in the request\n\t\t */\n\t\tid?: number\n\t\t/**\n\t\t * Score of the context under the index.\n\t\t */\n\t\tscore?: number\n\t}[]\n}\ninterface Ai_Cf_Baai_Bge_M3_Output_EmbeddingFor_Contexts {\n\tresponse?: number[][]\n\tshape?: number[]\n\t/**\n\t * The pooling method used in the embedding process.\n\t */\n\tpooling?: 'mean' | 'cls'\n}\ninterface Ai_Cf_Baai_Bge_M3_Ouput_Embedding {\n\tshape?: number[]\n\t/**\n\t * Embeddings of the requested text values\n\t */\n\tdata?: number[][]\n\t/**\n\t * The pooling method used in the embedding process.\n\t */\n\tpooling?: 'mean' | 'cls'\n}\ninterface Ai_Cf_Baai_Bge_M3_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Baai_Bge_M3 {\n\tinputs: Ai_Cf_Baai_Bge_M3_Input\n\tpostProcessedOutputs: Ai_Cf_Baai_Bge_M3_Output\n}\ninterface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input {\n\t/**\n\t * A text description of the image you want to generate.\n\t */\n\tprompt: string\n\t/**\n\t * The number of diffusion steps; higher values can improve quality but take longer.\n\t */\n\tsteps?: number\n}\ninterface Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output {\n\t/**\n\t * The generated image in Base64 format.\n\t */\n\timage?: string\n}\ndeclare abstract class Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell {\n\tinputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Input\n\tpostProcessedOutputs: Ai_Cf_Black_Forest_Labs_Flux_1_Schnell_Output\n}\ntype Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input =\n\t| Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt\n\t| Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages\ninterface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\timage?: number[] | (string & NonNullable<unknown>)\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n}\ninterface Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole?: string\n\t\t/**\n\t\t * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001\n\t\t */\n\t\ttool_call_id?: string\n\t\tcontent?:\n\t\t\t| string\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }[]\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }\n\t}[]\n\timage?: number[] | (string & NonNullable<unknown>)\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\t/**\n\t * If true, the response will be streamed back incrementally.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Controls the creativity of the AI's responses by adjusting how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ntype Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output = {\n\t/**\n\t * The generated text response from the model\n\t */\n\tresponse?: string\n\t/**\n\t * An array of tool calls requests made during the response generation\n\t */\n\ttool_calls?: {\n\t\t/**\n\t\t * The arguments passed to be passed to the tool call request\n\t\t */\n\t\targuments?: object\n\t\t/**\n\t\t * The name of the tool to be called\n\t\t */\n\t\tname?: string\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct {\n\tinputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Input\n\tpostProcessedOutputs: Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct_Output\n}\ntype Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input =\n\t| Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt\n\t| Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages\n\t| Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n\tresponse_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole: string\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_1 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Async_Batch {\n\trequests?: {\n\t\t/**\n\t\t * User-supplied reference. This field will be present in the response as well it can be used to reference the request and response. It's NOT validated to be unique.\n\t\t */\n\t\texternal_reference?: string\n\t\t/**\n\t\t * Prompt for the text generation model\n\t\t */\n\t\tprompt?: string\n\t\t/**\n\t\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t\t */\n\t\tstream?: boolean\n\t\t/**\n\t\t * The maximum number of tokens to generate in the response.\n\t\t */\n\t\tmax_tokens?: number\n\t\t/**\n\t\t * Controls the randomness of the output; higher values produce more random results.\n\t\t */\n\t\ttemperature?: number\n\t\t/**\n\t\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t\t */\n\t\ttop_p?: number\n\t\t/**\n\t\t * Random seed for reproducibility of the generation.\n\t\t */\n\t\tseed?: number\n\t\t/**\n\t\t * Penalty for repeated tokens; higher values discourage repetition.\n\t\t */\n\t\trepetition_penalty?: number\n\t\t/**\n\t\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t\t */\n\t\tfrequency_penalty?: number\n\t\t/**\n\t\t * Increases the likelihood of the model introducing new topics.\n\t\t */\n\t\tpresence_penalty?: number\n\t\tresponse_format?: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2\n\t}[]\n}\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_JSON_Mode_2 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ntype Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output =\n\t| {\n\t\t\t/**\n\t\t\t * The generated text response from the model\n\t\t\t */\n\t\t\tresponse: string\n\t\t\t/**\n\t\t\t * Usage statistics for the inference request\n\t\t\t */\n\t\t\tusage?: {\n\t\t\t\t/**\n\t\t\t\t * Total number of tokens in input\n\t\t\t\t */\n\t\t\t\tprompt_tokens?: number\n\t\t\t\t/**\n\t\t\t\t * Total number of tokens in output\n\t\t\t\t */\n\t\t\t\tcompletion_tokens?: number\n\t\t\t\t/**\n\t\t\t\t * Total number of input and output tokens\n\t\t\t\t */\n\t\t\t\ttotal_tokens?: number\n\t\t\t}\n\t\t\t/**\n\t\t\t * An array of tool calls requests made during the response generation\n\t\t\t */\n\t\t\ttool_calls?: {\n\t\t\t\t/**\n\t\t\t\t * The arguments passed to be passed to the tool call request\n\t\t\t\t */\n\t\t\t\targuments?: object\n\t\t\t\t/**\n\t\t\t\t * The name of the tool to be called\n\t\t\t\t */\n\t\t\t\tname?: string\n\t\t\t}[]\n\t  }\n\t| string\n\t| Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse\ninterface Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast {\n\tinputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Input\n\tpostProcessedOutputs: Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast_Output\n}\ninterface Ai_Cf_Meta_Llama_Guard_3_8B_Input {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender must alternate between 'user' and 'assistant'.\n\t\t */\n\t\trole: 'user' | 'assistant'\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Dictate the output format of the generated response.\n\t */\n\tresponse_format?: {\n\t\t/**\n\t\t * Set to json_object to process and output generated text as JSON.\n\t\t */\n\t\ttype?: string\n\t}\n}\ninterface Ai_Cf_Meta_Llama_Guard_3_8B_Output {\n\tresponse?:\n\t\t| string\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Whether the conversation is safe or not.\n\t\t\t\t */\n\t\t\t\tsafe?: boolean\n\t\t\t\t/**\n\t\t\t\t * A list of what hazard categories predicted for the conversation, if the conversation is deemed unsafe.\n\t\t\t\t */\n\t\t\t\tcategories?: string[]\n\t\t  }\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n}\ndeclare abstract class Base_Ai_Cf_Meta_Llama_Guard_3_8B {\n\tinputs: Ai_Cf_Meta_Llama_Guard_3_8B_Input\n\tpostProcessedOutputs: Ai_Cf_Meta_Llama_Guard_3_8B_Output\n}\ninterface Ai_Cf_Baai_Bge_Reranker_Base_Input {\n\t/**\n\t * A query you wish to perform against the provided contexts.\n\t */\n\t/**\n\t * Number of returned results starting with the best score.\n\t */\n\ttop_k?: number\n\t/**\n\t * List of provided contexts. Note that the index in this array is important, as the response will refer to it.\n\t */\n\tcontexts: {\n\t\t/**\n\t\t * One of the provided context content\n\t\t */\n\t\ttext?: string\n\t}[]\n}\ninterface Ai_Cf_Baai_Bge_Reranker_Base_Output {\n\tresponse?: {\n\t\t/**\n\t\t * Index of the context in the request\n\t\t */\n\t\tid?: number\n\t\t/**\n\t\t * Score of the context under the index.\n\t\t */\n\t\tscore?: number\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Baai_Bge_Reranker_Base {\n\tinputs: Ai_Cf_Baai_Bge_Reranker_Base_Input\n\tpostProcessedOutputs: Ai_Cf_Baai_Bge_Reranker_Base_Output\n}\ntype Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input =\n\t| Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt\n\t| Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages\ninterface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n\tresponse_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole: string\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_JSON_Mode_1 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ntype Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output = {\n\t/**\n\t * The generated text response from the model\n\t */\n\tresponse: string\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * An array of tool calls requests made during the response generation\n\t */\n\ttool_calls?: {\n\t\t/**\n\t\t * The arguments passed to be passed to the tool call request\n\t\t */\n\t\targuments?: object\n\t\t/**\n\t\t * The name of the tool to be called\n\t\t */\n\t\tname?: string\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct {\n\tinputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Input\n\tpostProcessedOutputs: Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct_Output\n}\ntype Ai_Cf_Qwen_Qwq_32B_Input =\n\t| Ai_Cf_Qwen_Qwq_32B_Prompt\n\t| Ai_Cf_Qwen_Qwq_32B_Messages\ninterface Ai_Cf_Qwen_Qwq_32B_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwq_32B_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole?: string\n\t\t/**\n\t\t * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001\n\t\t */\n\t\ttool_call_id?: string\n\t\tcontent?:\n\t\t\t| string\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }[]\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ntype Ai_Cf_Qwen_Qwq_32B_Output = {\n\t/**\n\t * The generated text response from the model\n\t */\n\tresponse: string\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * An array of tool calls requests made during the response generation\n\t */\n\ttool_calls?: {\n\t\t/**\n\t\t * The arguments passed to be passed to the tool call request\n\t\t */\n\t\targuments?: object\n\t\t/**\n\t\t * The name of the tool to be called\n\t\t */\n\t\tname?: string\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Qwen_Qwq_32B {\n\tinputs: Ai_Cf_Qwen_Qwq_32B_Input\n\tpostProcessedOutputs: Ai_Cf_Qwen_Qwq_32B_Output\n}\ntype Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input =\n\t| Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt\n\t| Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages\ninterface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole?: string\n\t\t/**\n\t\t * The tool call id. Must be supplied for tool calls for Mistral-3. If you don't know what to put here you can fall back to 000000001\n\t\t */\n\t\ttool_call_id?: string\n\t\tcontent?:\n\t\t\t| string\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }[]\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ntype Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output = {\n\t/**\n\t * The generated text response from the model\n\t */\n\tresponse: string\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * An array of tool calls requests made during the response generation\n\t */\n\ttool_calls?: {\n\t\t/**\n\t\t * The arguments passed to be passed to the tool call request\n\t\t */\n\t\targuments?: object\n\t\t/**\n\t\t * The name of the tool to be called\n\t\t */\n\t\tname?: string\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct {\n\tinputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Input\n\tpostProcessedOutputs: Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Output\n}\ntype Ai_Cf_Google_Gemma_3_12B_It_Input =\n\t| Ai_Cf_Google_Gemma_3_12B_It_Prompt\n\t| Ai_Cf_Google_Gemma_3_12B_It_Messages\ninterface Ai_Cf_Google_Gemma_3_12B_It_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Google_Gemma_3_12B_It_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole?: string\n\t\tcontent?:\n\t\t\t| string\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }[]\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ntype Ai_Cf_Google_Gemma_3_12B_It_Output = {\n\t/**\n\t * The generated text response from the model\n\t */\n\tresponse: string\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * An array of tool calls requests made during the response generation\n\t */\n\ttool_calls?: {\n\t\t/**\n\t\t * The arguments passed to be passed to the tool call request\n\t\t */\n\t\targuments?: object\n\t\t/**\n\t\t * The name of the tool to be called\n\t\t */\n\t\tname?: string\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Google_Gemma_3_12B_It {\n\tinputs: Ai_Cf_Google_Gemma_3_12B_It_Input\n\tpostProcessedOutputs: Ai_Cf_Google_Gemma_3_12B_It_Output\n}\ntype Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input =\n\t| Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt\n\t| Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages\n\t| Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch\ninterface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\tresponse_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole?: string\n\t\t/**\n\t\t * The tool call id. If you don't know what to put here you can fall back to 000000001\n\t\t */\n\t\ttool_call_id?: string\n\t\tcontent?:\n\t\t\t| string\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }[]\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Async_Batch {\n\trequests: (\n\t\t| Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner\n\t\t| Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner\n\t)[]\n}\ninterface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Prompt_Inner {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\tresponse_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole?: string\n\t\t/**\n\t\t * The tool call id. If you don't know what to put here you can fall back to 000000001\n\t\t */\n\t\ttool_call_id?: string\n\t\tcontent?:\n\t\t\t| string\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }[]\n\t\t\t| {\n\t\t\t\t\t/**\n\t\t\t\t\t * Type of the content provided\n\t\t\t\t\t */\n\t\t\t\t\ttype?: string\n\t\t\t\t\ttext?: string\n\t\t\t\t\timage_url?: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * image uri with data (e.g. data:image/jpeg;base64,/9j/...). HTTP URL will not be accepted\n\t\t\t\t\t\t */\n\t\t\t\t\t\turl?: string\n\t\t\t\t\t}\n\t\t\t  }\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode\n\t/**\n\t * JSON schema that should be fulfilled for the response.\n\t */\n\tguided_json?: object\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ntype Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output = {\n\t/**\n\t * The generated text response from the model\n\t */\n\tresponse: string\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * An array of tool calls requests made during the response generation\n\t */\n\ttool_calls?: {\n\t\t/**\n\t\t * The tool call id.\n\t\t */\n\t\tid?: string\n\t\t/**\n\t\t * Specifies the type of tool (e.g., 'function').\n\t\t */\n\t\ttype?: string\n\t\t/**\n\t\t * Details of the function tool.\n\t\t */\n\t\tfunction?: {\n\t\t\t/**\n\t\t\t * The name of the tool to be called\n\t\t\t */\n\t\t\tname?: string\n\t\t\t/**\n\t\t\t * The arguments passed to be passed to the tool call request\n\t\t\t */\n\t\t\targuments?: object\n\t\t}\n\t}[]\n}\ndeclare abstract class Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct {\n\tinputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Input\n\tpostProcessedOutputs: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Output\n}\ntype Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input =\n\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt\n\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages\n\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n\tresponse_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole: string\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_1 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Async_Batch {\n\trequests: (\n\t\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1\n\t\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1\n\t)[]\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Prompt_1 {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n\tresponse_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_2 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Messages_1 {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole: string\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_JSON_Mode_3 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ntype Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output =\n\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response\n\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response\n\t| string\n\t| Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Chat_Completion_Response {\n\t/**\n\t * Unique identifier for the completion\n\t */\n\tid?: string\n\t/**\n\t * Object type identifier\n\t */\n\tobject?: 'chat.completion'\n\t/**\n\t * Unix timestamp of when the completion was created\n\t */\n\tcreated?: number\n\t/**\n\t * Model used for the completion\n\t */\n\tmodel?: string\n\t/**\n\t * List of completion choices\n\t */\n\tchoices?: {\n\t\t/**\n\t\t * Index of the choice in the list\n\t\t */\n\t\tindex?: number\n\t\t/**\n\t\t * The message generated by the model\n\t\t */\n\t\tmessage?: {\n\t\t\t/**\n\t\t\t * Role of the message author\n\t\t\t */\n\t\t\trole: string\n\t\t\t/**\n\t\t\t * The content of the message\n\t\t\t */\n\t\t\tcontent: string\n\t\t\t/**\n\t\t\t * Internal reasoning content (if available)\n\t\t\t */\n\t\t\treasoning_content?: string\n\t\t\t/**\n\t\t\t * Tool calls made by the assistant\n\t\t\t */\n\t\t\ttool_calls?: {\n\t\t\t\t/**\n\t\t\t\t * Unique identifier for the tool call\n\t\t\t\t */\n\t\t\t\tid: string\n\t\t\t\t/**\n\t\t\t\t * Type of tool call\n\t\t\t\t */\n\t\t\t\ttype: 'function'\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * Name of the function to call\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * JSON string of arguments for the function\n\t\t\t\t\t */\n\t\t\t\t\targuments: string\n\t\t\t\t}\n\t\t\t}[]\n\t\t}\n\t\t/**\n\t\t * Reason why the model stopped generating\n\t\t */\n\t\tfinish_reason?: string\n\t\t/**\n\t\t * Stop reason (may be null)\n\t\t */\n\t\tstop_reason?: string | null\n\t\t/**\n\t\t * Log probabilities (if requested)\n\t\t */\n\t\tlogprobs?: {} | null\n\t}[]\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * Log probabilities for the prompt (if requested)\n\t */\n\tprompt_logprobs?: {} | null\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Text_Completion_Response {\n\t/**\n\t * Unique identifier for the completion\n\t */\n\tid?: string\n\t/**\n\t * Object type identifier\n\t */\n\tobject?: 'text_completion'\n\t/**\n\t * Unix timestamp of when the completion was created\n\t */\n\tcreated?: number\n\t/**\n\t * Model used for the completion\n\t */\n\tmodel?: string\n\t/**\n\t * List of completion choices\n\t */\n\tchoices?: {\n\t\t/**\n\t\t * Index of the choice in the list\n\t\t */\n\t\tindex: number\n\t\t/**\n\t\t * The generated text completion\n\t\t */\n\t\ttext: string\n\t\t/**\n\t\t * Reason why the model stopped generating\n\t\t */\n\t\tfinish_reason: string\n\t\t/**\n\t\t * Stop reason (may be null)\n\t\t */\n\t\tstop_reason?: string | null\n\t\t/**\n\t\t * Log probabilities (if requested)\n\t\t */\n\t\tlogprobs?: {} | null\n\t\t/**\n\t\t * Log probabilities for the prompt (if requested)\n\t\t */\n\t\tprompt_logprobs?: {} | null\n\t}[]\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n}\ninterface Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8 {\n\tinputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Input\n\tpostProcessedOutputs: Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8_Output\n}\ninterface Ai_Cf_Deepgram_Nova_3_Input {\n\taudio: {\n\t\tbody: object\n\t\tcontentType: string\n\t}\n\t/**\n\t * Sets how the model will interpret strings submitted to the custom_topic param. When strict, the model will only return topics submitted using the custom_topic param. When extended, the model will return its own detected topics in addition to those submitted using the custom_topic param.\n\t */\n\tcustom_topic_mode?: 'extended' | 'strict'\n\t/**\n\t * Custom topics you want the model to detect within your input audio or text if present Submit up to 100\n\t */\n\tcustom_topic?: string\n\t/**\n\t * Sets how the model will interpret intents submitted to the custom_intent param. When strict, the model will only return intents submitted using the custom_intent param. When extended, the model will return its own detected intents in addition those submitted using the custom_intents param\n\t */\n\tcustom_intent_mode?: 'extended' | 'strict'\n\t/**\n\t * Custom intents you want the model to detect within your input audio if present\n\t */\n\tcustom_intent?: string\n\t/**\n\t * Identifies and extracts key entities from content in submitted audio\n\t */\n\tdetect_entities?: boolean\n\t/**\n\t * Identifies the dominant language spoken in submitted audio\n\t */\n\tdetect_language?: boolean\n\t/**\n\t * Recognize speaker changes. Each word in the transcript will be assigned a speaker number starting at 0\n\t */\n\tdiarize?: boolean\n\t/**\n\t * Identify and extract key entities from content in submitted audio\n\t */\n\tdictation?: boolean\n\t/**\n\t * Specify the expected encoding of your submitted audio\n\t */\n\tencoding?:\n\t\t| 'linear16'\n\t\t| 'flac'\n\t\t| 'mulaw'\n\t\t| 'amr-nb'\n\t\t| 'amr-wb'\n\t\t| 'opus'\n\t\t| 'speex'\n\t\t| 'g729'\n\t/**\n\t * Arbitrary key-value pairs that are attached to the API response for usage in downstream processing\n\t */\n\textra?: string\n\t/**\n\t * Filler Words can help transcribe interruptions in your audio, like 'uh' and 'um'\n\t */\n\tfiller_words?: boolean\n\t/**\n\t * Key term prompting can boost or suppress specialized terminology and brands.\n\t */\n\tkeyterm?: string\n\t/**\n\t * Keywords can boost or suppress specialized terminology and brands.\n\t */\n\tkeywords?: string\n\t/**\n\t * The BCP-47 language tag that hints at the primary spoken language. Depending on the Model and API endpoint you choose only certain languages are available.\n\t */\n\tlanguage?: string\n\t/**\n\t * Spoken measurements will be converted to their corresponding abbreviations.\n\t */\n\tmeasurements?: boolean\n\t/**\n\t * Opts out requests from the Deepgram Model Improvement Program. Refer to our Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip.\n\t */\n\tmip_opt_out?: boolean\n\t/**\n\t * Mode of operation for the model representing broad area of topic that will be talked about in the supplied audio\n\t */\n\tmode?: 'general' | 'medical' | 'finance'\n\t/**\n\t * Transcribe each audio channel independently.\n\t */\n\tmultichannel?: boolean\n\t/**\n\t * Numerals converts numbers from written format to numerical format.\n\t */\n\tnumerals?: boolean\n\t/**\n\t * Splits audio into paragraphs to improve transcript readability.\n\t */\n\tparagraphs?: boolean\n\t/**\n\t * Profanity Filter looks for recognized profanity and converts it to the nearest recognized non-profane word or removes it from the transcript completely.\n\t */\n\tprofanity_filter?: boolean\n\t/**\n\t * Add punctuation and capitalization to the transcript.\n\t */\n\tpunctuate?: boolean\n\t/**\n\t * Redaction removes sensitive information from your transcripts.\n\t */\n\tredact?: string\n\t/**\n\t * Search for terms or phrases in submitted audio and replaces them.\n\t */\n\treplace?: string\n\t/**\n\t * Search for terms or phrases in submitted audio.\n\t */\n\tsearch?: string\n\t/**\n\t * Recognizes the sentiment throughout a transcript or text.\n\t */\n\tsentiment?: boolean\n\t/**\n\t * Apply formatting to transcript output. When set to true, additional formatting will be applied to transcripts to improve readability.\n\t */\n\tsmart_format?: boolean\n\t/**\n\t * Detect topics throughout a transcript or text.\n\t */\n\ttopics?: boolean\n\t/**\n\t * Segments speech into meaningful semantic units.\n\t */\n\tutterances?: boolean\n\t/**\n\t * Seconds to wait before detecting a pause between words in submitted audio.\n\t */\n\tutt_split?: number\n\t/**\n\t * The number of channels in the submitted audio\n\t */\n\tchannels?: number\n\t/**\n\t * Specifies whether the streaming endpoint should provide ongoing transcription updates as more audio is received. When set to true, the endpoint sends continuous updates, meaning transcription results may evolve over time. Note: Supported only for webosockets.\n\t */\n\tinterim_results?: boolean\n\t/**\n\t * Indicates how long model will wait to detect whether a speaker has finished speaking or pauses for a significant period of time. When set to a value, the streaming endpoint immediately finalizes the transcription for the processed time range and returns the transcript with a speech_final parameter set to true. Can also be set to false to disable endpointing\n\t */\n\tendpointing?: string\n\t/**\n\t * Indicates that speech has started. You'll begin receiving Speech Started messages upon speech starting. Note: Supported only for webosockets.\n\t */\n\tvad_events?: boolean\n\t/**\n\t * Indicates how long model will wait to send an UtteranceEnd message after a word has been transcribed. Use with interim_results. Note: Supported only for webosockets.\n\t */\n\tutterance_end_ms?: boolean\n}\ninterface Ai_Cf_Deepgram_Nova_3_Output {\n\tresults?: {\n\t\tchannels?: {\n\t\t\talternatives?: {\n\t\t\t\tconfidence?: number\n\t\t\t\ttranscript?: string\n\t\t\t\twords?: {\n\t\t\t\t\tconfidence?: number\n\t\t\t\t\tend?: number\n\t\t\t\t\tstart?: number\n\t\t\t\t\tword?: string\n\t\t\t\t}[]\n\t\t\t}[]\n\t\t}[]\n\t\tsummary?: {\n\t\t\tresult?: string\n\t\t\tshort?: string\n\t\t}\n\t\tsentiments?: {\n\t\t\tsegments?: {\n\t\t\t\ttext?: string\n\t\t\t\tstart_word?: number\n\t\t\t\tend_word?: number\n\t\t\t\tsentiment?: string\n\t\t\t\tsentiment_score?: number\n\t\t\t}[]\n\t\t\taverage?: {\n\t\t\t\tsentiment?: string\n\t\t\t\tsentiment_score?: number\n\t\t\t}\n\t\t}\n\t}\n}\ndeclare abstract class Base_Ai_Cf_Deepgram_Nova_3 {\n\tinputs: Ai_Cf_Deepgram_Nova_3_Input\n\tpostProcessedOutputs: Ai_Cf_Deepgram_Nova_3_Output\n}\ninterface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input {\n\tqueries?: string | string[]\n\t/**\n\t * Optional instruction for the task\n\t */\n\tinstruction?: string\n\tdocuments?: string | string[]\n\ttext?: string | string[]\n}\ninterface Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output {\n\tdata?: number[][]\n\tshape?: number[]\n}\ndeclare abstract class Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B {\n\tinputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Input\n\tpostProcessedOutputs: Ai_Cf_Qwen_Qwen3_Embedding_0_6B_Output\n}\ntype Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input =\n\t| {\n\t\t\t/**\n\t\t\t * readable stream with audio data and content-type specified for that data\n\t\t\t */\n\t\t\taudio: {\n\t\t\t\tbody: object\n\t\t\t\tcontentType: string\n\t\t\t}\n\t\t\t/**\n\t\t\t * type of data PCM data that's sent to the inference server as raw array\n\t\t\t */\n\t\t\tdtype?: 'uint8' | 'float32' | 'float64'\n\t  }\n\t| {\n\t\t\t/**\n\t\t\t * base64 encoded audio data\n\t\t\t */\n\t\t\taudio: string\n\t\t\t/**\n\t\t\t * type of data PCM data that's sent to the inference server as raw array\n\t\t\t */\n\t\t\tdtype?: 'uint8' | 'float32' | 'float64'\n\t  }\ninterface Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output {\n\t/**\n\t * if true, end-of-turn was detected\n\t */\n\tis_complete?: boolean\n\t/**\n\t * probability of the end-of-turn detection\n\t */\n\tprobability?: number\n}\ndeclare abstract class Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2 {\n\tinputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Input\n\tpostProcessedOutputs: Ai_Cf_Pipecat_Ai_Smart_Turn_V2_Output\n}\ndeclare abstract class Base_Ai_Cf_Openai_Gpt_Oss_120B {\n\tinputs: ResponsesInput\n\tpostProcessedOutputs: ResponsesOutput\n}\ndeclare abstract class Base_Ai_Cf_Openai_Gpt_Oss_20B {\n\tinputs: ResponsesInput\n\tpostProcessedOutputs: ResponsesOutput\n}\ninterface Ai_Cf_Leonardo_Phoenix_1_0_Input {\n\t/**\n\t * A text description of the image you want to generate.\n\t */\n\tprompt: string\n\t/**\n\t * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt\n\t */\n\tguidance?: number\n\t/**\n\t * Random seed for reproducibility of the image generation\n\t */\n\tseed?: number\n\t/**\n\t * The height of the generated image in pixels\n\t */\n\theight?: number\n\t/**\n\t * The width of the generated image in pixels\n\t */\n\twidth?: number\n\t/**\n\t * The number of diffusion steps; higher values can improve quality but take longer\n\t */\n\tnum_steps?: number\n\t/**\n\t * Specify what to exclude from the generated images\n\t */\n\tnegative_prompt?: string\n}\n/**\n * The generated image in JPEG format\n */\ntype Ai_Cf_Leonardo_Phoenix_1_0_Output = string\ndeclare abstract class Base_Ai_Cf_Leonardo_Phoenix_1_0 {\n\tinputs: Ai_Cf_Leonardo_Phoenix_1_0_Input\n\tpostProcessedOutputs: Ai_Cf_Leonardo_Phoenix_1_0_Output\n}\ninterface Ai_Cf_Leonardo_Lucid_Origin_Input {\n\t/**\n\t * A text description of the image you want to generate.\n\t */\n\tprompt: string\n\t/**\n\t * Controls how closely the generated image should adhere to the prompt; higher values make the image more aligned with the prompt\n\t */\n\tguidance?: number\n\t/**\n\t * Random seed for reproducibility of the image generation\n\t */\n\tseed?: number\n\t/**\n\t * The height of the generated image in pixels\n\t */\n\theight?: number\n\t/**\n\t * The width of the generated image in pixels\n\t */\n\twidth?: number\n\t/**\n\t * The number of diffusion steps; higher values can improve quality but take longer\n\t */\n\tnum_steps?: number\n\t/**\n\t * The number of diffusion steps; higher values can improve quality but take longer\n\t */\n\tsteps?: number\n}\ninterface Ai_Cf_Leonardo_Lucid_Origin_Output {\n\t/**\n\t * The generated image in Base64 format.\n\t */\n\timage?: string\n}\ndeclare abstract class Base_Ai_Cf_Leonardo_Lucid_Origin {\n\tinputs: Ai_Cf_Leonardo_Lucid_Origin_Input\n\tpostProcessedOutputs: Ai_Cf_Leonardo_Lucid_Origin_Output\n}\ninterface Ai_Cf_Deepgram_Aura_1_Input {\n\t/**\n\t * Speaker used to produce the audio.\n\t */\n\tspeaker?:\n\t\t| 'angus'\n\t\t| 'asteria'\n\t\t| 'arcas'\n\t\t| 'orion'\n\t\t| 'orpheus'\n\t\t| 'athena'\n\t\t| 'luna'\n\t\t| 'zeus'\n\t\t| 'perseus'\n\t\t| 'helios'\n\t\t| 'hera'\n\t\t| 'stella'\n\t/**\n\t * Encoding of the output audio.\n\t */\n\tencoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'\n\t/**\n\t * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type..\n\t */\n\tcontainer?: 'none' | 'wav' | 'ogg'\n\t/**\n\t * The text content to be converted to speech\n\t */\n\ttext: string\n\t/**\n\t * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable\n\t */\n\tsample_rate?: number\n\t/**\n\t * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type.\n\t */\n\tbit_rate?: number\n}\n/**\n * The generated audio in MP3 format\n */\ntype Ai_Cf_Deepgram_Aura_1_Output = string\ndeclare abstract class Base_Ai_Cf_Deepgram_Aura_1 {\n\tinputs: Ai_Cf_Deepgram_Aura_1_Input\n\tpostProcessedOutputs: Ai_Cf_Deepgram_Aura_1_Output\n}\ninterface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input {\n\t/**\n\t * Input text to translate. Can be a single string or a list of strings.\n\t */\n\ttext: string | string[]\n\t/**\n\t * Target language to translate to\n\t */\n\ttarget_language:\n\t\t| 'asm_Beng'\n\t\t| 'awa_Deva'\n\t\t| 'ben_Beng'\n\t\t| 'bho_Deva'\n\t\t| 'brx_Deva'\n\t\t| 'doi_Deva'\n\t\t| 'eng_Latn'\n\t\t| 'gom_Deva'\n\t\t| 'gon_Deva'\n\t\t| 'guj_Gujr'\n\t\t| 'hin_Deva'\n\t\t| 'hne_Deva'\n\t\t| 'kan_Knda'\n\t\t| 'kas_Arab'\n\t\t| 'kas_Deva'\n\t\t| 'kha_Latn'\n\t\t| 'lus_Latn'\n\t\t| 'mag_Deva'\n\t\t| 'mai_Deva'\n\t\t| 'mal_Mlym'\n\t\t| 'mar_Deva'\n\t\t| 'mni_Beng'\n\t\t| 'mni_Mtei'\n\t\t| 'npi_Deva'\n\t\t| 'ory_Orya'\n\t\t| 'pan_Guru'\n\t\t| 'san_Deva'\n\t\t| 'sat_Olck'\n\t\t| 'snd_Arab'\n\t\t| 'snd_Deva'\n\t\t| 'tam_Taml'\n\t\t| 'tel_Telu'\n\t\t| 'urd_Arab'\n\t\t| 'unr_Deva'\n}\ninterface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output {\n\t/**\n\t * Translated texts\n\t */\n\ttranslations: string[]\n}\ndeclare abstract class Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B {\n\tinputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input\n\tpostProcessedOutputs: Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Output\n}\ntype Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input =\n\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt\n\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages\n\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n\tresponse_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole: string\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_1 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Async_Batch {\n\trequests: (\n\t\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1\n\t\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1\n\t)[]\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Prompt_1 {\n\t/**\n\t * The input text prompt for the model to generate a response.\n\t */\n\tprompt: string\n\t/**\n\t * Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model.\n\t */\n\tlora?: string\n\tresponse_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_2 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Messages_1 {\n\t/**\n\t * An array of message objects representing the conversation history.\n\t */\n\tmessages: {\n\t\t/**\n\t\t * The role of the message sender (e.g., 'user', 'assistant', 'system', 'tool').\n\t\t */\n\t\trole: string\n\t\t/**\n\t\t * The content of the message as a string.\n\t\t */\n\t\tcontent: string\n\t}[]\n\tfunctions?: {\n\t\tname: string\n\t\tcode: string\n\t}[]\n\t/**\n\t * A list of tools available for the assistant to use.\n\t */\n\ttools?: (\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * The name of the tool. More descriptive the better.\n\t\t\t\t */\n\t\t\t\tname: string\n\t\t\t\t/**\n\t\t\t\t * A brief description of what the tool does.\n\t\t\t\t */\n\t\t\t\tdescription: string\n\t\t\t\t/**\n\t\t\t\t * Schema defining the parameters accepted by the tool.\n\t\t\t\t */\n\t\t\t\tparameters: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t */\n\t\t\t\t\ttype: string\n\t\t\t\t\t/**\n\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t */\n\t\t\t\t\trequired?: string[]\n\t\t\t\t\t/**\n\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t */\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Specifies the type of tool (e.g., 'function').\n\t\t\t\t */\n\t\t\t\ttype: string\n\t\t\t\t/**\n\t\t\t\t * Details of the function tool.\n\t\t\t\t */\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * The name of the function.\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * A brief description of what the function does.\n\t\t\t\t\t */\n\t\t\t\t\tdescription: string\n\t\t\t\t\t/**\n\t\t\t\t\t * Schema defining the parameters accepted by the function.\n\t\t\t\t\t */\n\t\t\t\t\tparameters: {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * The type of the parameters object (usually 'object').\n\t\t\t\t\t\t */\n\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * List of required parameter names.\n\t\t\t\t\t\t */\n\t\t\t\t\t\trequired?: string[]\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Definitions of each parameter.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tproperties: {\n\t\t\t\t\t\t\t[k: string]: {\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * The data type of the parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\ttype: string\n\t\t\t\t\t\t\t\t/**\n\t\t\t\t\t\t\t\t * A description of the expected parameter.\n\t\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\t\tdescription: string\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t  }\n\t)[]\n\tresponse_format?: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3\n\t/**\n\t * If true, a chat template is not applied and you must adhere to the specific model's expected formatting.\n\t */\n\traw?: boolean\n\t/**\n\t * If true, the response will be streamed back incrementally using SSE, Server Sent Events.\n\t */\n\tstream?: boolean\n\t/**\n\t * The maximum number of tokens to generate in the response.\n\t */\n\tmax_tokens?: number\n\t/**\n\t * Controls the randomness of the output; higher values produce more random results.\n\t */\n\ttemperature?: number\n\t/**\n\t * Adjusts the creativity of the AI's responses by controlling how many possible words it considers. Lower values make outputs more predictable; higher values allow for more varied and creative responses.\n\t */\n\ttop_p?: number\n\t/**\n\t * Limits the AI to choose from the top 'k' most probable words. Lower values make responses more focused; higher values introduce more variety and potential surprises.\n\t */\n\ttop_k?: number\n\t/**\n\t * Random seed for reproducibility of the generation.\n\t */\n\tseed?: number\n\t/**\n\t * Penalty for repeated tokens; higher values discourage repetition.\n\t */\n\trepetition_penalty?: number\n\t/**\n\t * Decreases the likelihood of the model repeating the same lines verbatim.\n\t */\n\tfrequency_penalty?: number\n\t/**\n\t * Increases the likelihood of the model introducing new topics.\n\t */\n\tpresence_penalty?: number\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_JSON_Mode_3 {\n\ttype?: 'json_object' | 'json_schema'\n\tjson_schema?: unknown\n}\ntype Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output =\n\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response\n\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response\n\t| string\n\t| Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Chat_Completion_Response {\n\t/**\n\t * Unique identifier for the completion\n\t */\n\tid?: string\n\t/**\n\t * Object type identifier\n\t */\n\tobject?: 'chat.completion'\n\t/**\n\t * Unix timestamp of when the completion was created\n\t */\n\tcreated?: number\n\t/**\n\t * Model used for the completion\n\t */\n\tmodel?: string\n\t/**\n\t * List of completion choices\n\t */\n\tchoices?: {\n\t\t/**\n\t\t * Index of the choice in the list\n\t\t */\n\t\tindex?: number\n\t\t/**\n\t\t * The message generated by the model\n\t\t */\n\t\tmessage?: {\n\t\t\t/**\n\t\t\t * Role of the message author\n\t\t\t */\n\t\t\trole: string\n\t\t\t/**\n\t\t\t * The content of the message\n\t\t\t */\n\t\t\tcontent: string\n\t\t\t/**\n\t\t\t * Internal reasoning content (if available)\n\t\t\t */\n\t\t\treasoning_content?: string\n\t\t\t/**\n\t\t\t * Tool calls made by the assistant\n\t\t\t */\n\t\t\ttool_calls?: {\n\t\t\t\t/**\n\t\t\t\t * Unique identifier for the tool call\n\t\t\t\t */\n\t\t\t\tid: string\n\t\t\t\t/**\n\t\t\t\t * Type of tool call\n\t\t\t\t */\n\t\t\t\ttype: 'function'\n\t\t\t\tfunction: {\n\t\t\t\t\t/**\n\t\t\t\t\t * Name of the function to call\n\t\t\t\t\t */\n\t\t\t\t\tname: string\n\t\t\t\t\t/**\n\t\t\t\t\t * JSON string of arguments for the function\n\t\t\t\t\t */\n\t\t\t\t\targuments: string\n\t\t\t\t}\n\t\t\t}[]\n\t\t}\n\t\t/**\n\t\t * Reason why the model stopped generating\n\t\t */\n\t\tfinish_reason?: string\n\t\t/**\n\t\t * Stop reason (may be null)\n\t\t */\n\t\tstop_reason?: string | null\n\t\t/**\n\t\t * Log probabilities (if requested)\n\t\t */\n\t\tlogprobs?: {} | null\n\t}[]\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n\t/**\n\t * Log probabilities for the prompt (if requested)\n\t */\n\tprompt_logprobs?: {} | null\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Text_Completion_Response {\n\t/**\n\t * Unique identifier for the completion\n\t */\n\tid?: string\n\t/**\n\t * Object type identifier\n\t */\n\tobject?: 'text_completion'\n\t/**\n\t * Unix timestamp of when the completion was created\n\t */\n\tcreated?: number\n\t/**\n\t * Model used for the completion\n\t */\n\tmodel?: string\n\t/**\n\t * List of completion choices\n\t */\n\tchoices?: {\n\t\t/**\n\t\t * Index of the choice in the list\n\t\t */\n\t\tindex: number\n\t\t/**\n\t\t * The generated text completion\n\t\t */\n\t\ttext: string\n\t\t/**\n\t\t * Reason why the model stopped generating\n\t\t */\n\t\tfinish_reason: string\n\t\t/**\n\t\t * Stop reason (may be null)\n\t\t */\n\t\tstop_reason?: string | null\n\t\t/**\n\t\t * Log probabilities (if requested)\n\t\t */\n\t\tlogprobs?: {} | null\n\t\t/**\n\t\t * Log probabilities for the prompt (if requested)\n\t\t */\n\t\tprompt_logprobs?: {} | null\n\t}[]\n\t/**\n\t * Usage statistics for the inference request\n\t */\n\tusage?: {\n\t\t/**\n\t\t * Total number of tokens in input\n\t\t */\n\t\tprompt_tokens?: number\n\t\t/**\n\t\t * Total number of tokens in output\n\t\t */\n\t\tcompletion_tokens?: number\n\t\t/**\n\t\t * Total number of input and output tokens\n\t\t */\n\t\ttotal_tokens?: number\n\t}\n}\ninterface Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_AsyncResponse {\n\t/**\n\t * The async request id that can be used to obtain the results.\n\t */\n\trequest_id?: string\n}\ndeclare abstract class Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It {\n\tinputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Input\n\tpostProcessedOutputs: Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It_Output\n}\ninterface Ai_Cf_Pfnet_Plamo_Embedding_1B_Input {\n\t/**\n\t * Input text to embed. Can be a single string or a list of strings.\n\t */\n\ttext: string | string[]\n}\ninterface Ai_Cf_Pfnet_Plamo_Embedding_1B_Output {\n\t/**\n\t * Embedding vectors, where each vector is a list of floats.\n\t */\n\tdata: number[][]\n\t/**\n\t * Shape of the embedding data as [number_of_embeddings, embedding_dimension].\n\t *\n\t * @minItems 2\n\t * @maxItems 2\n\t */\n\tshape: [number, number]\n}\ndeclare abstract class Base_Ai_Cf_Pfnet_Plamo_Embedding_1B {\n\tinputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Input\n\tpostProcessedOutputs: Ai_Cf_Pfnet_Plamo_Embedding_1B_Output\n}\ninterface Ai_Cf_Deepgram_Flux_Input {\n\t/**\n\t * Encoding of the audio stream. Currently only supports raw signed little-endian 16-bit PCM.\n\t */\n\tencoding: 'linear16'\n\t/**\n\t * Sample rate of the audio stream in Hz.\n\t */\n\tsample_rate: string\n\t/**\n\t * End-of-turn confidence required to fire an eager end-of-turn event. When set, enables EagerEndOfTurn and TurnResumed events. Valid Values 0.3 - 0.9.\n\t */\n\teager_eot_threshold?: string\n\t/**\n\t * End-of-turn confidence required to finish a turn. Valid Values 0.5 - 0.9.\n\t */\n\teot_threshold?: string\n\t/**\n\t * A turn will be finished when this much time has passed after speech, regardless of EOT confidence.\n\t */\n\teot_timeout_ms?: string\n\t/**\n\t * Keyterm prompting can improve recognition of specialized terminology. Pass multiple keyterm query parameters to boost multiple keyterms.\n\t */\n\tkeyterm?: string\n\t/**\n\t * Opts out requests from the Deepgram Model Improvement Program. Refer to Deepgram Docs for pricing impacts before setting this to true. https://dpgr.am/deepgram-mip\n\t */\n\tmip_opt_out?: 'true' | 'false'\n\t/**\n\t * Label your requests for the purpose of identification during usage reporting\n\t */\n\ttag?: string\n}\n/**\n * Output will be returned as websocket messages.\n */\ninterface Ai_Cf_Deepgram_Flux_Output {\n\t/**\n\t * The unique identifier of the request (uuid)\n\t */\n\trequest_id?: string\n\t/**\n\t * Starts at 0 and increments for each message the server sends to the client.\n\t */\n\tsequence_id?: number\n\t/**\n\t * The type of event being reported.\n\t */\n\tevent?:\n\t\t| 'Update'\n\t\t| 'StartOfTurn'\n\t\t| 'EagerEndOfTurn'\n\t\t| 'TurnResumed'\n\t\t| 'EndOfTurn'\n\t/**\n\t * The index of the current turn\n\t */\n\tturn_index?: number\n\t/**\n\t * Start time in seconds of the audio range that was transcribed\n\t */\n\taudio_window_start?: number\n\t/**\n\t * End time in seconds of the audio range that was transcribed\n\t */\n\taudio_window_end?: number\n\t/**\n\t * Text that was said over the course of the current turn\n\t */\n\ttranscript?: string\n\t/**\n\t * The words in the transcript\n\t */\n\twords?: {\n\t\t/**\n\t\t * The individual punctuated, properly-cased word from the transcript\n\t\t */\n\t\tword: string\n\t\t/**\n\t\t * Confidence that this word was transcribed correctly\n\t\t */\n\t\tconfidence: number\n\t}[]\n\t/**\n\t * Confidence that no more speech is coming in this turn\n\t */\n\tend_of_turn_confidence?: number\n}\ndeclare abstract class Base_Ai_Cf_Deepgram_Flux {\n\tinputs: Ai_Cf_Deepgram_Flux_Input\n\tpostProcessedOutputs: Ai_Cf_Deepgram_Flux_Output\n}\ninterface Ai_Cf_Deepgram_Aura_2_En_Input {\n\t/**\n\t * Speaker used to produce the audio.\n\t */\n\tspeaker?:\n\t\t| 'amalthea'\n\t\t| 'andromeda'\n\t\t| 'apollo'\n\t\t| 'arcas'\n\t\t| 'aries'\n\t\t| 'asteria'\n\t\t| 'athena'\n\t\t| 'atlas'\n\t\t| 'aurora'\n\t\t| 'callista'\n\t\t| 'cora'\n\t\t| 'cordelia'\n\t\t| 'delia'\n\t\t| 'draco'\n\t\t| 'electra'\n\t\t| 'harmonia'\n\t\t| 'helena'\n\t\t| 'hera'\n\t\t| 'hermes'\n\t\t| 'hyperion'\n\t\t| 'iris'\n\t\t| 'janus'\n\t\t| 'juno'\n\t\t| 'jupiter'\n\t\t| 'luna'\n\t\t| 'mars'\n\t\t| 'minerva'\n\t\t| 'neptune'\n\t\t| 'odysseus'\n\t\t| 'ophelia'\n\t\t| 'orion'\n\t\t| 'orpheus'\n\t\t| 'pandora'\n\t\t| 'phoebe'\n\t\t| 'pluto'\n\t\t| 'saturn'\n\t\t| 'thalia'\n\t\t| 'theia'\n\t\t| 'vesta'\n\t\t| 'zeus'\n\t/**\n\t * Encoding of the output audio.\n\t */\n\tencoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'\n\t/**\n\t * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type..\n\t */\n\tcontainer?: 'none' | 'wav' | 'ogg'\n\t/**\n\t * The text content to be converted to speech\n\t */\n\ttext: string\n\t/**\n\t * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable\n\t */\n\tsample_rate?: number\n\t/**\n\t * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type.\n\t */\n\tbit_rate?: number\n}\n/**\n * The generated audio in MP3 format\n */\ntype Ai_Cf_Deepgram_Aura_2_En_Output = string\ndeclare abstract class Base_Ai_Cf_Deepgram_Aura_2_En {\n\tinputs: Ai_Cf_Deepgram_Aura_2_En_Input\n\tpostProcessedOutputs: Ai_Cf_Deepgram_Aura_2_En_Output\n}\ninterface Ai_Cf_Deepgram_Aura_2_Es_Input {\n\t/**\n\t * Speaker used to produce the audio.\n\t */\n\tspeaker?:\n\t\t| 'sirio'\n\t\t| 'nestor'\n\t\t| 'carina'\n\t\t| 'celeste'\n\t\t| 'alvaro'\n\t\t| 'diana'\n\t\t| 'aquila'\n\t\t| 'selena'\n\t\t| 'estrella'\n\t\t| 'javier'\n\t/**\n\t * Encoding of the output audio.\n\t */\n\tencoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'\n\t/**\n\t * Container specifies the file format wrapper for the output audio. The available options depend on the encoding type..\n\t */\n\tcontainer?: 'none' | 'wav' | 'ogg'\n\t/**\n\t * The text content to be converted to speech\n\t */\n\ttext: string\n\t/**\n\t * Sample Rate specifies the sample rate for the output audio. Based on the encoding, different sample rates are supported. For some encodings, the sample rate is not configurable\n\t */\n\tsample_rate?: number\n\t/**\n\t * The bitrate of the audio in bits per second. Choose from predefined ranges or specific values based on the encoding type.\n\t */\n\tbit_rate?: number\n}\n/**\n * The generated audio in MP3 format\n */\ntype Ai_Cf_Deepgram_Aura_2_Es_Output = string\ndeclare abstract class Base_Ai_Cf_Deepgram_Aura_2_Es {\n\tinputs: Ai_Cf_Deepgram_Aura_2_Es_Input\n\tpostProcessedOutputs: Ai_Cf_Deepgram_Aura_2_Es_Output\n}\ninterface AiModels {\n\t'@cf/huggingface/distilbert-sst-2-int8': BaseAiTextClassification\n\t'@cf/stabilityai/stable-diffusion-xl-base-1.0': BaseAiTextToImage\n\t'@cf/runwayml/stable-diffusion-v1-5-inpainting': BaseAiTextToImage\n\t'@cf/runwayml/stable-diffusion-v1-5-img2img': BaseAiTextToImage\n\t'@cf/lykon/dreamshaper-8-lcm': BaseAiTextToImage\n\t'@cf/bytedance/stable-diffusion-xl-lightning': BaseAiTextToImage\n\t'@cf/myshell-ai/melotts': BaseAiTextToSpeech\n\t'@cf/google/embeddinggemma-300m': BaseAiTextEmbeddings\n\t'@cf/microsoft/resnet-50': BaseAiImageClassification\n\t'@cf/meta/llama-2-7b-chat-int8': BaseAiTextGeneration\n\t'@cf/mistral/mistral-7b-instruct-v0.1': BaseAiTextGeneration\n\t'@cf/meta/llama-2-7b-chat-fp16': BaseAiTextGeneration\n\t'@hf/thebloke/llama-2-13b-chat-awq': BaseAiTextGeneration\n\t'@hf/thebloke/mistral-7b-instruct-v0.1-awq': BaseAiTextGeneration\n\t'@hf/thebloke/zephyr-7b-beta-awq': BaseAiTextGeneration\n\t'@hf/thebloke/openhermes-2.5-mistral-7b-awq': BaseAiTextGeneration\n\t'@hf/thebloke/neural-chat-7b-v3-1-awq': BaseAiTextGeneration\n\t'@hf/thebloke/llamaguard-7b-awq': BaseAiTextGeneration\n\t'@hf/thebloke/deepseek-coder-6.7b-base-awq': BaseAiTextGeneration\n\t'@hf/thebloke/deepseek-coder-6.7b-instruct-awq': BaseAiTextGeneration\n\t'@cf/deepseek-ai/deepseek-math-7b-instruct': BaseAiTextGeneration\n\t'@cf/defog/sqlcoder-7b-2': BaseAiTextGeneration\n\t'@cf/openchat/openchat-3.5-0106': BaseAiTextGeneration\n\t'@cf/tiiuae/falcon-7b-instruct': BaseAiTextGeneration\n\t'@cf/thebloke/discolm-german-7b-v1-awq': BaseAiTextGeneration\n\t'@cf/qwen/qwen1.5-0.5b-chat': BaseAiTextGeneration\n\t'@cf/qwen/qwen1.5-7b-chat-awq': BaseAiTextGeneration\n\t'@cf/qwen/qwen1.5-14b-chat-awq': BaseAiTextGeneration\n\t'@cf/tinyllama/tinyllama-1.1b-chat-v1.0': BaseAiTextGeneration\n\t'@cf/microsoft/phi-2': BaseAiTextGeneration\n\t'@cf/qwen/qwen1.5-1.8b-chat': BaseAiTextGeneration\n\t'@cf/mistral/mistral-7b-instruct-v0.2-lora': BaseAiTextGeneration\n\t'@hf/nousresearch/hermes-2-pro-mistral-7b': BaseAiTextGeneration\n\t'@hf/nexusflow/starling-lm-7b-beta': BaseAiTextGeneration\n\t'@hf/google/gemma-7b-it': BaseAiTextGeneration\n\t'@cf/meta-llama/llama-2-7b-chat-hf-lora': BaseAiTextGeneration\n\t'@cf/google/gemma-2b-it-lora': BaseAiTextGeneration\n\t'@cf/google/gemma-7b-it-lora': BaseAiTextGeneration\n\t'@hf/mistral/mistral-7b-instruct-v0.2': BaseAiTextGeneration\n\t'@cf/meta/llama-3-8b-instruct': BaseAiTextGeneration\n\t'@cf/fblgit/una-cybertron-7b-v2-bf16': BaseAiTextGeneration\n\t'@cf/meta/llama-3-8b-instruct-awq': BaseAiTextGeneration\n\t'@cf/meta/llama-3.1-8b-instruct-fp8': BaseAiTextGeneration\n\t'@cf/meta/llama-3.1-8b-instruct-awq': BaseAiTextGeneration\n\t'@cf/meta/llama-3.2-3b-instruct': BaseAiTextGeneration\n\t'@cf/meta/llama-3.2-1b-instruct': BaseAiTextGeneration\n\t'@cf/deepseek-ai/deepseek-r1-distill-qwen-32b': BaseAiTextGeneration\n\t'@cf/ibm-granite/granite-4.0-h-micro': BaseAiTextGeneration\n\t'@cf/facebook/bart-large-cnn': BaseAiSummarization\n\t'@cf/llava-hf/llava-1.5-7b-hf': BaseAiImageToText\n\t'@cf/baai/bge-base-en-v1.5': Base_Ai_Cf_Baai_Bge_Base_En_V1_5\n\t'@cf/openai/whisper': Base_Ai_Cf_Openai_Whisper\n\t'@cf/meta/m2m100-1.2b': Base_Ai_Cf_Meta_M2M100_1_2B\n\t'@cf/baai/bge-small-en-v1.5': Base_Ai_Cf_Baai_Bge_Small_En_V1_5\n\t'@cf/baai/bge-large-en-v1.5': Base_Ai_Cf_Baai_Bge_Large_En_V1_5\n\t'@cf/unum/uform-gen2-qwen-500m': Base_Ai_Cf_Unum_Uform_Gen2_Qwen_500M\n\t'@cf/openai/whisper-tiny-en': Base_Ai_Cf_Openai_Whisper_Tiny_En\n\t'@cf/openai/whisper-large-v3-turbo': Base_Ai_Cf_Openai_Whisper_Large_V3_Turbo\n\t'@cf/baai/bge-m3': Base_Ai_Cf_Baai_Bge_M3\n\t'@cf/black-forest-labs/flux-1-schnell': Base_Ai_Cf_Black_Forest_Labs_Flux_1_Schnell\n\t'@cf/meta/llama-3.2-11b-vision-instruct': Base_Ai_Cf_Meta_Llama_3_2_11B_Vision_Instruct\n\t'@cf/meta/llama-3.3-70b-instruct-fp8-fast': Base_Ai_Cf_Meta_Llama_3_3_70B_Instruct_Fp8_Fast\n\t'@cf/meta/llama-guard-3-8b': Base_Ai_Cf_Meta_Llama_Guard_3_8B\n\t'@cf/baai/bge-reranker-base': Base_Ai_Cf_Baai_Bge_Reranker_Base\n\t'@cf/qwen/qwen2.5-coder-32b-instruct': Base_Ai_Cf_Qwen_Qwen2_5_Coder_32B_Instruct\n\t'@cf/qwen/qwq-32b': Base_Ai_Cf_Qwen_Qwq_32B\n\t'@cf/mistralai/mistral-small-3.1-24b-instruct': Base_Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct\n\t'@cf/google/gemma-3-12b-it': Base_Ai_Cf_Google_Gemma_3_12B_It\n\t'@cf/meta/llama-4-scout-17b-16e-instruct': Base_Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct\n\t'@cf/qwen/qwen3-30b-a3b-fp8': Base_Ai_Cf_Qwen_Qwen3_30B_A3B_Fp8\n\t'@cf/deepgram/nova-3': Base_Ai_Cf_Deepgram_Nova_3\n\t'@cf/qwen/qwen3-embedding-0.6b': Base_Ai_Cf_Qwen_Qwen3_Embedding_0_6B\n\t'@cf/pipecat-ai/smart-turn-v2': Base_Ai_Cf_Pipecat_Ai_Smart_Turn_V2\n\t'@cf/openai/gpt-oss-120b': Base_Ai_Cf_Openai_Gpt_Oss_120B\n\t'@cf/openai/gpt-oss-20b': Base_Ai_Cf_Openai_Gpt_Oss_20B\n\t'@cf/leonardo/phoenix-1.0': Base_Ai_Cf_Leonardo_Phoenix_1_0\n\t'@cf/leonardo/lucid-origin': Base_Ai_Cf_Leonardo_Lucid_Origin\n\t'@cf/deepgram/aura-1': Base_Ai_Cf_Deepgram_Aura_1\n\t'@cf/ai4bharat/indictrans2-en-indic-1B': Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B\n\t'@cf/aisingapore/gemma-sea-lion-v4-27b-it': Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It\n\t'@cf/pfnet/plamo-embedding-1b': Base_Ai_Cf_Pfnet_Plamo_Embedding_1B\n\t'@cf/deepgram/flux': Base_Ai_Cf_Deepgram_Flux\n\t'@cf/deepgram/aura-2-en': Base_Ai_Cf_Deepgram_Aura_2_En\n\t'@cf/deepgram/aura-2-es': Base_Ai_Cf_Deepgram_Aura_2_Es\n}\ntype AiOptions = {\n\t/**\n\t * Send requests as an asynchronous batch job, only works for supported models\n\t * https://developers.cloudflare.com/workers-ai/features/batch-api\n\t */\n\tqueueRequest?: boolean\n\t/**\n\t * Establish websocket connections, only works for supported models\n\t */\n\twebsocket?: boolean\n\t/**\n\t * Tag your requests to group and view them in Cloudflare dashboard.\n\t *\n\t * Rules:\n\t * Tags must only contain letters, numbers, and the symbols: : - . / @\n\t * Each tag can have maximum 50 characters.\n\t * Maximum 5 tags are allowed each request.\n\t * Duplicate tags will removed.\n\t */\n\ttags?: string[]\n\tgateway?: GatewayOptions\n\treturnRawResponse?: boolean\n\tprefix?: string\n\textraHeaders?: object\n}\ntype AiModelsSearchParams = {\n\tauthor?: string\n\thide_experimental?: boolean\n\tpage?: number\n\tper_page?: number\n\tsearch?: string\n\tsource?: number\n\ttask?: string\n}\ntype AiModelsSearchObject = {\n\tid: string\n\tsource: number\n\tname: string\n\tdescription: string\n\ttask: {\n\t\tid: string\n\t\tname: string\n\t\tdescription: string\n\t}\n\ttags: string[]\n\tproperties: {\n\t\tproperty_id: string\n\t\tvalue: string\n\t}[]\n}\ninterface InferenceUpstreamError extends Error {}\ninterface AiInternalError extends Error {}\ntype AiModelListType = Record<string, any>\ndeclare abstract class Ai<AiModelList extends AiModelListType = AiModels> {\n\taiGatewayLogId: string | null\n\tgateway(gatewayId: string): AiGateway\n\t/**\n\t * Access the AI Search API for managing AI-powered search instances.\n\t *\n\t * This is the new API that replaces AutoRAG with better namespace separation:\n\t * - Account-level operations: `list()`, `create()`\n\t * - Instance-level operations: `get(id).search()`, `get(id).chatCompletions()`, `get(id).delete()`\n\t *\n\t * @example\n\t * ```typescript\n\t * // List all AI Search instances\n\t * const instances = await env.AI.aiSearch.list();\n\t *\n\t * // Search an instance\n\t * const results = await env.AI.aiSearch.get('my-search').search({\n\t *   messages: [{ role: 'user', content: 'What is the policy?' }],\n\t *   ai_search_options: {\n\t *     retrieval: { max_num_results: 10 }\n\t *   }\n\t * });\n\t *\n\t * // Generate chat completions with AI Search context\n\t * const response = await env.AI.aiSearch.get('my-search').chatCompletions({\n\t *   messages: [{ role: 'user', content: 'What is the policy?' }],\n\t *   model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast'\n\t * });\n\t * ```\n\t */\n\taiSearch: AiSearchAccountService\n\t/**\n\t * @deprecated AutoRAG has been replaced by AI Search.\n\t * Use `env.AI.aiSearch` instead for better API design and new features.\n\t *\n\t * Migration guide:\n\t * - `env.AI.autorag().list()` → `env.AI.aiSearch.list()`\n\t * - `env.AI.autorag('id').search({ query: '...' })` → `env.AI.aiSearch.get('id').search({ messages: [{ role: 'user', content: '...' }] })`\n\t * - `env.AI.autorag('id').aiSearch(...)` → `env.AI.aiSearch.get('id').chatCompletions(...)`\n\t *\n\t * Note: The old API continues to work for backwards compatibility, but new projects should use AI Search.\n\t *\n\t * @see AiSearchAccountService\n\t * @param autoragId Optional instance ID (omit for account-level operations)\n\t */\n\tautorag(autoragId: string): AutoRAG\n\trun<\n\t\tName extends keyof AiModelList,\n\t\tOptions extends AiOptions,\n\t\tInputOptions extends AiModelList[Name]['inputs'],\n\t>(\n\t\tmodel: Name,\n\t\tinputs: InputOptions,\n\t\toptions?: Options,\n\t): Promise<\n\t\tOptions extends\n\t\t\t| {\n\t\t\t\t\treturnRawResponse: true\n\t\t\t  }\n\t\t\t| {\n\t\t\t\t\twebsocket: true\n\t\t\t  }\n\t\t\t? Response\n\t\t\t: InputOptions extends {\n\t\t\t\t\t\tstream: true\n\t\t\t\t  }\n\t\t\t\t? ReadableStream\n\t\t\t\t: AiModelList[Name]['postProcessedOutputs']\n\t>\n\tmodels(params?: AiModelsSearchParams): Promise<AiModelsSearchObject[]>\n\ttoMarkdown(): ToMarkdownService\n\ttoMarkdown(\n\t\tfiles: MarkdownDocument[],\n\t\toptions?: ConversionRequestOptions,\n\t): Promise<ConversionResponse[]>\n\ttoMarkdown(\n\t\tfiles: MarkdownDocument,\n\t\toptions?: ConversionRequestOptions,\n\t): Promise<ConversionResponse>\n}\ntype GatewayRetries = {\n\tmaxAttempts?: 1 | 2 | 3 | 4 | 5\n\tretryDelayMs?: number\n\tbackoff?: 'constant' | 'linear' | 'exponential'\n}\ntype GatewayOptions = {\n\tid: string\n\tcacheKey?: string\n\tcacheTtl?: number\n\tskipCache?: boolean\n\tmetadata?: Record<string, number | string | boolean | null | bigint>\n\tcollectLog?: boolean\n\teventId?: string\n\trequestTimeoutMs?: number\n\tretries?: GatewayRetries\n}\ntype UniversalGatewayOptions = Exclude<GatewayOptions, 'id'> & {\n\t/**\n\t ** @deprecated\n\t */\n\tid?: string\n}\ntype AiGatewayPatchLog = {\n\tscore?: number | null\n\tfeedback?: -1 | 1 | null\n\tmetadata?: Record<string, number | string | boolean | null | bigint> | null\n}\ntype AiGatewayLog = {\n\tid: string\n\tprovider: string\n\tmodel: string\n\tmodel_type?: string\n\tpath: string\n\tduration: number\n\trequest_type?: string\n\trequest_content_type?: string\n\tstatus_code: number\n\tresponse_content_type?: string\n\tsuccess: boolean\n\tcached: boolean\n\ttokens_in?: number\n\ttokens_out?: number\n\tmetadata?: Record<string, number | string | boolean | null | bigint>\n\tstep?: number\n\tcost?: number\n\tcustom_cost?: boolean\n\trequest_size: number\n\trequest_head?: string\n\trequest_head_complete: boolean\n\tresponse_size: number\n\tresponse_head?: string\n\tresponse_head_complete: boolean\n\tcreated_at: Date\n}\ntype AIGatewayProviders =\n\t| 'workers-ai'\n\t| 'anthropic'\n\t| 'aws-bedrock'\n\t| 'azure-openai'\n\t| 'google-vertex-ai'\n\t| 'huggingface'\n\t| 'openai'\n\t| 'perplexity-ai'\n\t| 'replicate'\n\t| 'groq'\n\t| 'cohere'\n\t| 'google-ai-studio'\n\t| 'mistral'\n\t| 'grok'\n\t| 'openrouter'\n\t| 'deepseek'\n\t| 'cerebras'\n\t| 'cartesia'\n\t| 'elevenlabs'\n\t| 'adobe-firefly'\ntype AIGatewayHeaders = {\n\t'cf-aig-metadata':\n\t\t| Record<string, number | string | boolean | null | bigint>\n\t\t| string\n\t'cf-aig-custom-cost':\n\t\t| {\n\t\t\t\tper_token_in?: number\n\t\t\t\tper_token_out?: number\n\t\t  }\n\t\t| {\n\t\t\t\ttotal_cost?: number\n\t\t  }\n\t\t| string\n\t'cf-aig-cache-ttl': number | string\n\t'cf-aig-skip-cache': boolean | string\n\t'cf-aig-cache-key': string\n\t'cf-aig-event-id': string\n\t'cf-aig-request-timeout': number | string\n\t'cf-aig-max-attempts': number | string\n\t'cf-aig-retry-delay': number | string\n\t'cf-aig-backoff': string\n\t'cf-aig-collect-log': boolean | string\n\tAuthorization: string\n\t'Content-Type': string\n\t[key: string]: string | number | boolean | object\n}\ntype AIGatewayUniversalRequest = {\n\tprovider: AIGatewayProviders | string // eslint-disable-line\n\tendpoint: string\n\theaders: Partial<AIGatewayHeaders>\n\tquery: unknown\n}\ninterface AiGatewayInternalError extends Error {}\ninterface AiGatewayLogNotFound extends Error {}\ndeclare abstract class AiGateway {\n\tpatchLog(logId: string, data: AiGatewayPatchLog): Promise<void>\n\tgetLog(logId: string): Promise<AiGatewayLog>\n\trun(\n\t\tdata: AIGatewayUniversalRequest | AIGatewayUniversalRequest[],\n\t\toptions?: {\n\t\t\tgateway?: UniversalGatewayOptions\n\t\t\textraHeaders?: object\n\t\t},\n\t): Promise<Response>\n\tgetUrl(provider?: AIGatewayProviders | string): Promise<string> // eslint-disable-line\n}\n/**\n * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchInternalError instead.\n * @see AiSearchInternalError\n */\ninterface AutoRAGInternalError extends Error {}\n/**\n * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchNotFoundError instead.\n * @see AiSearchNotFoundError\n */\ninterface AutoRAGNotFoundError extends Error {}\n/**\n * @deprecated This error type is no longer used in the AI Search API.\n */\ninterface AutoRAGUnauthorizedError extends Error {}\n/**\n * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchNameNotSetError instead.\n * @see AiSearchNameNotSetError\n */\ninterface AutoRAGNameNotSetError extends Error {}\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * Use AiSearchSearchRequest with the new API instead.\n * @see AiSearchSearchRequest\n */\ntype AutoRagSearchRequest = {\n\tquery: string\n\tfilters?: CompoundFilter | ComparisonFilter\n\tmax_num_results?: number\n\tranking_options?: {\n\t\tranker?: string\n\t\tscore_threshold?: number\n\t}\n\treranking?: {\n\t\tenabled?: boolean\n\t\tmodel?: string\n\t}\n\trewrite_query?: boolean\n}\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * Use AiSearchChatCompletionsRequest with the new API instead.\n * @see AiSearchChatCompletionsRequest\n */\ntype AutoRagAiSearchRequest = AutoRagSearchRequest & {\n\tstream?: boolean\n\tsystem_prompt?: string\n}\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * Use AiSearchChatCompletionsRequest with stream: true instead.\n * @see AiSearchChatCompletionsRequest\n */\ntype AutoRagAiSearchRequestStreaming = Omit<\n\tAutoRagAiSearchRequest,\n\t'stream'\n> & {\n\tstream: true\n}\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * Use AiSearchSearchResponse with the new API instead.\n * @see AiSearchSearchResponse\n */\ntype AutoRagSearchResponse = {\n\tobject: 'vector_store.search_results.page'\n\tsearch_query: string\n\tdata: {\n\t\tfile_id: string\n\t\tfilename: string\n\t\tscore: number\n\t\tattributes: Record<string, string | number | boolean | null>\n\t\tcontent: {\n\t\t\ttype: 'text'\n\t\t\ttext: string\n\t\t}[]\n\t}[]\n\thas_more: boolean\n\tnext_page: string | null\n}\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * Use AiSearchListResponse with the new API instead.\n * @see AiSearchListResponse\n */\ntype AutoRagListResponse = {\n\tid: string\n\tenable: boolean\n\ttype: string\n\tsource: string\n\tvectorize_name: string\n\tpaused: boolean\n\tstatus: string\n}[]\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * The new API returns different response formats for chat completions.\n */\ntype AutoRagAiSearchResponse = AutoRagSearchResponse & {\n\tresponse: string\n}\n/**\n * @deprecated AutoRAG has been replaced by AI Search.\n * Use the new AI Search API instead: `env.AI.aiSearch`\n *\n * Migration guide:\n * - `env.AI.autorag().list()` → `env.AI.aiSearch.list()`\n * - `env.AI.autorag('id').search(...)` → `env.AI.aiSearch.get('id').search(...)`\n * - `env.AI.autorag('id').aiSearch(...)` → `env.AI.aiSearch.get('id').chatCompletions(...)`\n *\n * @see AiSearchAccountService\n * @see AiSearchInstanceService\n */\ndeclare abstract class AutoRAG {\n\t/**\n\t * @deprecated Use `env.AI.aiSearch.list()` instead.\n\t * @see AiSearchAccountService.list\n\t */\n\tlist(): Promise<AutoRagListResponse>\n\t/**\n\t * @deprecated Use `env.AI.aiSearch.get(id).search(...)` instead.\n\t * Note: The new API uses a messages array instead of a query string.\n\t * @see AiSearchInstanceService.search\n\t */\n\tsearch(params: AutoRagSearchRequest): Promise<AutoRagSearchResponse>\n\t/**\n\t * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead.\n\t * @see AiSearchInstanceService.chatCompletions\n\t */\n\taiSearch(params: AutoRagAiSearchRequestStreaming): Promise<Response>\n\t/**\n\t * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead.\n\t * @see AiSearchInstanceService.chatCompletions\n\t */\n\taiSearch(params: AutoRagAiSearchRequest): Promise<AutoRagAiSearchResponse>\n\t/**\n\t * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead.\n\t * @see AiSearchInstanceService.chatCompletions\n\t */\n\taiSearch(\n\t\tparams: AutoRagAiSearchRequest,\n\t): Promise<AutoRagAiSearchResponse | Response>\n}\ninterface BasicImageTransformations {\n\t/**\n\t * Maximum width in image pixels. The value must be an integer.\n\t */\n\twidth?: number\n\t/**\n\t * Maximum height in image pixels. The value must be an integer.\n\t */\n\theight?: number\n\t/**\n\t * Resizing mode as a string. It affects interpretation of width and height\n\t * options:\n\t *  - scale-down: Similar to contain, but the image is never enlarged. If\n\t *    the image is larger than given width or height, it will be resized.\n\t *    Otherwise its original size will be kept.\n\t *  - contain: Resizes to maximum size that fits within the given width and\n\t *    height. If only a single dimension is given (e.g. only width), the\n\t *    image will be shrunk or enlarged to exactly match that dimension.\n\t *    Aspect ratio is always preserved.\n\t *  - cover: Resizes (shrinks or enlarges) to fill the entire area of width\n\t *    and height. If the image has an aspect ratio different from the ratio\n\t *    of width and height, it will be cropped to fit.\n\t *  - crop: The image will be shrunk and cropped to fit within the area\n\t *    specified by width and height. The image will not be enlarged. For images\n\t *    smaller than the given dimensions it's the same as scale-down. For\n\t *    images larger than the given dimensions, it's the same as cover.\n\t *    See also trim.\n\t *  - pad: Resizes to the maximum size that fits within the given width and\n\t *    height, and then fills the remaining area with a background color\n\t *    (white by default). Use of this mode is not recommended, as the same\n\t *    effect can be more efficiently achieved with the contain mode and the\n\t *    CSS object-fit: contain property.\n\t *  - squeeze: Stretches and deforms to the width and height given, even if it\n\t *    breaks aspect ratio\n\t */\n\tfit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad' | 'squeeze'\n\t/**\n\t * Image segmentation using artificial intelligence models. Sets pixels not\n\t * within selected segment area to transparent e.g \"foreground\" sets every\n\t * background pixel as transparent.\n\t */\n\tsegment?: 'foreground'\n\t/**\n\t * When cropping with fit: \"cover\", this defines the side or point that should\n\t * be left uncropped. The value is either a string\n\t * \"left\", \"right\", \"top\", \"bottom\", \"auto\", or \"center\" (the default),\n\t * or an object {x, y} containing focal point coordinates in the original\n\t * image expressed as fractions ranging from 0.0 (top or left) to 1.0\n\t * (bottom or right), 0.5 being the center. {fit: \"cover\", gravity: \"top\"} will\n\t * crop bottom or left and right sides as necessary, but won’t crop anything\n\t * from the top. {fit: \"cover\", gravity: {x:0.5, y:0.2}} will crop each side to\n\t * preserve as much as possible around a point at 20% of the height of the\n\t * source image.\n\t */\n\tgravity?:\n\t\t| 'face'\n\t\t| 'left'\n\t\t| 'right'\n\t\t| 'top'\n\t\t| 'bottom'\n\t\t| 'center'\n\t\t| 'auto'\n\t\t| 'entropy'\n\t\t| BasicImageTransformationsGravityCoordinates\n\t/**\n\t * Background color to add underneath the image. Applies only to images with\n\t * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…),\n\t * hsl(…), etc.)\n\t */\n\tbackground?: string\n\t/**\n\t * Number of degrees (90, 180, 270) to rotate the image by. width and height\n\t * options refer to axes after rotation.\n\t */\n\trotate?: 0 | 90 | 180 | 270 | 360\n}\ninterface BasicImageTransformationsGravityCoordinates {\n\tx?: number\n\ty?: number\n\tmode?: 'remainder' | 'box-center'\n}\n/**\n * In addition to the properties you can set in the RequestInit dict\n * that you pass as an argument to the Request constructor, you can\n * set certain properties of a `cf` object to control how Cloudflare\n * features are applied to that new Request.\n *\n * Note: Currently, these properties cannot be tested in the\n * playground.\n */\ninterface RequestInitCfProperties extends Record<string, unknown> {\n\tcacheEverything?: boolean\n\t/**\n\t * A request's cache key is what determines if two requests are\n\t * \"the same\" for caching purposes. If a request has the same cache key\n\t * as some previous request, then we can serve the same cached response for\n\t * both. (e.g. 'some-key')\n\t *\n\t * Only available for Enterprise customers.\n\t */\n\tcacheKey?: string\n\t/**\n\t * This allows you to append additional Cache-Tag response headers\n\t * to the origin response without modifications to the origin server.\n\t * This will allow for greater control over the Purge by Cache Tag feature\n\t * utilizing changes only in the Workers process.\n\t *\n\t * Only available for Enterprise customers.\n\t */\n\tcacheTags?: string[]\n\t/**\n\t * Force response to be cached for a given number of seconds. (e.g. 300)\n\t */\n\tcacheTtl?: number\n\t/**\n\t * Force response to be cached for a given number of seconds based on the Origin status code.\n\t * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 })\n\t */\n\tcacheTtlByStatus?: Record<string, number>\n\tscrapeShield?: boolean\n\tapps?: boolean\n\timage?: RequestInitCfPropertiesImage\n\tminify?: RequestInitCfPropertiesImageMinify\n\tmirage?: boolean\n\tpolish?: 'lossy' | 'lossless' | 'off'\n\tr2?: RequestInitCfPropertiesR2\n\t/**\n\t * Redirects the request to an alternate origin server. You can use this,\n\t * for example, to implement load balancing across several origins.\n\t * (e.g.us-east.example.com)\n\t *\n\t * Note - For security reasons, the hostname set in resolveOverride must\n\t * be proxied on the same Cloudflare zone of the incoming request.\n\t * Otherwise, the setting is ignored. CNAME hosts are allowed, so to\n\t * resolve to a host under a different domain or a DNS only domain first\n\t * declare a CNAME record within your own zone’s DNS mapping to the\n\t * external hostname, set proxy on Cloudflare, then set resolveOverride\n\t * to point to that CNAME record.\n\t */\n\tresolveOverride?: string\n}\ninterface RequestInitCfPropertiesImageDraw extends BasicImageTransformations {\n\t/**\n\t * Absolute URL of the image file to use for the drawing. It can be any of\n\t * the supported file formats. For drawing of watermarks or non-rectangular\n\t * overlays we recommend using PNG or WebP images.\n\t */\n\turl: string\n\t/**\n\t * Floating-point number between 0 (transparent) and 1 (opaque).\n\t * For example, opacity: 0.5 makes overlay semitransparent.\n\t */\n\topacity?: number\n\t/**\n\t * - If set to true, the overlay image will be tiled to cover the entire\n\t *   area. This is useful for stock-photo-like watermarks.\n\t * - If set to \"x\", the overlay image will be tiled horizontally only\n\t *   (form a line).\n\t * - If set to \"y\", the overlay image will be tiled vertically only\n\t *   (form a line).\n\t */\n\trepeat?: true | 'x' | 'y'\n\t/**\n\t * Position of the overlay image relative to a given edge. Each property is\n\t * an offset in pixels. 0 aligns exactly to the edge. For example, left: 10\n\t * positions left side of the overlay 10 pixels from the left edge of the\n\t * image it's drawn over. bottom: 0 aligns bottom of the overlay with bottom\n\t * of the background image.\n\t *\n\t * Setting both left & right, or both top & bottom is an error.\n\t *\n\t * If no position is specified, the image will be centered.\n\t */\n\ttop?: number\n\tleft?: number\n\tbottom?: number\n\tright?: number\n}\ninterface RequestInitCfPropertiesImage extends BasicImageTransformations {\n\t/**\n\t * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it\n\t * easier to specify higher-DPI sizes in <img srcset>.\n\t */\n\tdpr?: number\n\t/**\n\t * Allows you to trim your image. Takes dpr into account and is performed before\n\t * resizing or rotation.\n\t *\n\t * It can be used as:\n\t * - left, top, right, bottom - it will specify the number of pixels to cut\n\t *   off each side\n\t * - width, height - the width/height you'd like to end up with - can be used\n\t *   in combination with the properties above\n\t * - border - this will automatically trim the surroundings of an image based on\n\t *   it's color. It consists of three properties:\n\t *    - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit)\n\t *    - tolerance: difference from color to treat as color\n\t *    - keep: the number of pixels of border to keep\n\t */\n\ttrim?:\n\t\t| 'border'\n\t\t| {\n\t\t\t\ttop?: number\n\t\t\t\tbottom?: number\n\t\t\t\tleft?: number\n\t\t\t\tright?: number\n\t\t\t\twidth?: number\n\t\t\t\theight?: number\n\t\t\t\tborder?:\n\t\t\t\t\t| boolean\n\t\t\t\t\t| {\n\t\t\t\t\t\t\tcolor?: string\n\t\t\t\t\t\t\ttolerance?: number\n\t\t\t\t\t\t\tkeep?: number\n\t\t\t\t\t  }\n\t\t  }\n\t/**\n\t * Quality setting from 1-100 (useful values are in 60-90 range). Lower values\n\t * make images look worse, but load faster. The default is 85. It applies only\n\t * to JPEG and WebP images. It doesn’t have any effect on PNG.\n\t */\n\tquality?: number | 'low' | 'medium-low' | 'medium-high' | 'high'\n\t/**\n\t * Output format to generate. It can be:\n\t *  - avif: generate images in AVIF format.\n\t *  - webp: generate images in Google WebP format. Set quality to 100 to get\n\t *    the WebP-lossless format.\n\t *  - json: instead of generating an image, outputs information about the\n\t *    image, in JSON format. The JSON object will contain image size\n\t *    (before and after resizing), source image’s MIME type, file size, etc.\n\t * - jpeg: generate images in JPEG format.\n\t * - png: generate images in PNG format.\n\t */\n\tformat?:\n\t\t| 'avif'\n\t\t| 'webp'\n\t\t| 'json'\n\t\t| 'jpeg'\n\t\t| 'png'\n\t\t| 'baseline-jpeg'\n\t\t| 'png-force'\n\t\t| 'svg'\n\t/**\n\t * Whether to preserve animation frames from input files. Default is true.\n\t * Setting it to false reduces animations to still images. This setting is\n\t * recommended when enlarging images or processing arbitrary user content,\n\t * because large GIF animations can weigh tens or even hundreds of megabytes.\n\t * It is also useful to set anim:false when using format:\"json\" to get the\n\t * response quicker without the number of frames.\n\t */\n\tanim?: boolean\n\t/**\n\t * What EXIF data should be preserved in the output image. Note that EXIF\n\t * rotation and embedded color profiles are always applied (\"baked in\" into\n\t * the image), and aren't affected by this option. Note that if the Polish\n\t * feature is enabled, all metadata may have been removed already and this\n\t * option may have no effect.\n\t *  - keep: Preserve most of EXIF metadata, including GPS location if there's\n\t *    any.\n\t *  - copyright: Only keep the copyright tag, and discard everything else.\n\t *    This is the default behavior for JPEG files.\n\t *  - none: Discard all invisible EXIF metadata. Currently WebP and PNG\n\t *    output formats always discard metadata.\n\t */\n\tmetadata?: 'keep' | 'copyright' | 'none'\n\t/**\n\t * Strength of sharpening filter to apply to the image. Floating-point\n\t * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a\n\t * recommended value for downscaled images.\n\t */\n\tsharpen?: number\n\t/**\n\t * Radius of a blur filter (approximate gaussian). Maximum supported radius\n\t * is 250.\n\t */\n\tblur?: number\n\t/**\n\t * Overlays are drawn in the order they appear in the array (last array\n\t * entry is the topmost layer).\n\t */\n\tdraw?: RequestInitCfPropertiesImageDraw[]\n\t/**\n\t * Fetching image from authenticated origin. Setting this property will\n\t * pass authentication headers (Authorization, Cookie, etc.) through to\n\t * the origin.\n\t */\n\t'origin-auth'?: 'share-publicly'\n\t/**\n\t * Adds a border around the image. The border is added after resizing. Border\n\t * width takes dpr into account, and can be specified either using a single\n\t * width property, or individually for each side.\n\t */\n\tborder?:\n\t\t| {\n\t\t\t\tcolor: string\n\t\t\t\twidth: number\n\t\t  }\n\t\t| {\n\t\t\t\tcolor: string\n\t\t\t\ttop: number\n\t\t\t\tright: number\n\t\t\t\tbottom: number\n\t\t\t\tleft: number\n\t\t  }\n\t/**\n\t * Increase brightness by a factor. A value of 1.0 equals no change, a value\n\t * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright.\n\t * 0 is ignored.\n\t */\n\tbrightness?: number\n\t/**\n\t * Increase contrast by a factor. A value of 1.0 equals no change, a value of\n\t * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is\n\t * ignored.\n\t */\n\tcontrast?: number\n\t/**\n\t * Increase exposure by a factor. A value of 1.0 equals no change, a value of\n\t * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored.\n\t */\n\tgamma?: number\n\t/**\n\t * Increase contrast by a factor. A value of 1.0 equals no change, a value of\n\t * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is\n\t * ignored.\n\t */\n\tsaturation?: number\n\t/**\n\t * Flips the images horizontally, vertically, or both. Flipping is applied before\n\t * rotation, so if you apply flip=h,rotate=90 then the image will be flipped\n\t * horizontally, then rotated by 90 degrees.\n\t */\n\tflip?: 'h' | 'v' | 'hv'\n\t/**\n\t * Slightly reduces latency on a cache miss by selecting a\n\t * quickest-to-compress file format, at a cost of increased file size and\n\t * lower image quality. It will usually override the format option and choose\n\t * JPEG over WebP or AVIF. We do not recommend using this option, except in\n\t * unusual circumstances like resizing uncacheable dynamically-generated\n\t * images.\n\t */\n\tcompression?: 'fast'\n}\ninterface RequestInitCfPropertiesImageMinify {\n\tjavascript?: boolean\n\tcss?: boolean\n\thtml?: boolean\n}\ninterface RequestInitCfPropertiesR2 {\n\t/**\n\t * Colo id of bucket that an object is stored in\n\t */\n\tbucketColoId?: number\n}\n/**\n * Request metadata provided by Cloudflare's edge.\n */\ntype IncomingRequestCfProperties<HostMetadata = unknown> =\n\tIncomingRequestCfPropertiesBase &\n\t\tIncomingRequestCfPropertiesBotManagementEnterprise &\n\t\tIncomingRequestCfPropertiesCloudflareForSaaSEnterprise<HostMetadata> &\n\t\tIncomingRequestCfPropertiesGeographicInformation &\n\t\tIncomingRequestCfPropertiesCloudflareAccessOrApiShield\ninterface IncomingRequestCfPropertiesBase extends Record<string, unknown> {\n\t/**\n\t * [ASN](https://www.iana.org/assignments/as-numbers/as-numbers.xhtml) of the incoming request.\n\t *\n\t * @example 395747\n\t */\n\tasn?: number\n\t/**\n\t * The organization which owns the ASN of the incoming request.\n\t *\n\t * @example \"Google Cloud\"\n\t */\n\tasOrganization?: string\n\t/**\n\t * The original value of the `Accept-Encoding` header if Cloudflare modified it.\n\t *\n\t * @example \"gzip, deflate, br\"\n\t */\n\tclientAcceptEncoding?: string\n\t/**\n\t * The number of milliseconds it took for the request to reach your worker.\n\t *\n\t * @example 22\n\t */\n\tclientTcpRtt?: number\n\t/**\n\t * The three-letter [IATA](https://en.wikipedia.org/wiki/IATA_airport_code)\n\t * airport code of the data center that the request hit.\n\t *\n\t * @example \"DFW\"\n\t */\n\tcolo: string\n\t/**\n\t * Represents the upstream's response to a\n\t * [TCP `keepalive` message](https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html)\n\t * from cloudflare.\n\t *\n\t * For workers with no upstream, this will always be `1`.\n\t *\n\t * @example 3\n\t */\n\tedgeRequestKeepAliveStatus: IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus\n\t/**\n\t * The HTTP Protocol the request used.\n\t *\n\t * @example \"HTTP/2\"\n\t */\n\thttpProtocol: string\n\t/**\n\t * The browser-requested prioritization information in the request object.\n\t *\n\t * If no information was set, defaults to the empty string `\"\"`\n\t *\n\t * @example \"weight=192;exclusive=0;group=3;group-weight=127\"\n\t * @default \"\"\n\t */\n\trequestPriority: string\n\t/**\n\t * The TLS version of the connection to Cloudflare.\n\t * In requests served over plaintext (without TLS), this property is the empty string `\"\"`.\n\t *\n\t * @example \"TLSv1.3\"\n\t */\n\ttlsVersion: string\n\t/**\n\t * The cipher for the connection to Cloudflare.\n\t * In requests served over plaintext (without TLS), this property is the empty string `\"\"`.\n\t *\n\t * @example \"AEAD-AES128-GCM-SHA256\"\n\t */\n\ttlsCipher: string\n\t/**\n\t * Metadata containing the [`HELLO`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2) and [`FINISHED`](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9) messages from this request's TLS handshake.\n\t *\n\t * If the incoming request was served over plaintext (without TLS) this field is undefined.\n\t */\n\ttlsExportedAuthenticator?: IncomingRequestCfPropertiesExportedAuthenticatorMetadata\n}\ninterface IncomingRequestCfPropertiesBotManagementBase {\n\t/**\n\t * Cloudflare’s [level of certainty](https://developers.cloudflare.com/bots/concepts/bot-score/) that a request comes from a bot,\n\t * represented as an integer percentage between `1` (almost certainly a bot) and `99` (almost certainly human).\n\t *\n\t * @example 54\n\t */\n\tscore: number\n\t/**\n\t * A boolean value that is true if the request comes from a good bot, like Google or Bing.\n\t * Most customers choose to allow this traffic. For more details, see [Traffic from known bots](https://developers.cloudflare.com/firewall/known-issues-and-faq/#how-does-firewall-rules-handle-traffic-from-known-bots).\n\t */\n\tverifiedBot: boolean\n\t/**\n\t * A boolean value that is true if the request originates from a\n\t * Cloudflare-verified proxy service.\n\t */\n\tcorporateProxy: boolean\n\t/**\n\t * A boolean value that's true if the request matches [file extensions](https://developers.cloudflare.com/bots/reference/static-resources/) for many types of static resources.\n\t */\n\tstaticResource: boolean\n\t/**\n\t * List of IDs that correlate to the Bot Management heuristic detections made on a request (you can have multiple heuristic detections on the same request).\n\t */\n\tdetectionIds: number[]\n}\ninterface IncomingRequestCfPropertiesBotManagement {\n\t/**\n\t * Results of Cloudflare's Bot Management analysis\n\t */\n\tbotManagement: IncomingRequestCfPropertiesBotManagementBase\n\t/**\n\t * Duplicate of `botManagement.score`.\n\t *\n\t * @deprecated\n\t */\n\tclientTrustScore: number\n}\ninterface IncomingRequestCfPropertiesBotManagementEnterprise extends IncomingRequestCfPropertiesBotManagement {\n\t/**\n\t * Results of Cloudflare's Bot Management analysis\n\t */\n\tbotManagement: IncomingRequestCfPropertiesBotManagementBase & {\n\t\t/**\n\t\t * A [JA3 Fingerprint](https://developers.cloudflare.com/bots/concepts/ja3-fingerprint/) to help profile specific SSL/TLS clients\n\t\t * across different destination IPs, Ports, and X509 certificates.\n\t\t */\n\t\tja3Hash: string\n\t}\n}\ninterface IncomingRequestCfPropertiesCloudflareForSaaSEnterprise<HostMetadata> {\n\t/**\n\t * Custom metadata set per-host in [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/).\n\t *\n\t * This field is only present if you have Cloudflare for SaaS enabled on your account\n\t * and you have followed the [required steps to enable it]((https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/custom-metadata/)).\n\t */\n\thostMetadata?: HostMetadata\n}\ninterface IncomingRequestCfPropertiesCloudflareAccessOrApiShield {\n\t/**\n\t * Information about the client certificate presented to Cloudflare.\n\t *\n\t * This is populated when the incoming request is served over TLS using\n\t * either Cloudflare Access or API Shield (mTLS)\n\t * and the presented SSL certificate has a valid\n\t * [Certificate Serial Number](https://ldapwiki.com/wiki/Certificate%20Serial%20Number)\n\t * (i.e., not `null` or `\"\"`).\n\t *\n\t * Otherwise, a set of placeholder values are used.\n\t *\n\t * The property `certPresented` will be set to `\"1\"` when\n\t * the object is populated (i.e. the above conditions were met).\n\t */\n\ttlsClientAuth:\n\t\t| IncomingRequestCfPropertiesTLSClientAuth\n\t\t| IncomingRequestCfPropertiesTLSClientAuthPlaceholder\n}\n/**\n * Metadata about the request's TLS handshake\n */\ninterface IncomingRequestCfPropertiesExportedAuthenticatorMetadata {\n\t/**\n\t * The client's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal\n\t *\n\t * @example \"44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d\"\n\t */\n\tclientHandshake: string\n\t/**\n\t * The server's [`HELLO` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2), encoded in hexadecimal\n\t *\n\t * @example \"44372ba35fa1270921d318f34c12f155dc87b682cf36a790cfaa3ba8737a1b5d\"\n\t */\n\tserverHandshake: string\n\t/**\n\t * The client's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal\n\t *\n\t * @example \"084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b\"\n\t */\n\tclientFinished: string\n\t/**\n\t * The server's [`FINISHED` message](https://www.rfc-editor.org/rfc/rfc5246#section-7.4.9), encoded in hexadecimal\n\t *\n\t * @example \"084ee802fe1348f688220e2a6040a05b2199a761f33cf753abb1b006792d3f8b\"\n\t */\n\tserverFinished: string\n}\n/**\n * Geographic data about the request's origin.\n */\ninterface IncomingRequestCfPropertiesGeographicInformation {\n\t/**\n\t * The [ISO 3166-1 Alpha 2](https://www.iso.org/iso-3166-country-codes.html) country code the request originated from.\n\t *\n\t * If your worker is [configured to accept TOR connections](https://support.cloudflare.com/hc/en-us/articles/203306930-Understanding-Cloudflare-Tor-support-and-Onion-Routing), this may also be `\"T1\"`, indicating a request that originated over TOR.\n\t *\n\t * If Cloudflare is unable to determine where the request originated this property is omitted.\n\t *\n\t * The country code `\"T1\"` is used for requests originating on TOR.\n\t *\n\t * @example \"GB\"\n\t */\n\tcountry?: Iso3166Alpha2Code | 'T1'\n\t/**\n\t * If present, this property indicates that the request originated in the EU\n\t *\n\t * @example \"1\"\n\t */\n\tisEUCountry?: '1'\n\t/**\n\t * A two-letter code indicating the continent the request originated from.\n\t *\n\t * @example \"AN\"\n\t */\n\tcontinent?: ContinentCode\n\t/**\n\t * The city the request originated from\n\t *\n\t * @example \"Austin\"\n\t */\n\tcity?: string\n\t/**\n\t * Postal code of the incoming request\n\t *\n\t * @example \"78701\"\n\t */\n\tpostalCode?: string\n\t/**\n\t * Latitude of the incoming request\n\t *\n\t * @example \"30.27130\"\n\t */\n\tlatitude?: string\n\t/**\n\t * Longitude of the incoming request\n\t *\n\t * @example \"-97.74260\"\n\t */\n\tlongitude?: string\n\t/**\n\t * Timezone of the incoming request\n\t *\n\t * @example \"America/Chicago\"\n\t */\n\ttimezone?: string\n\t/**\n\t * If known, the ISO 3166-2 name for the first level region associated with\n\t * the IP address of the incoming request\n\t *\n\t * @example \"Texas\"\n\t */\n\tregion?: string\n\t/**\n\t * If known, the ISO 3166-2 code for the first-level region associated with\n\t * the IP address of the incoming request\n\t *\n\t * @example \"TX\"\n\t */\n\tregionCode?: string\n\t/**\n\t * Metro code (DMA) of the incoming request\n\t *\n\t * @example \"635\"\n\t */\n\tmetroCode?: string\n}\n/** Data about the incoming request's TLS certificate */\ninterface IncomingRequestCfPropertiesTLSClientAuth {\n\t/** Always `\"1\"`, indicating that the certificate was presented */\n\tcertPresented: '1'\n\t/**\n\t * Result of certificate verification.\n\t *\n\t * @example \"FAILED:self signed certificate\"\n\t */\n\tcertVerified: Exclude<CertVerificationStatus, 'NONE'>\n\t/** The presented certificate's revokation status.\n\t *\n\t * - A value of `\"1\"` indicates the certificate has been revoked\n\t * - A value of `\"0\"` indicates the certificate has not been revoked\n\t */\n\tcertRevoked: '1' | '0'\n\t/**\n\t * The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html)\n\t *\n\t * @example \"CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare\"\n\t */\n\tcertIssuerDN: string\n\t/**\n\t * The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html)\n\t *\n\t * @example \"CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare\"\n\t */\n\tcertSubjectDN: string\n\t/**\n\t * The certificate issuer's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted)\n\t *\n\t * @example \"CN=cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare\"\n\t */\n\tcertIssuerDNRFC2253: string\n\t/**\n\t * The certificate subject's [distinguished name](https://knowledge.digicert.com/generalinformation/INFO1745.html) ([RFC 2253](https://www.rfc-editor.org/rfc/rfc2253.html) formatted)\n\t *\n\t * @example \"CN=*.cloudflareaccess.com, C=US, ST=Texas, L=Austin, O=Cloudflare\"\n\t */\n\tcertSubjectDNRFC2253: string\n\t/** The certificate issuer's distinguished name (legacy policies) */\n\tcertIssuerDNLegacy: string\n\t/** The certificate subject's distinguished name (legacy policies) */\n\tcertSubjectDNLegacy: string\n\t/**\n\t * The certificate's serial number\n\t *\n\t * @example \"00936EACBE07F201DF\"\n\t */\n\tcertSerial: string\n\t/**\n\t * The certificate issuer's serial number\n\t *\n\t * @example \"2489002934BDFEA34\"\n\t */\n\tcertIssuerSerial: string\n\t/**\n\t * The certificate's Subject Key Identifier\n\t *\n\t * @example \"BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4\"\n\t */\n\tcertSKI: string\n\t/**\n\t * The certificate issuer's Subject Key Identifier\n\t *\n\t * @example \"BB:AF:7E:02:3D:FA:A6:F1:3C:84:8E:AD:EE:38:98:EC:D9:32:32:D4\"\n\t */\n\tcertIssuerSKI: string\n\t/**\n\t * The certificate's SHA-1 fingerprint\n\t *\n\t * @example \"6b9109f323999e52259cda7373ff0b4d26bd232e\"\n\t */\n\tcertFingerprintSHA1: string\n\t/**\n\t * The certificate's SHA-256 fingerprint\n\t *\n\t * @example \"acf77cf37b4156a2708e34c4eb755f9b5dbbe5ebb55adfec8f11493438d19e6ad3f157f81fa3b98278453d5652b0c1fd1d71e5695ae4d709803a4d3f39de9dea\"\n\t */\n\tcertFingerprintSHA256: string\n\t/**\n\t * The effective starting date of the certificate\n\t *\n\t * @example \"Dec 22 19:39:00 2018 GMT\"\n\t */\n\tcertNotBefore: string\n\t/**\n\t * The effective expiration date of the certificate\n\t *\n\t * @example \"Dec 22 19:39:00 2018 GMT\"\n\t */\n\tcertNotAfter: string\n}\n/** Placeholder values for TLS Client Authorization */\ninterface IncomingRequestCfPropertiesTLSClientAuthPlaceholder {\n\tcertPresented: '0'\n\tcertVerified: 'NONE'\n\tcertRevoked: '0'\n\tcertIssuerDN: ''\n\tcertSubjectDN: ''\n\tcertIssuerDNRFC2253: ''\n\tcertSubjectDNRFC2253: ''\n\tcertIssuerDNLegacy: ''\n\tcertSubjectDNLegacy: ''\n\tcertSerial: ''\n\tcertIssuerSerial: ''\n\tcertSKI: ''\n\tcertIssuerSKI: ''\n\tcertFingerprintSHA1: ''\n\tcertFingerprintSHA256: ''\n\tcertNotBefore: ''\n\tcertNotAfter: ''\n}\n/** Possible outcomes of TLS verification */\ndeclare type CertVerificationStatus =\n\t/** Authentication succeeded */\n\t| 'SUCCESS'\n\t/** No certificate was presented */\n\t| 'NONE'\n\t/** Failed because the certificate was self-signed */\n\t| 'FAILED:self signed certificate'\n\t/** Failed because the certificate failed a trust chain check */\n\t| 'FAILED:unable to verify the first certificate'\n\t/** Failed because the certificate not yet valid */\n\t| 'FAILED:certificate is not yet valid'\n\t/** Failed because the certificate is expired */\n\t| 'FAILED:certificate has expired'\n\t/** Failed for another unspecified reason */\n\t| 'FAILED'\n/**\n * An upstream endpoint's response to a TCP `keepalive` message from Cloudflare.\n */\ndeclare type IncomingRequestCfPropertiesEdgeRequestKeepAliveStatus =\n\t| 0 /** Unknown */\n\t| 1 /** no keepalives (not found) */\n\t| 2 /** no connection re-use, opening keepalive connection failed */\n\t| 3 /** no connection re-use, keepalive accepted and saved */\n\t| 4 /** connection re-use, refused by the origin server (`TCP FIN`) */\n\t| 5 /** connection re-use, accepted by the origin server */\n/** ISO 3166-1 Alpha-2 codes */\ndeclare type Iso3166Alpha2Code =\n\t| 'AD'\n\t| 'AE'\n\t| 'AF'\n\t| 'AG'\n\t| 'AI'\n\t| 'AL'\n\t| 'AM'\n\t| 'AO'\n\t| 'AQ'\n\t| 'AR'\n\t| 'AS'\n\t| 'AT'\n\t| 'AU'\n\t| 'AW'\n\t| 'AX'\n\t| 'AZ'\n\t| 'BA'\n\t| 'BB'\n\t| 'BD'\n\t| 'BE'\n\t| 'BF'\n\t| 'BG'\n\t| 'BH'\n\t| 'BI'\n\t| 'BJ'\n\t| 'BL'\n\t| 'BM'\n\t| 'BN'\n\t| 'BO'\n\t| 'BQ'\n\t| 'BR'\n\t| 'BS'\n\t| 'BT'\n\t| 'BV'\n\t| 'BW'\n\t| 'BY'\n\t| 'BZ'\n\t| 'CA'\n\t| 'CC'\n\t| 'CD'\n\t| 'CF'\n\t| 'CG'\n\t| 'CH'\n\t| 'CI'\n\t| 'CK'\n\t| 'CL'\n\t| 'CM'\n\t| 'CN'\n\t| 'CO'\n\t| 'CR'\n\t| 'CU'\n\t| 'CV'\n\t| 'CW'\n\t| 'CX'\n\t| 'CY'\n\t| 'CZ'\n\t| 'DE'\n\t| 'DJ'\n\t| 'DK'\n\t| 'DM'\n\t| 'DO'\n\t| 'DZ'\n\t| 'EC'\n\t| 'EE'\n\t| 'EG'\n\t| 'EH'\n\t| 'ER'\n\t| 'ES'\n\t| 'ET'\n\t| 'FI'\n\t| 'FJ'\n\t| 'FK'\n\t| 'FM'\n\t| 'FO'\n\t| 'FR'\n\t| 'GA'\n\t| 'GB'\n\t| 'GD'\n\t| 'GE'\n\t| 'GF'\n\t| 'GG'\n\t| 'GH'\n\t| 'GI'\n\t| 'GL'\n\t| 'GM'\n\t| 'GN'\n\t| 'GP'\n\t| 'GQ'\n\t| 'GR'\n\t| 'GS'\n\t| 'GT'\n\t| 'GU'\n\t| 'GW'\n\t| 'GY'\n\t| 'HK'\n\t| 'HM'\n\t| 'HN'\n\t| 'HR'\n\t| 'HT'\n\t| 'HU'\n\t| 'ID'\n\t| 'IE'\n\t| 'IL'\n\t| 'IM'\n\t| 'IN'\n\t| 'IO'\n\t| 'IQ'\n\t| 'IR'\n\t| 'IS'\n\t| 'IT'\n\t| 'JE'\n\t| 'JM'\n\t| 'JO'\n\t| 'JP'\n\t| 'KE'\n\t| 'KG'\n\t| 'KH'\n\t| 'KI'\n\t| 'KM'\n\t| 'KN'\n\t| 'KP'\n\t| 'KR'\n\t| 'KW'\n\t| 'KY'\n\t| 'KZ'\n\t| 'LA'\n\t| 'LB'\n\t| 'LC'\n\t| 'LI'\n\t| 'LK'\n\t| 'LR'\n\t| 'LS'\n\t| 'LT'\n\t| 'LU'\n\t| 'LV'\n\t| 'LY'\n\t| 'MA'\n\t| 'MC'\n\t| 'MD'\n\t| 'ME'\n\t| 'MF'\n\t| 'MG'\n\t| 'MH'\n\t| 'MK'\n\t| 'ML'\n\t| 'MM'\n\t| 'MN'\n\t| 'MO'\n\t| 'MP'\n\t| 'MQ'\n\t| 'MR'\n\t| 'MS'\n\t| 'MT'\n\t| 'MU'\n\t| 'MV'\n\t| 'MW'\n\t| 'MX'\n\t| 'MY'\n\t| 'MZ'\n\t| 'NA'\n\t| 'NC'\n\t| 'NE'\n\t| 'NF'\n\t| 'NG'\n\t| 'NI'\n\t| 'NL'\n\t| 'NO'\n\t| 'NP'\n\t| 'NR'\n\t| 'NU'\n\t| 'NZ'\n\t| 'OM'\n\t| 'PA'\n\t| 'PE'\n\t| 'PF'\n\t| 'PG'\n\t| 'PH'\n\t| 'PK'\n\t| 'PL'\n\t| 'PM'\n\t| 'PN'\n\t| 'PR'\n\t| 'PS'\n\t| 'PT'\n\t| 'PW'\n\t| 'PY'\n\t| 'QA'\n\t| 'RE'\n\t| 'RO'\n\t| 'RS'\n\t| 'RU'\n\t| 'RW'\n\t| 'SA'\n\t| 'SB'\n\t| 'SC'\n\t| 'SD'\n\t| 'SE'\n\t| 'SG'\n\t| 'SH'\n\t| 'SI'\n\t| 'SJ'\n\t| 'SK'\n\t| 'SL'\n\t| 'SM'\n\t| 'SN'\n\t| 'SO'\n\t| 'SR'\n\t| 'SS'\n\t| 'ST'\n\t| 'SV'\n\t| 'SX'\n\t| 'SY'\n\t| 'SZ'\n\t| 'TC'\n\t| 'TD'\n\t| 'TF'\n\t| 'TG'\n\t| 'TH'\n\t| 'TJ'\n\t| 'TK'\n\t| 'TL'\n\t| 'TM'\n\t| 'TN'\n\t| 'TO'\n\t| 'TR'\n\t| 'TT'\n\t| 'TV'\n\t| 'TW'\n\t| 'TZ'\n\t| 'UA'\n\t| 'UG'\n\t| 'UM'\n\t| 'US'\n\t| 'UY'\n\t| 'UZ'\n\t| 'VA'\n\t| 'VC'\n\t| 'VE'\n\t| 'VG'\n\t| 'VI'\n\t| 'VN'\n\t| 'VU'\n\t| 'WF'\n\t| 'WS'\n\t| 'YE'\n\t| 'YT'\n\t| 'ZA'\n\t| 'ZM'\n\t| 'ZW'\n/** The 2-letter continent codes Cloudflare uses */\ndeclare type ContinentCode = 'AF' | 'AN' | 'AS' | 'EU' | 'NA' | 'OC' | 'SA'\ntype CfProperties<HostMetadata = unknown> =\n\t| IncomingRequestCfProperties<HostMetadata>\n\t| RequestInitCfProperties\ninterface D1Meta {\n\tduration: number\n\tsize_after: number\n\trows_read: number\n\trows_written: number\n\tlast_row_id: number\n\tchanged_db: boolean\n\tchanges: number\n\t/**\n\t * The region of the database instance that executed the query.\n\t */\n\tserved_by_region?: string\n\t/**\n\t * The three letters airport code of the colo that executed the query.\n\t */\n\tserved_by_colo?: string\n\t/**\n\t * True if-and-only-if the database instance that executed the query was the primary.\n\t */\n\tserved_by_primary?: boolean\n\ttimings?: {\n\t\t/**\n\t\t * The duration of the SQL query execution by the database instance. It doesn't include any network time.\n\t\t */\n\t\tsql_duration_ms: number\n\t}\n\t/**\n\t * Number of total attempts to execute the query, due to automatic retries.\n\t * Note: All other fields in the response like `timings` only apply to the last attempt.\n\t */\n\ttotal_attempts?: number\n}\ninterface D1Response {\n\tsuccess: true\n\tmeta: D1Meta & Record<string, unknown>\n\terror?: never\n}\ntype D1Result<T = unknown> = D1Response & {\n\tresults: T[]\n}\ninterface D1ExecResult {\n\tcount: number\n\tduration: number\n}\ntype D1SessionConstraint =\n\t// Indicates that the first query should go to the primary, and the rest queries\n\t// using the same D1DatabaseSession will go to any replica that is consistent with\n\t// the bookmark maintained by the session (returned by the first query).\n\t| 'first-primary'\n\t// Indicates that the first query can go anywhere (primary or replica), and the rest queries\n\t// using the same D1DatabaseSession will go to any replica that is consistent with\n\t// the bookmark maintained by the session (returned by the first query).\n\t| 'first-unconstrained'\ntype D1SessionBookmark = string\ndeclare abstract class D1Database {\n\tprepare(query: string): D1PreparedStatement\n\tbatch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>\n\texec(query: string): Promise<D1ExecResult>\n\t/**\n\t * Creates a new D1 Session anchored at the given constraint or the bookmark.\n\t * All queries executed using the created session will have sequential consistency,\n\t * meaning that all writes done through the session will be visible in subsequent reads.\n\t *\n\t * @param constraintOrBookmark Either the session constraint or the explicit bookmark to anchor the created session.\n\t */\n\twithSession(\n\t\tconstraintOrBookmark?: D1SessionBookmark | D1SessionConstraint,\n\t): D1DatabaseSession\n\t/**\n\t * @deprecated dump() will be removed soon, only applies to deprecated alpha v1 databases.\n\t */\n\tdump(): Promise<ArrayBuffer>\n}\ndeclare abstract class D1DatabaseSession {\n\tprepare(query: string): D1PreparedStatement\n\tbatch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>\n\t/**\n\t * @returns The latest session bookmark across all executed queries on the session.\n\t *          If no query has been executed yet, `null` is returned.\n\t */\n\tgetBookmark(): D1SessionBookmark | null\n}\ndeclare abstract class D1PreparedStatement {\n\tbind(...values: unknown[]): D1PreparedStatement\n\tfirst<T = unknown>(colName: string): Promise<T | null>\n\tfirst<T = Record<string, unknown>>(): Promise<T | null>\n\trun<T = Record<string, unknown>>(): Promise<D1Result<T>>\n\tall<T = Record<string, unknown>>(): Promise<D1Result<T>>\n\traw<T = unknown[]>(options: {\n\t\tcolumnNames: true\n\t}): Promise<[string[], ...T[]]>\n\traw<T = unknown[]>(options?: { columnNames?: false }): Promise<T[]>\n}\n// `Disposable` was added to TypeScript's standard lib types in version 5.2.\n// To support older TypeScript versions, define an empty `Disposable` interface.\n// Users won't be able to use `using`/`Symbol.dispose` without upgrading to 5.2,\n// but this will ensure type checking on older versions still passes.\n// TypeScript's interface merging will ensure our empty interface is effectively\n// ignored when `Disposable` is included in the standard lib.\ninterface Disposable {}\n/**\n * The returned data after sending an email\n */\ninterface EmailSendResult {\n\t/**\n\t * The Email Message ID\n\t */\n\tmessageId: string\n}\n/**\n * An email message that can be sent from a Worker.\n */\ninterface EmailMessage {\n\t/**\n\t * Envelope From attribute of the email message.\n\t */\n\treadonly from: string\n\t/**\n\t * Envelope To attribute of the email message.\n\t */\n\treadonly to: string\n}\n/**\n * An email message that is sent to a consumer Worker and can be rejected/forwarded.\n */\ninterface ForwardableEmailMessage extends EmailMessage {\n\t/**\n\t * Stream of the email message content.\n\t */\n\treadonly raw: ReadableStream<Uint8Array>\n\t/**\n\t * An [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers).\n\t */\n\treadonly headers: Headers\n\t/**\n\t * Size of the email message content.\n\t */\n\treadonly rawSize: number\n\t/**\n\t * Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason.\n\t * @param reason The reject reason.\n\t * @returns void\n\t */\n\tsetReject(reason: string): void\n\t/**\n\t * Forward this email message to a verified destination address of the account.\n\t * @param rcptTo Verified destination address.\n\t * @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers).\n\t * @returns A promise that resolves when the email message is forwarded.\n\t */\n\tforward(rcptTo: string, headers?: Headers): Promise<EmailSendResult>\n\t/**\n\t * Reply to the sender of this email message with a new EmailMessage object.\n\t * @param message The reply message.\n\t * @returns A promise that resolves when the email message is replied.\n\t */\n\treply(message: EmailMessage): Promise<EmailSendResult>\n}\n/** A file attachment for an email message */\ntype EmailAttachment =\n\t| {\n\t\t\tdisposition: 'inline'\n\t\t\tcontentId: string\n\t\t\tfilename: string\n\t\t\ttype: string\n\t\t\tcontent: string | ArrayBuffer | ArrayBufferView\n\t  }\n\t| {\n\t\t\tdisposition: 'attachment'\n\t\t\tcontentId?: undefined\n\t\t\tfilename: string\n\t\t\ttype: string\n\t\t\tcontent: string | ArrayBuffer | ArrayBufferView\n\t  }\n/** An Email Address */\ninterface EmailAddress {\n\tname: string\n\temail: string\n}\n/**\n * A binding that allows a Worker to send email messages.\n */\ninterface SendEmail {\n\tsend(message: EmailMessage): Promise<EmailSendResult>\n\tsend(builder: {\n\t\tfrom: string | EmailAddress\n\t\tto: string | string[]\n\t\tsubject: string\n\t\treplyTo?: string | EmailAddress\n\t\tcc?: string | string[]\n\t\tbcc?: string | string[]\n\t\theaders?: Record<string, string>\n\t\ttext?: string\n\t\thtml?: string\n\t\tattachments?: EmailAttachment[]\n\t}): Promise<EmailSendResult>\n}\ndeclare abstract class EmailEvent extends ExtendableEvent {\n\treadonly message: ForwardableEmailMessage\n}\ndeclare type EmailExportedHandler<Env = unknown> = (\n\tmessage: ForwardableEmailMessage,\n\tenv: Env,\n\tctx: ExecutionContext,\n) => void | Promise<void>\ndeclare module 'cloudflare:email' {\n\tlet _EmailMessage: {\n\t\tprototype: EmailMessage\n\t\tnew (from: string, to: string, raw: ReadableStream | string): EmailMessage\n\t}\n\texport { _EmailMessage as EmailMessage }\n}\n/**\n * Hello World binding to serve as an explanatory example. DO NOT USE\n */\ninterface HelloWorldBinding {\n\t/**\n\t * Retrieve the current stored value\n\t */\n\tget(): Promise<{\n\t\tvalue: string\n\t\tms?: number\n\t}>\n\t/**\n\t * Set a new stored value\n\t */\n\tset(value: string): Promise<void>\n}\ninterface Hyperdrive {\n\t/**\n\t * Connect directly to Hyperdrive as if it's your database, returning a TCP socket.\n\t *\n\t * Calling this method returns an identical socket to if you call\n\t * `connect(\"host:port\")` using the `host` and `port` fields from this object.\n\t * Pick whichever approach works better with your preferred DB client library.\n\t *\n\t * Note that this socket is not yet authenticated -- it's expected that your\n\t * code (or preferably, the client library of your choice) will authenticate\n\t * using the information in this class's readonly fields.\n\t */\n\tconnect(): Socket\n\t/**\n\t * A valid DB connection string that can be passed straight into the typical\n\t * client library/driver/ORM. This will typically be the easiest way to use\n\t * Hyperdrive.\n\t */\n\treadonly connectionString: string\n\t/*\n\t * A randomly generated hostname that is only valid within the context of the\n\t * currently running Worker which, when passed into `connect()` function from\n\t * the \"cloudflare:sockets\" module, will connect to the Hyperdrive instance\n\t * for your database.\n\t */\n\treadonly host: string\n\t/*\n\t * The port that must be paired the the host field when connecting.\n\t */\n\treadonly port: number\n\t/*\n\t * The username to use when authenticating to your database via Hyperdrive.\n\t * Unlike the host and password, this will be the same every time\n\t */\n\treadonly user: string\n\t/*\n\t * The randomly generated password to use when authenticating to your\n\t * database via Hyperdrive. Like the host field, this password is only valid\n\t * within the context of the currently running Worker instance from which\n\t * it's read.\n\t */\n\treadonly password: string\n\t/*\n\t * The name of the database to connect to.\n\t */\n\treadonly database: string\n}\n// Copyright (c) 2024 Cloudflare, Inc.\n// Licensed under the Apache 2.0 license found in the LICENSE file or at:\n//     https://opensource.org/licenses/Apache-2.0\ntype ImageInfoResponse =\n\t| {\n\t\t\tformat: 'image/svg+xml'\n\t  }\n\t| {\n\t\t\tformat: string\n\t\t\tfileSize: number\n\t\t\twidth: number\n\t\t\theight: number\n\t  }\ntype ImageTransform = {\n\twidth?: number\n\theight?: number\n\tbackground?: string\n\tblur?: number\n\tborder?:\n\t\t| {\n\t\t\t\tcolor?: string\n\t\t\t\twidth?: number\n\t\t  }\n\t\t| {\n\t\t\t\ttop?: number\n\t\t\t\tbottom?: number\n\t\t\t\tleft?: number\n\t\t\t\tright?: number\n\t\t  }\n\tbrightness?: number\n\tcontrast?: number\n\tfit?: 'scale-down' | 'contain' | 'pad' | 'squeeze' | 'cover' | 'crop'\n\tflip?: 'h' | 'v' | 'hv'\n\tgamma?: number\n\tsegment?: 'foreground'\n\tgravity?:\n\t\t| 'face'\n\t\t| 'left'\n\t\t| 'right'\n\t\t| 'top'\n\t\t| 'bottom'\n\t\t| 'center'\n\t\t| 'auto'\n\t\t| 'entropy'\n\t\t| {\n\t\t\t\tx?: number\n\t\t\t\ty?: number\n\t\t\t\tmode: 'remainder' | 'box-center'\n\t\t  }\n\trotate?: 0 | 90 | 180 | 270\n\tsaturation?: number\n\tsharpen?: number\n\ttrim?:\n\t\t| 'border'\n\t\t| {\n\t\t\t\ttop?: number\n\t\t\t\tbottom?: number\n\t\t\t\tleft?: number\n\t\t\t\tright?: number\n\t\t\t\twidth?: number\n\t\t\t\theight?: number\n\t\t\t\tborder?:\n\t\t\t\t\t| boolean\n\t\t\t\t\t| {\n\t\t\t\t\t\t\tcolor?: string\n\t\t\t\t\t\t\ttolerance?: number\n\t\t\t\t\t\t\tkeep?: number\n\t\t\t\t\t  }\n\t\t  }\n}\ntype ImageDrawOptions = {\n\topacity?: number\n\trepeat?: boolean | string\n\ttop?: number\n\tleft?: number\n\tbottom?: number\n\tright?: number\n}\ntype ImageInputOptions = {\n\tencoding?: 'base64'\n}\ntype ImageOutputOptions = {\n\tformat:\n\t\t| 'image/jpeg'\n\t\t| 'image/png'\n\t\t| 'image/gif'\n\t\t| 'image/webp'\n\t\t| 'image/avif'\n\t\t| 'rgb'\n\t\t| 'rgba'\n\tquality?: number\n\tbackground?: string\n\tanim?: boolean\n}\ninterface ImagesBinding {\n\t/**\n\t * Get image metadata (type, width and height)\n\t * @throws {@link ImagesError} with code 9412 if input is not an image\n\t * @param stream The image bytes\n\t */\n\tinfo(\n\t\tstream: ReadableStream<Uint8Array>,\n\t\toptions?: ImageInputOptions,\n\t): Promise<ImageInfoResponse>\n\t/**\n\t * Begin applying a series of transformations to an image\n\t * @param stream The image bytes\n\t * @returns A transform handle\n\t */\n\tinput(\n\t\tstream: ReadableStream<Uint8Array>,\n\t\toptions?: ImageInputOptions,\n\t): ImageTransformer\n}\ninterface ImageTransformer {\n\t/**\n\t * Apply transform next, returning a transform handle.\n\t * You can then apply more transformations, draw, or retrieve the output.\n\t * @param transform\n\t */\n\ttransform(transform: ImageTransform): ImageTransformer\n\t/**\n\t * Draw an image on this transformer, returning a transform handle.\n\t * You can then apply more transformations, draw, or retrieve the output.\n\t * @param image The image (or transformer that will give the image) to draw\n\t * @param options The options configuring how to draw the image\n\t */\n\tdraw(\n\t\timage: ReadableStream<Uint8Array> | ImageTransformer,\n\t\toptions?: ImageDrawOptions,\n\t): ImageTransformer\n\t/**\n\t * Retrieve the image that results from applying the transforms to the\n\t * provided input\n\t * @param options Options that apply to the output e.g. output format\n\t */\n\toutput(options: ImageOutputOptions): Promise<ImageTransformationResult>\n}\ntype ImageTransformationOutputOptions = {\n\tencoding?: 'base64'\n}\ninterface ImageTransformationResult {\n\t/**\n\t * The image as a response, ready to store in cache or return to users\n\t */\n\tresponse(): Response\n\t/**\n\t * The content type of the returned image\n\t */\n\tcontentType(): string\n\t/**\n\t * The bytes of the response\n\t */\n\timage(options?: ImageTransformationOutputOptions): ReadableStream<Uint8Array>\n}\ninterface ImagesError extends Error {\n\treadonly code: number\n\treadonly message: string\n\treadonly stack?: string\n}\n/**\n * Media binding for transforming media streams.\n * Provides the entry point for media transformation operations.\n */\ninterface MediaBinding {\n\t/**\n\t * Creates a media transformer from an input stream.\n\t * @param media - The input media bytes\n\t * @returns A MediaTransformer instance for applying transformations\n\t */\n\tinput(media: ReadableStream<Uint8Array>): MediaTransformer\n}\n/**\n * Media transformer for applying transformation operations to media content.\n * Handles sizing, fitting, and other input transformation parameters.\n */\ninterface MediaTransformer {\n\t/**\n\t * Applies transformation options to the media content.\n\t * @param transform - Configuration for how the media should be transformed\n\t * @returns A generator for producing the transformed media output\n\t */\n\ttransform(\n\t\ttransform?: MediaTransformationInputOptions,\n\t): MediaTransformationGenerator\n\t/**\n\t * Generates the final media output with specified options.\n\t * @param output - Configuration for the output format and parameters\n\t * @returns The final transformation result containing the transformed media\n\t */\n\toutput(output?: MediaTransformationOutputOptions): MediaTransformationResult\n}\n/**\n * Generator for producing media transformation results.\n * Configures the output format and parameters for the transformed media.\n */\ninterface MediaTransformationGenerator {\n\t/**\n\t * Generates the final media output with specified options.\n\t * @param output - Configuration for the output format and parameters\n\t * @returns The final transformation result containing the transformed media\n\t */\n\toutput(output?: MediaTransformationOutputOptions): MediaTransformationResult\n}\n/**\n * Result of a media transformation operation.\n * Provides multiple ways to access the transformed media content.\n */\ninterface MediaTransformationResult {\n\t/**\n\t * Returns the transformed media as a readable stream of bytes.\n\t * @returns A promise containing a readable stream with the transformed media\n\t */\n\tmedia(): Promise<ReadableStream<Uint8Array>>\n\t/**\n\t * Returns the transformed media as an HTTP response object.\n\t * @returns The transformed media as a Promise<Response>, ready to store in cache or return to users\n\t */\n\tresponse(): Promise<Response>\n\t/**\n\t * Returns the MIME type of the transformed media.\n\t * @returns A promise containing the content type string (e.g., 'image/jpeg', 'video/mp4')\n\t */\n\tcontentType(): Promise<string>\n}\n/**\n * Configuration options for transforming media input.\n * Controls how the media should be resized and fitted.\n */\ntype MediaTransformationInputOptions = {\n\t/** How the media should be resized to fit the specified dimensions */\n\tfit?: 'contain' | 'cover' | 'scale-down'\n\t/** Target width in pixels */\n\twidth?: number\n\t/** Target height in pixels */\n\theight?: number\n}\n/**\n * Configuration options for Media Transformations output.\n * Controls the format, timing, and type of the generated output.\n */\ntype MediaTransformationOutputOptions = {\n\t/**\n\t * Output mode determining the type of media to generate\n\t */\n\tmode?: 'video' | 'spritesheet' | 'frame' | 'audio'\n\t/** Whether to include audio in the output */\n\taudio?: boolean\n\t/**\n\t * Starting timestamp for frame extraction or start time for clips. (e.g. '2s').\n\t */\n\ttime?: string\n\t/**\n\t * Duration for video clips, audio extraction, and spritesheet generation (e.g. '5s').\n\t */\n\tduration?: string\n\t/**\n\t * Number of frames in the spritesheet.\n\t */\n\timageCount?: number\n\t/**\n\t * Output format for the generated media.\n\t */\n\tformat?: 'jpg' | 'png' | 'm4a'\n}\n/**\n * Error object for media transformation operations.\n * Extends the standard Error interface with additional media-specific information.\n */\ninterface MediaError extends Error {\n\treadonly code: number\n\treadonly message: string\n\treadonly stack?: string\n}\ndeclare module 'cloudflare:node' {\n\tinterface NodeStyleServer {\n\t\tlisten(...args: unknown[]): this\n\t\taddress(): {\n\t\t\tport?: number | null | undefined\n\t\t}\n\t}\n\texport function httpServerHandler(port: number): ExportedHandler\n\texport function httpServerHandler(options: { port: number }): ExportedHandler\n\texport function httpServerHandler(server: NodeStyleServer): ExportedHandler\n}\ntype Params<P extends string = any> = Record<P, string | string[]>\ntype EventContext<Env, P extends string, Data> = {\n\trequest: Request<unknown, IncomingRequestCfProperties<unknown>>\n\tfunctionPath: string\n\twaitUntil: (promise: Promise<any>) => void\n\tpassThroughOnException: () => void\n\tnext: (input?: Request | string, init?: RequestInit) => Promise<Response>\n\tenv: Env & {\n\t\tASSETS: {\n\t\t\tfetch: typeof fetch\n\t\t}\n\t}\n\tparams: Params<P>\n\tdata: Data\n}\ntype PagesFunction<\n\tEnv = unknown,\n\tParams extends string = any,\n\tData extends Record<string, unknown> = Record<string, unknown>,\n> = (context: EventContext<Env, Params, Data>) => Response | Promise<Response>\ntype EventPluginContext<Env, P extends string, Data, PluginArgs> = {\n\trequest: Request<unknown, IncomingRequestCfProperties<unknown>>\n\tfunctionPath: string\n\twaitUntil: (promise: Promise<any>) => void\n\tpassThroughOnException: () => void\n\tnext: (input?: Request | string, init?: RequestInit) => Promise<Response>\n\tenv: Env & {\n\t\tASSETS: {\n\t\t\tfetch: typeof fetch\n\t\t}\n\t}\n\tparams: Params<P>\n\tdata: Data\n\tpluginArgs: PluginArgs\n}\ntype PagesPluginFunction<\n\tEnv = unknown,\n\tParams extends string = any,\n\tData extends Record<string, unknown> = Record<string, unknown>,\n\tPluginArgs = unknown,\n> = (\n\tcontext: EventPluginContext<Env, Params, Data, PluginArgs>,\n) => Response | Promise<Response>\ndeclare module 'assets:*' {\n\texport const onRequest: PagesFunction\n}\n// Copyright (c) 2022-2023 Cloudflare, Inc.\n// Licensed under the Apache 2.0 license found in the LICENSE file or at:\n//     https://opensource.org/licenses/Apache-2.0\ndeclare module 'cloudflare:pipelines' {\n\texport abstract class PipelineTransformationEntrypoint<\n\t\tEnv = unknown,\n\t\tI extends PipelineRecord = PipelineRecord,\n\t\tO extends PipelineRecord = PipelineRecord,\n\t> {\n\t\tprotected env: Env\n\t\tprotected ctx: ExecutionContext\n\t\tconstructor(ctx: ExecutionContext, env: Env)\n\t\t/**\n\t\t * run receives an array of PipelineRecord which can be\n\t\t * transformed and returned to the pipeline\n\t\t * @param records Incoming records from the pipeline to be transformed\n\t\t * @param metadata Information about the specific pipeline calling the transformation entrypoint\n\t\t * @returns A promise containing the transformed PipelineRecord array\n\t\t */\n\t\tpublic run(records: I[], metadata: PipelineBatchMetadata): Promise<O[]>\n\t}\n\texport type PipelineRecord = Record<string, unknown>\n\texport type PipelineBatchMetadata = {\n\t\tpipelineId: string\n\t\tpipelineName: string\n\t}\n\texport interface Pipeline<T extends PipelineRecord = PipelineRecord> {\n\t\t/**\n\t\t * The Pipeline interface represents the type of a binding to a Pipeline\n\t\t *\n\t\t * @param records The records to send to the pipeline\n\t\t */\n\t\tsend(records: T[]): Promise<void>\n\t}\n}\n// PubSubMessage represents an incoming PubSub message.\n// The message includes metadata about the broker, the client, and the payload\n// itself.\n// https://developers.cloudflare.com/pub-sub/\ninterface PubSubMessage {\n\t// Message ID\n\treadonly mid: number\n\t// MQTT broker FQDN in the form mqtts://BROKER.NAMESPACE.cloudflarepubsub.com:PORT\n\treadonly broker: string\n\t// The MQTT topic the message was sent on.\n\treadonly topic: string\n\t// The client ID of the client that published this message.\n\treadonly clientId: string\n\t// The unique identifier (JWT ID) used by the client to authenticate, if token\n\t// auth was used.\n\treadonly jti?: string\n\t// A Unix timestamp (seconds from Jan 1, 1970), set when the Pub/Sub Broker\n\t// received the message from the client.\n\treadonly receivedAt: number\n\t// An (optional) string with the MIME type of the payload, if set by the\n\t// client.\n\treadonly contentType: string\n\t// Set to 1 when the payload is a UTF-8 string\n\t// https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901063\n\treadonly payloadFormatIndicator: number\n\t// Pub/Sub (MQTT) payloads can be UTF-8 strings, or byte arrays.\n\t// You can use payloadFormatIndicator to inspect this before decoding.\n\tpayload: string | Uint8Array\n}\n// JsonWebKey extended by kid parameter\ninterface JsonWebKeyWithKid extends JsonWebKey {\n\t// Key Identifier of the JWK\n\treadonly kid: string\n}\ninterface RateLimitOptions {\n\tkey: string\n}\ninterface RateLimitOutcome {\n\tsuccess: boolean\n}\ninterface RateLimit {\n\t/**\n\t * Rate limit a request based on the provided options.\n\t * @see https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/\n\t * @returns A promise that resolves with the outcome of the rate limit.\n\t */\n\tlimit(options: RateLimitOptions): Promise<RateLimitOutcome>\n}\n// Namespace for RPC utility types. Unfortunately, we can't use a `module` here as these types need\n// to referenced by `Fetcher`. This is included in the \"importable\" version of the types which\n// strips all `module` blocks.\ndeclare namespace Rpc {\n\t// Branded types for identifying `WorkerEntrypoint`/`DurableObject`/`Target`s.\n\t// TypeScript uses *structural* typing meaning anything with the same shape as type `T` is a `T`.\n\t// For the classes exported by `cloudflare:workers` we want *nominal* typing (i.e. we only want to\n\t// accept `WorkerEntrypoint` from `cloudflare:workers`, not any other class with the same shape)\n\texport const __RPC_STUB_BRAND: '__RPC_STUB_BRAND'\n\texport const __RPC_TARGET_BRAND: '__RPC_TARGET_BRAND'\n\texport const __WORKER_ENTRYPOINT_BRAND: '__WORKER_ENTRYPOINT_BRAND'\n\texport const __DURABLE_OBJECT_BRAND: '__DURABLE_OBJECT_BRAND'\n\texport const __WORKFLOW_ENTRYPOINT_BRAND: '__WORKFLOW_ENTRYPOINT_BRAND'\n\texport interface RpcTargetBranded {\n\t\t[__RPC_TARGET_BRAND]: never\n\t}\n\texport interface WorkerEntrypointBranded {\n\t\t[__WORKER_ENTRYPOINT_BRAND]: never\n\t}\n\texport interface DurableObjectBranded {\n\t\t[__DURABLE_OBJECT_BRAND]: never\n\t}\n\texport interface WorkflowEntrypointBranded {\n\t\t[__WORKFLOW_ENTRYPOINT_BRAND]: never\n\t}\n\texport type EntrypointBranded =\n\t\t| WorkerEntrypointBranded\n\t\t| DurableObjectBranded\n\t\t| WorkflowEntrypointBranded\n\t// Types that can be used through `Stub`s\n\texport type Stubable = RpcTargetBranded | ((...args: any[]) => any)\n\t// Types that can be passed over RPC\n\t// The reason for using a generic type here is to build a serializable subset of structured\n\t//   cloneable composite types. This allows types defined with the \"interface\" keyword to pass the\n\t//   serializable check as well. Otherwise, only types defined with the \"type\" keyword would pass.\n\ttype Serializable<T> =\n\t\t// Structured cloneables\n\t\t| BaseType\n\t\t// Structured cloneable composites\n\t\t| Map<\n\t\t\t\tT extends Map<infer U, unknown> ? Serializable<U> : never,\n\t\t\t\tT extends Map<unknown, infer U> ? Serializable<U> : never\n\t\t  >\n\t\t| Set<T extends Set<infer U> ? Serializable<U> : never>\n\t\t| ReadonlyArray<T extends ReadonlyArray<infer U> ? Serializable<U> : never>\n\t\t| {\n\t\t\t\t[K in keyof T]: K extends number | string ? Serializable<T[K]> : never\n\t\t  }\n\t\t// Special types\n\t\t| Stub<Stubable>\n\t\t// Serialized as stubs, see `Stubify`\n\t\t| Stubable\n\t// Base type for all RPC stubs, including common memory management methods.\n\t// `T` is used as a marker type for unwrapping `Stub`s later.\n\tinterface StubBase<T extends Stubable> extends Disposable {\n\t\t[__RPC_STUB_BRAND]: T\n\t\tdup(): this\n\t}\n\texport type Stub<T extends Stubable> = Provider<T> & StubBase<T>\n\t// This represents all the types that can be sent as-is over an RPC boundary\n\ttype BaseType =\n\t\t| void\n\t\t| undefined\n\t\t| null\n\t\t| boolean\n\t\t| number\n\t\t| bigint\n\t\t| string\n\t\t| TypedArray\n\t\t| ArrayBuffer\n\t\t| DataView\n\t\t| Date\n\t\t| Error\n\t\t| RegExp\n\t\t| ReadableStream<Uint8Array>\n\t\t| WritableStream<Uint8Array>\n\t\t| Request\n\t\t| Response\n\t\t| Headers\n\t// Recursively rewrite all `Stubable` types with `Stub`s\n\t// prettier-ignore\n\ttype Stubify<T> = T extends Stubable ? Stub<T> : T extends Map<infer K, infer V> ? Map<Stubify<K>, Stubify<V>> : T extends Set<infer V> ? Set<Stubify<V>> : T extends Array<infer V> ? Array<Stubify<V>> : T extends ReadonlyArray<infer V> ? ReadonlyArray<Stubify<V>> : T extends BaseType ? T : T extends {\n        [key: string | number]: any;\n    } ? {\n        [K in keyof T]: Stubify<T[K]>;\n    } : T;\n\t// Recursively rewrite all `Stub<T>`s with the corresponding `T`s.\n\t// Note we use `StubBase` instead of `Stub` here to avoid circular dependencies:\n\t// `Stub` depends on `Provider`, which depends on `Unstubify`, which would depend on `Stub`.\n\t// prettier-ignore\n\ttype Unstubify<T> = T extends StubBase<infer V> ? V : T extends Map<infer K, infer V> ? Map<Unstubify<K>, Unstubify<V>> : T extends Set<infer V> ? Set<Unstubify<V>> : T extends Array<infer V> ? Array<Unstubify<V>> : T extends ReadonlyArray<infer V> ? ReadonlyArray<Unstubify<V>> : T extends BaseType ? T : T extends {\n        [key: string | number]: unknown;\n    } ? {\n        [K in keyof T]: Unstubify<T[K]>;\n    } : T;\n\ttype UnstubifyAll<A extends any[]> = {\n\t\t[I in keyof A]: Unstubify<A[I]>\n\t}\n\t// Utility type for adding `Provider`/`Disposable`s to `object` types only.\n\t// Note `unknown & T` is equivalent to `T`.\n\ttype MaybeProvider<T> = T extends object ? Provider<T> : unknown\n\ttype MaybeDisposable<T> = T extends object ? Disposable : unknown\n\t// Type for method return or property on an RPC interface.\n\t// - Stubable types are replaced by stubs.\n\t// - Serializable types are passed by value, with stubable types replaced by stubs\n\t//   and a top-level `Disposer`.\n\t// Everything else can't be passed over PRC.\n\t// Technically, we use custom thenables here, but they quack like `Promise`s.\n\t// Intersecting with `(Maybe)Provider` allows pipelining.\n\t// prettier-ignore\n\ttype Result<R> = R extends Stubable ? Promise<Stub<R>> & Provider<R> : R extends Serializable<R> ? Promise<Stubify<R> & MaybeDisposable<R>> & MaybeProvider<R> : never;\n\t// Type for method or property on an RPC interface.\n\t// For methods, unwrap `Stub`s in parameters, and rewrite returns to be `Result`s.\n\t// Unwrapping `Stub`s allows calling with `Stubable` arguments.\n\t// For properties, rewrite types to be `Result`s.\n\t// In each case, unwrap `Promise`s.\n\ttype MethodOrProperty<V> = V extends (...args: infer P) => infer R\n\t\t? (...args: UnstubifyAll<P>) => Result<Awaited<R>>\n\t\t: Result<Awaited<V>>\n\t// Type for the callable part of an `Provider` if `T` is callable.\n\t// This is intersected with methods/properties.\n\ttype MaybeCallableProvider<T> = T extends (...args: any[]) => any\n\t\t? MethodOrProperty<T>\n\t\t: unknown\n\t// Base type for all other types providing RPC-like interfaces.\n\t// Rewrites all methods/properties to be `MethodOrProperty`s, while preserving callable types.\n\t// `Reserved` names (e.g. stub method names like `dup()`) and symbols can't be accessed over RPC.\n\texport type Provider<\n\t\tT extends object,\n\t\tReserved extends string = never,\n\t> = MaybeCallableProvider<T> &\n\t\tPick<\n\t\t\t{\n\t\t\t\t[K in keyof T]: MethodOrProperty<T[K]>\n\t\t\t},\n\t\t\tExclude<keyof T, Reserved | symbol | keyof StubBase<never>>\n\t\t>\n}\ndeclare namespace Cloudflare {\n\t// Type of `env`.\n\t//\n\t// The specific project can extend `Env` by redeclaring it in project-specific files. Typescript\n\t// will merge all declarations.\n\t//\n\t// You can use `wrangler types` to generate the `Env` type automatically.\n\tinterface Env {}\n\t// Project-specific parameters used to inform types.\n\t//\n\t// This interface is, again, intended to be declared in project-specific files, and then that\n\t// declaration will be merged with this one.\n\t//\n\t// A project should have a declaration like this:\n\t//\n\t//     interface GlobalProps {\n\t//       // Declares the main module's exports. Used to populate Cloudflare.Exports aka the type\n\t//       // of `ctx.exports`.\n\t//       mainModule: typeof import(\"my-main-module\");\n\t//\n\t//       // Declares which of the main module's exports are configured with durable storage, and\n\t//       // thus should behave as Durable Object namsepace bindings.\n\t//       durableNamespaces: \"MyDurableObject\" | \"AnotherDurableObject\";\n\t//     }\n\t//\n\t// You can use `wrangler types` to generate `GlobalProps` automatically.\n\tinterface GlobalProps {}\n\t// Evaluates to the type of a property in GlobalProps, defaulting to `Default` if it is not\n\t// present.\n\ttype GlobalProp<K extends string, Default> = K extends keyof GlobalProps\n\t\t? GlobalProps[K]\n\t\t: Default\n\t// The type of the program's main module exports, if known. Requires `GlobalProps` to declare the\n\t// `mainModule` property.\n\ttype MainModule = GlobalProp<'mainModule', {}>\n\t// The type of ctx.exports, which contains loopback bindings for all top-level exports.\n\ttype Exports = {\n\t\t[K in keyof MainModule]: LoopbackForExport<MainModule[K]> &\n\t\t\t// If the export is listed in `durableNamespaces`, then it is also a\n\t\t\t// DurableObjectNamespace.\n\t\t\t(K extends GlobalProp<'durableNamespaces', never>\n\t\t\t\t? MainModule[K] extends new (...args: any[]) => infer DoInstance\n\t\t\t\t\t? DoInstance extends Rpc.DurableObjectBranded\n\t\t\t\t\t\t? DurableObjectNamespace<DoInstance>\n\t\t\t\t\t\t: DurableObjectNamespace<undefined>\n\t\t\t\t\t: DurableObjectNamespace<undefined>\n\t\t\t\t: {})\n\t}\n}\ndeclare namespace CloudflareWorkersModule {\n\texport type RpcStub<T extends Rpc.Stubable> = Rpc.Stub<T>\n\texport const RpcStub: {\n\t\tnew <T extends Rpc.Stubable>(value: T): Rpc.Stub<T>\n\t}\n\texport abstract class RpcTarget implements Rpc.RpcTargetBranded {\n\t\t[Rpc.__RPC_TARGET_BRAND]: never\n\t}\n\t// `protected` fields don't appear in `keyof`s, so can't be accessed over RPC\n\texport abstract class WorkerEntrypoint<Env = Cloudflare.Env, Props = {}>\n\t\timplements Rpc.WorkerEntrypointBranded\n\t{\n\t\t[Rpc.__WORKER_ENTRYPOINT_BRAND]: never\n\t\tprotected ctx: ExecutionContext<Props>\n\t\tprotected env: Env\n\t\tconstructor(ctx: ExecutionContext, env: Env)\n\t\temail?(message: ForwardableEmailMessage): void | Promise<void>\n\t\tfetch?(request: Request): Response | Promise<Response>\n\t\tqueue?(batch: MessageBatch<unknown>): void | Promise<void>\n\t\tscheduled?(controller: ScheduledController): void | Promise<void>\n\t\ttail?(events: TraceItem[]): void | Promise<void>\n\t\ttailStream?(\n\t\t\tevent: TailStream.TailEvent<TailStream.Onset>,\n\t\t):\n\t\t\t| TailStream.TailEventHandlerType\n\t\t\t| Promise<TailStream.TailEventHandlerType>\n\t\ttest?(controller: TestController): void | Promise<void>\n\t\ttrace?(traces: TraceItem[]): void | Promise<void>\n\t}\n\texport abstract class DurableObject<Env = Cloudflare.Env, Props = {}>\n\t\timplements Rpc.DurableObjectBranded\n\t{\n\t\t[Rpc.__DURABLE_OBJECT_BRAND]: never\n\t\tprotected ctx: DurableObjectState<Props>\n\t\tprotected env: Env\n\t\tconstructor(ctx: DurableObjectState, env: Env)\n\t\talarm?(alarmInfo?: AlarmInvocationInfo): void | Promise<void>\n\t\tfetch?(request: Request): Response | Promise<Response>\n\t\twebSocketMessage?(\n\t\t\tws: WebSocket,\n\t\t\tmessage: string | ArrayBuffer,\n\t\t): void | Promise<void>\n\t\twebSocketClose?(\n\t\t\tws: WebSocket,\n\t\t\tcode: number,\n\t\t\treason: string,\n\t\t\twasClean: boolean,\n\t\t): void | Promise<void>\n\t\twebSocketError?(ws: WebSocket, error: unknown): void | Promise<void>\n\t}\n\texport type WorkflowDurationLabel =\n\t\t| 'second'\n\t\t| 'minute'\n\t\t| 'hour'\n\t\t| 'day'\n\t\t| 'week'\n\t\t| 'month'\n\t\t| 'year'\n\texport type WorkflowSleepDuration =\n\t\t| `${number} ${WorkflowDurationLabel}${'s' | ''}`\n\t\t| number\n\texport type WorkflowDelayDuration = WorkflowSleepDuration\n\texport type WorkflowTimeoutDuration = WorkflowSleepDuration\n\texport type WorkflowRetentionDuration = WorkflowSleepDuration\n\texport type WorkflowBackoff = 'constant' | 'linear' | 'exponential'\n\texport type WorkflowStepConfig = {\n\t\tretries?: {\n\t\t\tlimit: number\n\t\t\tdelay: WorkflowDelayDuration | number\n\t\t\tbackoff?: WorkflowBackoff\n\t\t}\n\t\ttimeout?: WorkflowTimeoutDuration | number\n\t}\n\texport type WorkflowEvent<T> = {\n\t\tpayload: Readonly<T>\n\t\ttimestamp: Date\n\t\tinstanceId: string\n\t}\n\texport type WorkflowStepEvent<T> = {\n\t\tpayload: Readonly<T>\n\t\ttimestamp: Date\n\t\ttype: string\n\t}\n\texport abstract class WorkflowStep {\n\t\tdo<T extends Rpc.Serializable<T>>(\n\t\t\tname: string,\n\t\t\tcallback: () => Promise<T>,\n\t\t): Promise<T>\n\t\tdo<T extends Rpc.Serializable<T>>(\n\t\t\tname: string,\n\t\t\tconfig: WorkflowStepConfig,\n\t\t\tcallback: () => Promise<T>,\n\t\t): Promise<T>\n\t\tsleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>\n\t\tsleepUntil: (name: string, timestamp: Date | number) => Promise<void>\n\t\twaitForEvent<T extends Rpc.Serializable<T>>(\n\t\t\tname: string,\n\t\t\toptions: {\n\t\t\t\ttype: string\n\t\t\t\ttimeout?: WorkflowTimeoutDuration | number\n\t\t\t},\n\t\t): Promise<WorkflowStepEvent<T>>\n\t}\n\texport type WorkflowInstanceStatus =\n\t\t| 'queued'\n\t\t| 'running'\n\t\t| 'paused'\n\t\t| 'errored'\n\t\t| 'terminated'\n\t\t| 'complete'\n\t\t| 'waiting'\n\t\t| 'waitingForPause'\n\t\t| 'unknown'\n\texport abstract class WorkflowEntrypoint<\n\t\tEnv = unknown,\n\t\tT extends Rpc.Serializable<T> | unknown = unknown,\n\t>\n\t\timplements Rpc.WorkflowEntrypointBranded\n\t{\n\t\t[Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never\n\t\tprotected ctx: ExecutionContext\n\t\tprotected env: Env\n\t\tconstructor(ctx: ExecutionContext, env: Env)\n\t\trun(event: Readonly<WorkflowEvent<T>>, step: WorkflowStep): Promise<unknown>\n\t}\n\texport function waitUntil(promise: Promise<unknown>): void\n\texport function withEnv(newEnv: unknown, fn: () => unknown): unknown\n\texport function withExports(newExports: unknown, fn: () => unknown): unknown\n\texport function withEnvAndExports(\n\t\tnewEnv: unknown,\n\t\tnewExports: unknown,\n\t\tfn: () => unknown,\n\t): unknown\n\texport const env: Cloudflare.Env\n\texport const exports: Cloudflare.Exports\n}\ndeclare module 'cloudflare:workers' {\n\texport = CloudflareWorkersModule\n}\ninterface SecretsStoreSecret {\n\t/**\n\t * Get a secret from the Secrets Store, returning a string of the secret value\n\t * if it exists, or throws an error if it does not exist\n\t */\n\tget(): Promise<string>\n}\ndeclare module 'cloudflare:sockets' {\n\tfunction _connect(\n\t\taddress: string | SocketAddress,\n\t\toptions?: SocketOptions,\n\t): Socket\n\texport { _connect as connect }\n}\ntype MarkdownDocument = {\n\tname: string\n\tblob: Blob\n}\ntype ConversionResponse =\n\t| {\n\t\t\tid: string\n\t\t\tname: string\n\t\t\tmimeType: string\n\t\t\tformat: 'markdown'\n\t\t\ttokens: number\n\t\t\tdata: string\n\t  }\n\t| {\n\t\t\tid: string\n\t\t\tname: string\n\t\t\tmimeType: string\n\t\t\tformat: 'error'\n\t\t\terror: string\n\t  }\ntype ImageConversionOptions = {\n\tdescriptionLanguage?: 'en' | 'es' | 'fr' | 'it' | 'pt' | 'de'\n}\ntype EmbeddedImageConversionOptions = ImageConversionOptions & {\n\tconvert?: boolean\n\tmaxConvertedImages?: number\n}\ntype ConversionOptions = {\n\thtml?: {\n\t\timages?: EmbeddedImageConversionOptions & {\n\t\t\tconvertOGImage?: boolean\n\t\t}\n\t\thostname?: string\n\t}\n\tdocx?: {\n\t\timages?: EmbeddedImageConversionOptions\n\t}\n\timage?: ImageConversionOptions\n\tpdf?: {\n\t\timages?: EmbeddedImageConversionOptions\n\t\tmetadata?: boolean\n\t}\n}\ntype ConversionRequestOptions = {\n\tgateway?: GatewayOptions\n\textraHeaders?: object\n\tconversionOptions?: ConversionOptions\n}\ntype SupportedFileFormat = {\n\tmimeType: string\n\textension: string\n}\ndeclare abstract class ToMarkdownService {\n\ttransform(\n\t\tfiles: MarkdownDocument[],\n\t\toptions?: ConversionRequestOptions,\n\t): Promise<ConversionResponse[]>\n\ttransform(\n\t\tfiles: MarkdownDocument,\n\t\toptions?: ConversionRequestOptions,\n\t): Promise<ConversionResponse>\n\tsupported(): Promise<SupportedFileFormat[]>\n}\ndeclare namespace TailStream {\n\tinterface Header {\n\t\treadonly name: string\n\t\treadonly value: string\n\t}\n\tinterface FetchEventInfo {\n\t\treadonly type: 'fetch'\n\t\treadonly method: string\n\t\treadonly url: string\n\t\treadonly cfJson?: object\n\t\treadonly headers: Header[]\n\t}\n\tinterface JsRpcEventInfo {\n\t\treadonly type: 'jsrpc'\n\t}\n\tinterface ScheduledEventInfo {\n\t\treadonly type: 'scheduled'\n\t\treadonly scheduledTime: Date\n\t\treadonly cron: string\n\t}\n\tinterface AlarmEventInfo {\n\t\treadonly type: 'alarm'\n\t\treadonly scheduledTime: Date\n\t}\n\tinterface QueueEventInfo {\n\t\treadonly type: 'queue'\n\t\treadonly queueName: string\n\t\treadonly batchSize: number\n\t}\n\tinterface EmailEventInfo {\n\t\treadonly type: 'email'\n\t\treadonly mailFrom: string\n\t\treadonly rcptTo: string\n\t\treadonly rawSize: number\n\t}\n\tinterface TraceEventInfo {\n\t\treadonly type: 'trace'\n\t\treadonly traces: (string | null)[]\n\t}\n\tinterface HibernatableWebSocketEventInfoMessage {\n\t\treadonly type: 'message'\n\t}\n\tinterface HibernatableWebSocketEventInfoError {\n\t\treadonly type: 'error'\n\t}\n\tinterface HibernatableWebSocketEventInfoClose {\n\t\treadonly type: 'close'\n\t\treadonly code: number\n\t\treadonly wasClean: boolean\n\t}\n\tinterface HibernatableWebSocketEventInfo {\n\t\treadonly type: 'hibernatableWebSocket'\n\t\treadonly info:\n\t\t\t| HibernatableWebSocketEventInfoClose\n\t\t\t| HibernatableWebSocketEventInfoError\n\t\t\t| HibernatableWebSocketEventInfoMessage\n\t}\n\tinterface CustomEventInfo {\n\t\treadonly type: 'custom'\n\t}\n\tinterface FetchResponseInfo {\n\t\treadonly type: 'fetch'\n\t\treadonly statusCode: number\n\t}\n\ttype EventOutcome =\n\t\t| 'ok'\n\t\t| 'canceled'\n\t\t| 'exception'\n\t\t| 'unknown'\n\t\t| 'killSwitch'\n\t\t| 'daemonDown'\n\t\t| 'exceededCpu'\n\t\t| 'exceededMemory'\n\t\t| 'loadShed'\n\t\t| 'responseStreamDisconnected'\n\t\t| 'scriptNotFound'\n\tinterface ScriptVersion {\n\t\treadonly id: string\n\t\treadonly tag?: string\n\t\treadonly message?: string\n\t}\n\tinterface Onset {\n\t\treadonly type: 'onset'\n\t\treadonly attributes: Attribute[]\n\t\t// id for the span being opened by this Onset event.\n\t\treadonly spanId: string\n\t\treadonly dispatchNamespace?: string\n\t\treadonly entrypoint?: string\n\t\treadonly executionModel: string\n\t\treadonly scriptName?: string\n\t\treadonly scriptTags?: string[]\n\t\treadonly scriptVersion?: ScriptVersion\n\t\treadonly info:\n\t\t\t| FetchEventInfo\n\t\t\t| JsRpcEventInfo\n\t\t\t| ScheduledEventInfo\n\t\t\t| AlarmEventInfo\n\t\t\t| QueueEventInfo\n\t\t\t| EmailEventInfo\n\t\t\t| TraceEventInfo\n\t\t\t| HibernatableWebSocketEventInfo\n\t\t\t| CustomEventInfo\n\t}\n\tinterface Outcome {\n\t\treadonly type: 'outcome'\n\t\treadonly outcome: EventOutcome\n\t\treadonly cpuTime: number\n\t\treadonly wallTime: number\n\t}\n\tinterface SpanOpen {\n\t\treadonly type: 'spanOpen'\n\t\treadonly name: string\n\t\t// id for the span being opened by this SpanOpen event.\n\t\treadonly spanId: string\n\t\treadonly info?: FetchEventInfo | JsRpcEventInfo | Attributes\n\t}\n\tinterface SpanClose {\n\t\treadonly type: 'spanClose'\n\t\treadonly outcome: EventOutcome\n\t}\n\tinterface DiagnosticChannelEvent {\n\t\treadonly type: 'diagnosticChannel'\n\t\treadonly channel: string\n\t\treadonly message: any\n\t}\n\tinterface Exception {\n\t\treadonly type: 'exception'\n\t\treadonly name: string\n\t\treadonly message: string\n\t\treadonly stack?: string\n\t}\n\tinterface Log {\n\t\treadonly type: 'log'\n\t\treadonly level: 'debug' | 'error' | 'info' | 'log' | 'warn'\n\t\treadonly message: object\n\t}\n\tinterface DroppedEventsDiagnostic {\n\t\treadonly diagnosticsType: 'droppedEvents'\n\t\treadonly count: number\n\t}\n\tinterface StreamDiagnostic {\n\t\treadonly type: 'streamDiagnostic'\n\t\t// To add new diagnostic types, define a new interface and add it to this union type.\n\t\treadonly diagnostic: DroppedEventsDiagnostic\n\t}\n\t// This marks the worker handler return information.\n\t// This is separate from Outcome because the worker invocation can live for a long time after\n\t// returning. For example - Websockets that return an http upgrade response but then continue\n\t// streaming information or SSE http connections.\n\tinterface Return {\n\t\treadonly type: 'return'\n\t\treadonly info?: FetchResponseInfo\n\t}\n\tinterface Attribute {\n\t\treadonly name: string\n\t\treadonly value:\n\t\t\t| string\n\t\t\t| string[]\n\t\t\t| boolean\n\t\t\t| boolean[]\n\t\t\t| number\n\t\t\t| number[]\n\t\t\t| bigint\n\t\t\t| bigint[]\n\t}\n\tinterface Attributes {\n\t\treadonly type: 'attributes'\n\t\treadonly info: Attribute[]\n\t}\n\ttype EventType =\n\t\t| Onset\n\t\t| Outcome\n\t\t| SpanOpen\n\t\t| SpanClose\n\t\t| DiagnosticChannelEvent\n\t\t| Exception\n\t\t| Log\n\t\t| StreamDiagnostic\n\t\t| Return\n\t\t| Attributes\n\t// Context in which this trace event lives.\n\tinterface SpanContext {\n\t\t// Single id for the entire top-level invocation\n\t\t// This should be a new traceId for the first worker stage invoked in the eyeball request and then\n\t\t// same-account service-bindings should reuse the same traceId but cross-account service-bindings\n\t\t// should use a new traceId.\n\t\treadonly traceId: string\n\t\t// spanId in which this event is handled\n\t\t// for Onset and SpanOpen events this would be the parent span id\n\t\t// for Outcome and SpanClose these this would be the span id of the opening Onset and SpanOpen events\n\t\t// For Hibernate and Mark this would be the span under which they were emitted.\n\t\t// spanId is not set ONLY if:\n\t\t//  1. This is an Onset event\n\t\t//  2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation)\n\t\treadonly spanId?: string\n\t}\n\tinterface TailEvent<Event extends EventType> {\n\t\t// invocation id of the currently invoked worker stage.\n\t\t// invocation id will always be unique to every Onset event and will be the same until the Outcome event.\n\t\treadonly invocationId: string\n\t\t// Inherited spanContext for this event.\n\t\treadonly spanContext: SpanContext\n\t\treadonly timestamp: Date\n\t\treadonly sequence: number\n\t\treadonly event: Event\n\t}\n\ttype TailEventHandler<Event extends EventType = EventType> = (\n\t\tevent: TailEvent<Event>,\n\t) => void | Promise<void>\n\ttype TailEventHandlerObject = {\n\t\toutcome?: TailEventHandler<Outcome>\n\t\tspanOpen?: TailEventHandler<SpanOpen>\n\t\tspanClose?: TailEventHandler<SpanClose>\n\t\tdiagnosticChannel?: TailEventHandler<DiagnosticChannelEvent>\n\t\texception?: TailEventHandler<Exception>\n\t\tlog?: TailEventHandler<Log>\n\t\treturn?: TailEventHandler<Return>\n\t\tattributes?: TailEventHandler<Attributes>\n\t}\n\ttype TailEventHandlerType = TailEventHandler | TailEventHandlerObject\n}\n// Copyright (c) 2022-2023 Cloudflare, Inc.\n// Licensed under the Apache 2.0 license found in the LICENSE file or at:\n//     https://opensource.org/licenses/Apache-2.0\n/**\n * Data types supported for holding vector metadata.\n */\ntype VectorizeVectorMetadataValue = string | number | boolean | string[]\n/**\n * Additional information to associate with a vector.\n */\ntype VectorizeVectorMetadata =\n\t| VectorizeVectorMetadataValue\n\t| Record<string, VectorizeVectorMetadataValue>\ntype VectorFloatArray = Float32Array | Float64Array\ninterface VectorizeError {\n\tcode?: number\n\terror: string\n}\n/**\n * Comparison logic/operation to use for metadata filtering.\n *\n * This list is expected to grow as support for more operations are released.\n */\ntype VectorizeVectorMetadataFilterOp =\n\t| '$eq'\n\t| '$ne'\n\t| '$lt'\n\t| '$lte'\n\t| '$gt'\n\t| '$gte'\ntype VectorizeVectorMetadataFilterCollectionOp = '$in' | '$nin'\n/**\n * Filter criteria for vector metadata used to limit the retrieved query result set.\n */\ntype VectorizeVectorMetadataFilter = {\n\t[field: string]:\n\t\t| Exclude<VectorizeVectorMetadataValue, string[]>\n\t\t| null\n\t\t| {\n\t\t\t\t[Op in VectorizeVectorMetadataFilterOp]?: Exclude<\n\t\t\t\t\tVectorizeVectorMetadataValue,\n\t\t\t\t\tstring[]\n\t\t\t\t> | null\n\t\t  }\n\t\t| {\n\t\t\t\t[Op in VectorizeVectorMetadataFilterCollectionOp]?: Exclude<\n\t\t\t\t\tVectorizeVectorMetadataValue,\n\t\t\t\t\tstring[]\n\t\t\t\t>[]\n\t\t  }\n}\n/**\n * Supported distance metrics for an index.\n * Distance metrics determine how other \"similar\" vectors are determined.\n */\ntype VectorizeDistanceMetric = 'euclidean' | 'cosine' | 'dot-product'\n/**\n * Metadata return levels for a Vectorize query.\n *\n * Default to \"none\".\n *\n * @property all      Full metadata for the vector return set, including all fields (including those un-indexed) without truncation. This is a more expensive retrieval, as it requires additional fetching & reading of un-indexed data.\n * @property indexed  Return all metadata fields configured for indexing in the vector return set. This level of retrieval is \"free\" in that no additional overhead is incurred returning this data. However, note that indexed metadata is subject to truncation (especially for larger strings).\n * @property none     No indexed metadata will be returned.\n */\ntype VectorizeMetadataRetrievalLevel = 'all' | 'indexed' | 'none'\ninterface VectorizeQueryOptions {\n\ttopK?: number\n\tnamespace?: string\n\treturnValues?: boolean\n\treturnMetadata?: boolean | VectorizeMetadataRetrievalLevel\n\tfilter?: VectorizeVectorMetadataFilter\n}\n/**\n * Information about the configuration of an index.\n */\ntype VectorizeIndexConfig =\n\t| {\n\t\t\tdimensions: number\n\t\t\tmetric: VectorizeDistanceMetric\n\t  }\n\t| {\n\t\t\tpreset: string // keep this generic, as we'll be adding more presets in the future and this is only in a read capacity\n\t  }\n/**\n * Metadata about an existing index.\n *\n * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released.\n * See {@link VectorizeIndexInfo} for its post-beta equivalent.\n */\ninterface VectorizeIndexDetails {\n\t/** The unique ID of the index */\n\treadonly id: string\n\t/** The name of the index. */\n\tname: string\n\t/** (optional) A human readable description for the index. */\n\tdescription?: string\n\t/** The index configuration, including the dimension size and distance metric. */\n\tconfig: VectorizeIndexConfig\n\t/** The number of records containing vectors within the index. */\n\tvectorsCount: number\n}\n/**\n * Metadata about an existing index.\n */\ninterface VectorizeIndexInfo {\n\t/** The number of records containing vectors within the index. */\n\tvectorCount: number\n\t/** Number of dimensions the index has been configured for. */\n\tdimensions: number\n\t/** ISO 8601 datetime of the last processed mutation on in the index. All changes before this mutation will be reflected in the index state. */\n\tprocessedUpToDatetime: number\n\t/** UUIDv4 of the last mutation processed by the index. All changes before this mutation will be reflected in the index state. */\n\tprocessedUpToMutation: number\n}\n/**\n * Represents a single vector value set along with its associated metadata.\n */\ninterface VectorizeVector {\n\t/** The ID for the vector. This can be user-defined, and must be unique. It should uniquely identify the object, and is best set based on the ID of what the vector represents. */\n\tid: string\n\t/** The vector values */\n\tvalues: VectorFloatArray | number[]\n\t/** The namespace this vector belongs to. */\n\tnamespace?: string\n\t/** Metadata associated with the vector. Includes the values of other fields and potentially additional details. */\n\tmetadata?: Record<string, VectorizeVectorMetadata>\n}\n/**\n * Represents a matched vector for a query along with its score and (if specified) the matching vector information.\n */\ntype VectorizeMatch = Pick<Partial<VectorizeVector>, 'values'> &\n\tOmit<VectorizeVector, 'values'> & {\n\t\t/** The score or rank for similarity, when returned as a result */\n\t\tscore: number\n\t}\n/**\n * A set of matching {@link VectorizeMatch} for a particular query.\n */\ninterface VectorizeMatches {\n\tmatches: VectorizeMatch[]\n\tcount: number\n}\n/**\n * Results of an operation that performed a mutation on a set of vectors.\n * Here, `ids` is a list of vectors that were successfully processed.\n *\n * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released.\n * See {@link VectorizeAsyncMutation} for its post-beta equivalent.\n */\ninterface VectorizeVectorMutation {\n\t/* List of ids of vectors that were successfully processed. */\n\tids: string[]\n\t/* Total count of the number of processed vectors. */\n\tcount: number\n}\n/**\n * Result type indicating a mutation on the Vectorize Index.\n * Actual mutations are processed async where the `mutationId` is the unique identifier for the operation.\n */\ninterface VectorizeAsyncMutation {\n\t/** The unique identifier for the async mutation operation containing the changeset. */\n\tmutationId: string\n}\n/**\n * A Vectorize Vector Search Index for querying vectors/embeddings.\n *\n * This type is exclusively for the Vectorize **beta** and will be deprecated once Vectorize RC is released.\n * See {@link Vectorize} for its new implementation.\n */\ndeclare abstract class VectorizeIndex {\n\t/**\n\t * Get information about the currently bound index.\n\t * @returns A promise that resolves with information about the current index.\n\t */\n\tpublic describe(): Promise<VectorizeIndexDetails>\n\t/**\n\t * Use the provided vector to perform a similarity search across the index.\n\t * @param vector Input vector that will be used to drive the similarity search.\n\t * @param options Configuration options to massage the returned data.\n\t * @returns A promise that resolves with matched and scored vectors.\n\t */\n\tpublic query(\n\t\tvector: VectorFloatArray | number[],\n\t\toptions?: VectorizeQueryOptions,\n\t): Promise<VectorizeMatches>\n\t/**\n\t * Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown.\n\t * @param vectors List of vectors that will be inserted.\n\t * @returns A promise that resolves with the ids & count of records that were successfully processed.\n\t */\n\tpublic insert(vectors: VectorizeVector[]): Promise<VectorizeVectorMutation>\n\t/**\n\t * Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values.\n\t * @param vectors List of vectors that will be upserted.\n\t * @returns A promise that resolves with the ids & count of records that were successfully processed.\n\t */\n\tpublic upsert(vectors: VectorizeVector[]): Promise<VectorizeVectorMutation>\n\t/**\n\t * Delete a list of vectors with a matching id.\n\t * @param ids List of vector ids that should be deleted.\n\t * @returns A promise that resolves with the ids & count of records that were successfully processed (and thus deleted).\n\t */\n\tpublic deleteByIds(ids: string[]): Promise<VectorizeVectorMutation>\n\t/**\n\t * Get a list of vectors with a matching id.\n\t * @param ids List of vector ids that should be returned.\n\t * @returns A promise that resolves with the raw unscored vectors matching the id set.\n\t */\n\tpublic getByIds(ids: string[]): Promise<VectorizeVector[]>\n}\n/**\n * A Vectorize Vector Search Index for querying vectors/embeddings.\n *\n * Mutations in this version are async, returning a mutation id.\n */\ndeclare abstract class Vectorize {\n\t/**\n\t * Get information about the currently bound index.\n\t * @returns A promise that resolves with information about the current index.\n\t */\n\tpublic describe(): Promise<VectorizeIndexInfo>\n\t/**\n\t * Use the provided vector to perform a similarity search across the index.\n\t * @param vector Input vector that will be used to drive the similarity search.\n\t * @param options Configuration options to massage the returned data.\n\t * @returns A promise that resolves with matched and scored vectors.\n\t */\n\tpublic query(\n\t\tvector: VectorFloatArray | number[],\n\t\toptions?: VectorizeQueryOptions,\n\t): Promise<VectorizeMatches>\n\t/**\n\t * Use the provided vector-id to perform a similarity search across the index.\n\t * @param vectorId Id for a vector in the index against which the index should be queried.\n\t * @param options Configuration options to massage the returned data.\n\t * @returns A promise that resolves with matched and scored vectors.\n\t */\n\tpublic queryById(\n\t\tvectorId: string,\n\t\toptions?: VectorizeQueryOptions,\n\t): Promise<VectorizeMatches>\n\t/**\n\t * Insert a list of vectors into the index dataset. If a provided id exists, an error will be thrown.\n\t * @param vectors List of vectors that will be inserted.\n\t * @returns A promise that resolves with a unique identifier of a mutation containing the insert changeset.\n\t */\n\tpublic insert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>\n\t/**\n\t * Upsert a list of vectors into the index dataset. If a provided id exists, it will be replaced with the new values.\n\t * @param vectors List of vectors that will be upserted.\n\t * @returns A promise that resolves with a unique identifier of a mutation containing the upsert changeset.\n\t */\n\tpublic upsert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>\n\t/**\n\t * Delete a list of vectors with a matching id.\n\t * @param ids List of vector ids that should be deleted.\n\t * @returns A promise that resolves with a unique identifier of a mutation containing the delete changeset.\n\t */\n\tpublic deleteByIds(ids: string[]): Promise<VectorizeAsyncMutation>\n\t/**\n\t * Get a list of vectors with a matching id.\n\t * @param ids List of vector ids that should be returned.\n\t * @returns A promise that resolves with the raw unscored vectors matching the id set.\n\t */\n\tpublic getByIds(ids: string[]): Promise<VectorizeVector[]>\n}\n/**\n * The interface for \"version_metadata\" binding\n * providing metadata about the Worker Version using this binding.\n */\ntype WorkerVersionMetadata = {\n\t/** The ID of the Worker Version using this binding */\n\tid: string\n\t/** The tag of the Worker Version using this binding */\n\ttag: string\n\t/** The timestamp of when the Worker Version was uploaded */\n\ttimestamp: string\n}\ninterface DynamicDispatchLimits {\n\t/**\n\t * Limit CPU time in milliseconds.\n\t */\n\tcpuMs?: number\n\t/**\n\t * Limit number of subrequests.\n\t */\n\tsubRequests?: number\n}\ninterface DynamicDispatchOptions {\n\t/**\n\t * Limit resources of invoked Worker script.\n\t */\n\tlimits?: DynamicDispatchLimits\n\t/**\n\t * Arguments for outbound Worker script, if configured.\n\t */\n\toutbound?: {\n\t\t[key: string]: any\n\t}\n}\ninterface DispatchNamespace {\n\t/**\n\t * @param name Name of the Worker script.\n\t * @param args Arguments to Worker script.\n\t * @param options Options for Dynamic Dispatch invocation.\n\t * @returns A Fetcher object that allows you to send requests to the Worker script.\n\t * @throws If the Worker script does not exist in this dispatch namespace, an error will be thrown.\n\t */\n\tget(\n\t\tname: string,\n\t\targs?: {\n\t\t\t[key: string]: any\n\t\t},\n\t\toptions?: DynamicDispatchOptions,\n\t): Fetcher\n}\ndeclare module 'cloudflare:workflows' {\n\t/**\n\t * NonRetryableError allows for a user to throw a fatal error\n\t * that makes a Workflow instance fail immediately without triggering a retry\n\t */\n\texport class NonRetryableError extends Error {\n\t\tpublic constructor(message: string, name?: string)\n\t}\n}\ndeclare abstract class Workflow<PARAMS = unknown> {\n\t/**\n\t * Get a handle to an existing instance of the Workflow.\n\t * @param id Id for the instance of this Workflow\n\t * @returns A promise that resolves with a handle for the Instance\n\t */\n\tpublic get(id: string): Promise<WorkflowInstance>\n\t/**\n\t * Create a new instance and return a handle to it. If a provided id exists, an error will be thrown.\n\t * @param options Options when creating an instance including id and params\n\t * @returns A promise that resolves with a handle for the Instance\n\t */\n\tpublic create(\n\t\toptions?: WorkflowInstanceCreateOptions<PARAMS>,\n\t): Promise<WorkflowInstance>\n\t/**\n\t * Create a batch of instances and return handle for all of them. If a provided id exists, an error will be thrown.\n\t * `createBatch` is limited at 100 instances at a time or when the RPC limit for the batch (1MiB) is reached.\n\t * @param batch List of Options when creating an instance including name and params\n\t * @returns A promise that resolves with a list of handles for the created instances.\n\t */\n\tpublic createBatch(\n\t\tbatch: WorkflowInstanceCreateOptions<PARAMS>[],\n\t): Promise<WorkflowInstance[]>\n}\ntype WorkflowDurationLabel =\n\t| 'second'\n\t| 'minute'\n\t| 'hour'\n\t| 'day'\n\t| 'week'\n\t| 'month'\n\t| 'year'\ntype WorkflowSleepDuration =\n\t| `${number} ${WorkflowDurationLabel}${'s' | ''}`\n\t| number\ntype WorkflowRetentionDuration = WorkflowSleepDuration\ninterface WorkflowInstanceCreateOptions<PARAMS = unknown> {\n\t/**\n\t * An id for your Workflow instance. Must be unique within the Workflow.\n\t */\n\tid?: string\n\t/**\n\t * The event payload the Workflow instance is triggered with\n\t */\n\tparams?: PARAMS\n\t/**\n\t * The retention policy for Workflow instance.\n\t * Defaults to the maximum retention period available for the owner's account.\n\t */\n\tretention?: {\n\t\tsuccessRetention?: WorkflowRetentionDuration\n\t\terrorRetention?: WorkflowRetentionDuration\n\t}\n}\ntype InstanceStatus = {\n\tstatus:\n\t\t| 'queued' // means that instance is waiting to be started (see concurrency limits)\n\t\t| 'running'\n\t\t| 'paused'\n\t\t| 'errored'\n\t\t| 'terminated' // user terminated the instance while it was running\n\t\t| 'complete'\n\t\t| 'waiting' // instance is hibernating and waiting for sleep or event to finish\n\t\t| 'waitingForPause' // instance is finishing the current work to pause\n\t\t| 'unknown'\n\terror?: {\n\t\tname: string\n\t\tmessage: string\n\t}\n\toutput?: unknown\n}\ninterface WorkflowError {\n\tcode?: number\n\tmessage: string\n}\ndeclare abstract class WorkflowInstance {\n\tpublic id: string\n\t/**\n\t * Pause the instance.\n\t */\n\tpublic pause(): Promise<void>\n\t/**\n\t * Resume the instance. If it is already running, an error will be thrown.\n\t */\n\tpublic resume(): Promise<void>\n\t/**\n\t * Terminate the instance. If it is errored, terminated or complete, an error will be thrown.\n\t */\n\tpublic terminate(): Promise<void>\n\t/**\n\t * Restart the instance.\n\t */\n\tpublic restart(): Promise<void>\n\t/**\n\t * Returns the current status of the instance.\n\t */\n\tpublic status(): Promise<InstanceStatus>\n\t/**\n\t * Send an event to this instance.\n\t */\n\tpublic sendEvent({\n\t\ttype,\n\t\tpayload,\n\t}: {\n\t\ttype: string\n\t\tpayload: unknown\n\t}): Promise<void>\n}\n"
  },
  {
    "path": "apps/backend/wrangler.toml",
    "content": "# JWT_SECRET 和 DATABASE_URL 通过 `wrangler secret put` 设置\nname = \"bbplayer-backend\"\nmain = \"src/index.ts\"\ncompatibility_date = \"2025-02-01\"\ncompatibility_flags = [\"nodejs_compat\"]\nroutes = [{ pattern = \"be.bbplayer.roitium.com\", custom_domain = true }]\n\n[[kv_namespaces]]\nbinding = \"KV\"\nid = \"bf12576248b9475f99b8465d8b962e65\"\n\n[dev]\nlocal_protocol = \"http\"\n\n[observability]\n[observability.logs]\nenabled = true\ninvocation_logs = true\n"
  },
  {
    "path": "apps/docs/.gitignore",
    "content": ".vitepress/cache\ndocs/.vitepress/cache\nnode_modules\ndist\ndocs/.vitepress/dist"
  },
  {
    "path": "apps/docs/README.md",
    "content": "# @bbplayer/docs\n\nBBPlayer 官方文档站源代码。\n\n## 简介\n\n此目录包含基于 VitePress 构建的 BBPlayer 文档站点。它负责向用户提供安装、使用、歌词配置等全方位的指南。\n\n## 文档内容\n\n- **站点源码**: 位于 `docs/` 目录。\n- **公共资源**: 位于 `docs/public/` 目录。\n\n## 本地开发\n\n```bash\n# 安装依赖\npnpm install\n\n# 启动开发服务器\npnpm docs:dev\n```\n"
  },
  {
    "path": "apps/docs/docs/.vitepress/components/AppNotExistPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\nimport { Download, Github, RefreshCw } from 'lucide-vue-next'\n\nconst githubUrl = 'https://github.com/bbplayer-app/bbplayer/releases'\n\n// 从 query 参数里获取原始跳转目标，转换为 bbplayer:// scheme\nconst schemeUrl = ref('')\n\nonMounted(() => {\n\tconst params = new URLSearchParams(window.location.search)\n\tconst from = params.get('from')\n\tif (!from) return\n\n\ttry {\n\t\t// 支持 app.bbplayer.roitium.com/app/link-to/<path?query>\n\t\t// 也兼容其他形式，只要包含 /link-to/\n\t\tconst linkToMarker = '/link-to/'\n\t\tconst idx = from.indexOf(linkToMarker)\n\t\tif (idx !== -1) {\n\t\t\t// 取 /link-to/ 后面的部分：path + query\n\t\t\tconst rest = from.slice(idx + linkToMarker.length)\n\t\t\tschemeUrl.value = `bbplayer://${rest}`\n\t\t}\n\t} catch {\n\t\t// 无法解析则静默失败，不显示重试按钮\n\t}\n})\n</script>\n\n<template>\n\t<div class=\"page\">\n\t\t<div class=\"card center-card\">\n\t\t\t<div class=\"icon-wrapper\">\n\t\t\t\t<Download\n\t\t\t\t\t:size=\"52\"\n\t\t\t\t\tclass=\"main-icon\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<h1 class=\"page-title\">未检测到 BBPlayer</h1>\n\t\t\t<p class=\"page-desc\">\n\t\t\t\t<template v-if=\"schemeUrl\">\n\t\t\t\t\t应用未能打开，可能是 BBPlayer 还未安装。<br />\n\t\t\t\t\t点击「重试打开」再试一次，或前往 GitHub 下载安装。\n\t\t\t\t</template>\n\t\t\t\t<template v-else>\n\t\t\t\t\t似乎您还没有安装 BBPlayer，或者跳转失败了。<br />\n\t\t\t\t\t请前往 GitHub 下载最新版本。\n\t\t\t\t</template>\n\t\t\t</p>\n\t\t\t<div class=\"button-group\">\n\t\t\t\t<!-- 回退方案：有 from 参数时显示重试按钮 -->\n\t\t\t\t<a\n\t\t\t\t\tv-if=\"schemeUrl\"\n\t\t\t\t\t:href=\"schemeUrl\"\n\t\t\t\t\tclass=\"btn btn-primary\"\n\t\t\t\t>\n\t\t\t\t\t<RefreshCw\n\t\t\t\t\t\tclass=\"btn-icon\"\n\t\t\t\t\t\t:size=\"16\"\n\t\t\t\t\t/>\n\t\t\t\t\t重试打开\n\t\t\t\t</a>\n\t\t\t\t<a\n\t\t\t\t\t:href=\"githubUrl\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t:class=\"schemeUrl ? 'btn btn-secondary' : 'btn btn-primary'\"\n\t\t\t\t>\n\t\t\t\t\t<Github\n\t\t\t\t\t\tclass=\"btn-icon\"\n\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t/>\n\t\t\t\t\t前往 GitHub 下载\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"footer\">\n\t\t\t<a\n\t\t\t\thref=\"https://bbplayer.roitium.com\"\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\tclass=\"footer-link\"\n\t\t\t\t>来自 BBPlayer | 由 Roitium ❤️ 构建</a\n\t\t\t>\n\t\t</div>\n\t</div>\n</template>\n\n<style scoped>\n@import './shared-page.css';\n\n/* ── Center card ───────────────────────────────────────────────────────── */\n.center-card {\n\tmax-width: 420px;\n\tpadding: 48px 40px 0;\n\ttext-align: center;\n\talign-items: center;\n}\n\n.icon-wrapper {\n\tmargin-bottom: 28px;\n\tcolor: var(--text-1);\n\topacity: 0.85;\n}\n\n.page-title {\n\tfont-size: 1.6rem;\n\tfont-weight: 700;\n\tcolor: var(--text-1);\n\tmargin: 0 0 12px;\n\tline-height: 1.3;\n}\n\n.page-desc {\n\tfont-size: 1rem;\n\tcolor: var(--text-2);\n\tline-height: 1.65;\n\tmargin: 0 0 28px;\n}\n\n/* button-group: no border-top for simple center card */\n.center-card .button-group {\n\twidth: 100%;\n\tborder-top: none;\n\tpadding-top: 0;\n\tpadding-bottom: 40px;\n}\n\n/* ── Responsive ────────────────────────────────────────────────────────── */\n@media (max-width: 480px) {\n\t.center-card {\n\t\tpadding: 40px 28px 0;\n\t}\n\n\t.page-title {\n\t\tfont-size: 1.35rem;\n\t}\n}\n</style>\n"
  },
  {
    "path": "apps/docs/docs/.vitepress/components/SharePlaylistPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted, computed } from 'vue'\nimport {\n\tListMusic,\n\tPlay,\n\tAlertCircle,\n\tExternalLink,\n\tUser,\n\tMusic2,\n\tUsers,\n\tShare2,\n} from 'lucide-vue-next'\n\n// ── State ──────────────────────────────────────────────────────────────────\nconst shareId = ref('')\nconst inviteCode = ref('')\nconst isOnInAppBrowser = ref(false)\n\ninterface Track {\n\tunique_key: string\n\ttitle: string\n\tartist_name?: string\n\tcover_url?: string\n\tbilibili_bvid: string\n}\n\ninterface Playlist {\n\tid: string\n\ttitle: string\n\tdescription?: string | null\n\tcover_url?: string | null\n\ttrack_count: number\n}\n\ninterface Owner {\n\tmid: number\n\tname: string\n\tavatar_url?: string | null\n}\n\ninterface PreviewData {\n\tplaylist: Playlist\n\towner: Owner | null\n\ttracks: Track[]\n\tpreview_limit: number\n}\n\nconst data = ref<PreviewData | null>(null)\nconst loading = ref(true)\nconst error = ref('')\n\nconst BACKEND_URL = 'https://be.bbplayer.roitium.com'\n\n// ── Lifecycle ──────────────────────────────────────────────────────────────\nonMounted(async () => {\n\tconst ua = navigator.userAgent\n\tisOnInAppBrowser.value =\n\t\t/MicroMessenger|QQ\\/|Weibo|AlipayClient|DingTalk|ZhihuHybrid|BaiduBoxApp/i.test(\n\t\t\tua,\n\t\t)\n\n\tconst params = new URLSearchParams(window.location.search)\n\tshareId.value = params.get('shareId') || ''\n\tinviteCode.value = params.get('inviteCode') || ''\n\n\tif (!shareId.value) {\n\t\terror.value = '无效的分享链接'\n\t\tloading.value = false\n\t\treturn\n\t}\n\n\ttry {\n\t\tconst resp = await fetch(\n\t\t\t`${BACKEND_URL}/playlists/${encodeURIComponent(shareId.value)}/preview`,\n\t\t)\n\t\tif (resp.status === 404) {\n\t\t\terror.value = '歌单不存在或已删除'\n\t\t} else if (!resp.ok) {\n\t\t\terror.value = `加载失败（${resp.status}）`\n\t\t} else {\n\t\t\tdata.value = await resp.json()\n\t\t}\n\t} catch {\n\t\terror.value = '网络错误，请稍后重试'\n\t} finally {\n\t\tloading.value = false\n\t}\n})\n\n// ── Computed ───────────────────────────────────────────────────────────────\nconst isInvite = computed(() => !!inviteCode.value)\n\nconst bannerText = computed(() => {\n\tif (!data.value?.owner)\n\t\treturn isInvite.value ? '邀请你共同编辑歌单' : '分享了一个歌单给你'\n\tconst name = data.value.owner.name\n\treturn isInvite.value\n\t\t? `${name} 邀请你共同编辑歌单`\n\t\t: `${name} 分享了一个歌单给你`\n})\n\nconst bbplayerAppLink = computed(() => {\n\tif (!shareId.value) return ''\n\tconst params = new URLSearchParams({ shareId: shareId.value })\n\tif (inviteCode.value) params.set('inviteCode', inviteCode.value)\n\treturn `https://app.bbplayer.roitium.com/app/link-to/share/playlist?${params.toString()}`\n})\n</script>\n\n<template>\n\t<div class=\"page\">\n\t\t<!-- In-app browser overlay -->\n\t\t<div\n\t\t\tv-if=\"isOnInAppBrowser\"\n\t\t\tclass=\"browser-overlay\"\n\t\t>\n\t\t\t<div class=\"overlay-content\">\n\t\t\t\t<ExternalLink\n\t\t\t\t\t:size=\"40\"\n\t\t\t\t\tclass=\"overlay-icon\"\n\t\t\t\t/>\n\t\t\t\t<h3 class=\"overlay-title\">请在浏览器打开</h3>\n\t\t\t\t<p class=\"overlay-desc\">点击右上角菜单，选择在浏览器中打开以继续</p>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<!-- Loading skeleton -->\n\t\t<div\n\t\t\tv-if=\"loading\"\n\t\t\tclass=\"card\"\n\t\t>\n\t\t\t<div class=\"skeleton cover-skeleton\" />\n\t\t\t<div class=\"skeleton title-skeleton\" />\n\t\t\t<div class=\"skeleton subtitle-skeleton\" />\n\t\t\t<div class=\"skeleton btn-skeleton\" />\n\t\t</div>\n\n\t\t<!-- Error state -->\n\t\t<div\n\t\t\tv-else-if=\"error\"\n\t\t\tclass=\"card center-card\"\n\t\t>\n\t\t\t<AlertCircle\n\t\t\t\t:size=\"56\"\n\t\t\t\tclass=\"error-icon\"\n\t\t\t/>\n\t\t\t<h2 class=\"error-title\">{{ error }}</h2>\n\t\t\t<p class=\"error-desc\">请检查分享链接是否正确</p>\n\t\t</div>\n\n\t\t<!-- Preview card -->\n\t\t<div\n\t\t\tv-else-if=\"data\"\n\t\t\tclass=\"card preview-card\"\n\t\t>\n\t\t\t<!-- Banner -->\n\t\t\t<div\n\t\t\t\tclass=\"banner\"\n\t\t\t\t:class=\"isInvite ? 'banner-invite' : 'banner-share'\"\n\t\t\t>\n\t\t\t\t<component\n\t\t\t\t\t:is=\"isInvite ? Users : Share2\"\n\t\t\t\t\t:size=\"14\"\n\t\t\t\t\tclass=\"banner-icon\"\n\t\t\t\t/>\n\t\t\t\t<span>{{ bannerText }}</span>\n\t\t\t</div>\n\n\t\t\t<!-- Scrollable body -->\n\t\t\t<div class=\"card-body\">\n\t\t\t\t<!-- Header: cover + meta side by side -->\n\t\t\t\t<div class=\"header-row\">\n\t\t\t\t\t<div class=\"cover-wrapper\">\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tv-if=\"data.playlist.cover_url\"\n\t\t\t\t\t\t\t:src=\"data.playlist.cover_url\"\n\t\t\t\t\t\t\t:alt=\"data.playlist.title\"\n\t\t\t\t\t\t\tclass=\"cover-image\"\n\t\t\t\t\t\t\treferrerpolicy=\"no-referrer\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tv-else\n\t\t\t\t\t\t\tclass=\"cover-placeholder\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ListMusic :size=\"40\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div class=\"meta\">\n\t\t\t\t\t\t<h1 class=\"playlist-title\">\n\t\t\t\t\t\t\t{{ data.playlist.title || '未命名歌单' }}\n\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t<p\n\t\t\t\t\t\t\tv-if=\"data.playlist.description\"\n\t\t\t\t\t\t\tclass=\"playlist-desc\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{{ data.playlist.description }}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tv-if=\"data.owner\"\n\t\t\t\t\t\t\tclass=\"owner-row\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\tv-if=\"data.owner.avatar_url\"\n\t\t\t\t\t\t\t\t:src=\"data.owner.avatar_url\"\n\t\t\t\t\t\t\t\tclass=\"owner-avatar\"\n\t\t\t\t\t\t\t\treferrerpolicy=\"no-referrer\"\n\t\t\t\t\t\t\t\t:alt=\"data.owner.name\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tv-else\n\t\t\t\t\t\t\t\tclass=\"owner-avatar-placeholder\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<User :size=\"12\" />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span class=\"owner-name\">{{ data.owner.name }}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<span class=\"track-count\"\n\t\t\t\t\t\t\t>{{ data.playlist.track_count }} 首曲目</span\n\t\t\t\t\t\t>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<!-- Track list -->\n\t\t\t\t<ul\n\t\t\t\t\tv-if=\"data.tracks.length\"\n\t\t\t\t\tclass=\"track-list\"\n\t\t\t\t>\n\t\t\t\t\t<li\n\t\t\t\t\t\tv-for=\"(track, index) in data.tracks\"\n\t\t\t\t\t\t:key=\"track.unique_key\"\n\t\t\t\t\t\tclass=\"track-item\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span class=\"track-index\">{{ index + 1 }}</span>\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tv-if=\"track.cover_url\"\n\t\t\t\t\t\t\t:src=\"track.cover_url\"\n\t\t\t\t\t\t\tclass=\"track-cover\"\n\t\t\t\t\t\t\treferrerpolicy=\"no-referrer\"\n\t\t\t\t\t\t\t:alt=\"track.title\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tv-else\n\t\t\t\t\t\t\tclass=\"track-cover-placeholder\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Music2 :size=\"13\" />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"track-info\">\n\t\t\t\t\t\t\t<span class=\"track-title\">{{ track.title }}</span>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tv-if=\"track.artist_name\"\n\t\t\t\t\t\t\t\tclass=\"track-artist\"\n\t\t\t\t\t\t\t\t>{{ track.artist_name }}</span\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\n\t\t\t\t<p\n\t\t\t\t\tv-if=\"data.tracks.length < data.playlist.track_count\"\n\t\t\t\t\tclass=\"more-hint\"\n\t\t\t\t>\n\t\t\t\t\t仅预览前 {{ data.preview_limit }} 首 · 订阅后自动同步全部曲目\n\t\t\t\t</p>\n\t\t\t</div>\n\n\t\t\t<!-- Action buttons (pinned to bottom of card) -->\n\t\t\t<div class=\"button-group\">\n\t\t\t\t<a\n\t\t\t\t\t:href=\"bbplayerAppLink\"\n\t\t\t\t\tclass=\"btn btn-primary\"\n\t\t\t\t>\n\t\t\t\t\t<Play\n\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\tclass=\"btn-icon\"\n\t\t\t\t\t/>\n\t\t\t\t\t{{ isInvite ? '接受邀请，在 BBPlayer 中打开' : '在 BBPlayer 中订阅' }}\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<!-- Footer -->\n\t\t<div class=\"footer\">\n\t\t\t<a\n\t\t\t\thref=\"https://bbplayer.roitium.com\"\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\tclass=\"footer-link\"\n\t\t\t>\n\t\t\t\t来自 BBPlayer | 由 Roitium ❤️ 构建\n\t\t\t</a>\n\t\t</div>\n\t</div>\n</template>\n\n<style scoped>\n/* ── Design tokens ─────────────────────────────────────────────────────── */\n.page {\n\t--bg: #dde1e7;\n\t--card-bg: #ffffff;\n\t--text-1: #0f172a;\n\t--text-2: #64748b;\n\t--text-3: #94a3b8;\n\t--primary: #0f172a;\n\t--primary-fg: #ffffff;\n\t--secondary-bg: #f1f5f9;\n\t--secondary-fg: #334155;\n\t--border: rgba(0, 0, 0, 0.08);\n\t--track-hover: #f8fafc;\n\t--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n\t.page {\n\t\t--bg: #0a0a0f;\n\t\t--card-bg: #16161e;\n\t\t--text-1: #f1f5f9;\n\t\t--text-2: #94a3b8;\n\t\t--text-3: #475569;\n\t\t--primary: #f1f5f9;\n\t\t--primary-fg: #0f172a;\n\t\t--secondary-bg: #1e1e2a;\n\t\t--secondary-fg: #cbd5e1;\n\t\t--border: rgba(255, 255, 255, 0.08);\n\t\t--track-hover: #1e1e2a;\n\t\t--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 1px 4px rgba(0, 0, 0, 0.3);\n\t}\n}\n\n/* ── Layout: locked to viewport, no page scroll ────────────────────────── */\n.page {\n\theight: 100dvh;\n\toverflow: hidden;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tjustify-content: center;\n\tpadding: 20px 16px 12px;\n\tbackground-color: var(--bg);\n\tfont-family:\n\t\t-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',\n\t\tArial, sans-serif;\n\tcolor: var(--text-1);\n\tbox-sizing: border-box;\n}\n\n/* ── Card ──────────────────────────────────────────────────────────────── */\n.card {\n\tbackground: var(--card-bg);\n\tborder-radius: 20px;\n\tmax-width: 480px;\n\twidth: 100%;\n\tborder: 1px solid var(--border);\n\tbox-shadow: var(--card-shadow);\n\tanimation: fadeUp 0.45s ease-out both;\n\t/* flex column so card-body can grow and buttons stay at bottom */\n\tdisplay: flex;\n\tflex-direction: column;\n\t/* card must not exceed viewport */\n\tmax-height: calc(100dvh - 80px);\n\toverflow: hidden;\n}\n\n/* skeleton / error cards don't need inner flex scroll */\n.center-card {\n\tpadding: 48px 40px;\n\ttext-align: center;\n\talign-items: center;\n}\n\n@keyframes fadeUp {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(10px);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: translateY(0);\n\t}\n}\n\n/* ── Banner ────────────────────────────────────────────────────────────── */\n.banner {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 10px 18px;\n\tfont-size: 0.8rem;\n\tfont-weight: 500;\n\tborder-bottom: 1px solid var(--border);\n\tflex-shrink: 0;\n}\n\n.banner-share {\n\tbackground: color-mix(in srgb, #3b82f6 8%, transparent);\n\tcolor: #3b82f6;\n}\n\n.banner-invite {\n\tbackground: color-mix(in srgb, #8b5cf6 8%, transparent);\n\tcolor: #8b5cf6;\n}\n\n@media (prefers-color-scheme: dark) {\n\t.banner-share {\n\t\tcolor: #93c5fd;\n\t\tbackground: color-mix(in srgb, #3b82f6 12%, transparent);\n\t}\n\t.banner-invite {\n\t\tcolor: #c4b5fd;\n\t\tbackground: color-mix(in srgb, #8b5cf6 12%, transparent);\n\t}\n}\n\n.banner-icon {\n\tflex-shrink: 0;\n}\n\n/* ── Scrollable body ───────────────────────────────────────────────────── */\n.card-body {\n\tflex: 1;\n\tmin-height: 0;\n\toverflow-y: auto;\n\tpadding: 20px 20px 0;\n\tscrollbar-width: thin;\n\tscrollbar-color: var(--border) transparent;\n}\n\n/* ── Header row: cover + meta ──────────────────────────────────────────── */\n.header-row {\n\tdisplay: flex;\n\tgap: 16px;\n\talign-items: flex-start;\n\tmargin-bottom: 16px;\n}\n\n.cover-wrapper {\n\twidth: 88px;\n\theight: 88px;\n\tborder-radius: 12px;\n\toverflow: hidden;\n\tflex-shrink: 0;\n\tbackground: var(--secondary-bg);\n\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);\n}\n\n.cover-image {\n\twidth: 100%;\n\theight: 100%;\n\tobject-fit: cover;\n}\n\n.cover-placeholder {\n\twidth: 100%;\n\theight: 100%;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcolor: var(--text-3);\n}\n\n.meta {\n\tflex: 1;\n\tmin-width: 0;\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 4px;\n\tpadding-top: 2px;\n}\n\n.playlist-title {\n\tfont-size: 1.05rem;\n\tfont-weight: 700;\n\tcolor: var(--text-1);\n\tline-height: 1.35;\n\tword-break: break-word;\n\tmargin: 0 0 2px;\n}\n\n.playlist-desc {\n\tfont-size: 0.8rem;\n\tcolor: var(--text-2);\n\tline-height: 1.5;\n\tmargin: 0;\n\tdisplay: -webkit-box;\n\t-webkit-line-clamp: 2;\n\t-webkit-box-orient: vertical;\n\toverflow: hidden;\n}\n\n.owner-row {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmargin-top: 4px;\n}\n\n.owner-avatar {\n\twidth: 20px;\n\theight: 20px;\n\tborder-radius: 50%;\n\tobject-fit: cover;\n}\n\n.owner-avatar-placeholder {\n\twidth: 20px;\n\theight: 20px;\n\tborder-radius: 50%;\n\tbackground: var(--secondary-bg);\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcolor: var(--text-3);\n}\n\n.owner-name {\n\tfont-size: 0.8rem;\n\tfont-weight: 500;\n\tcolor: var(--text-2);\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.track-count {\n\tfont-size: 0.78rem;\n\tcolor: var(--text-3);\n\tmargin-top: 2px;\n}\n\n/* ── Track list ────────────────────────────────────────────────────────── */\n.track-list {\n\tlist-style: none;\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 1px solid var(--border);\n\tborder-radius: 12px;\n\toverflow: hidden;\n}\n\n.track-item {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 10px;\n\tpadding: 9px 12px;\n\tborder-bottom: 1px solid var(--border);\n\ttransition: background 0.12s ease;\n}\n\n.track-item:last-child {\n\tborder-bottom: none;\n}\n\n.track-item:hover {\n\tbackground: var(--track-hover);\n}\n\n.track-index {\n\tfont-size: 0.7rem;\n\tcolor: var(--text-3);\n\twidth: 16px;\n\ttext-align: right;\n\tflex-shrink: 0;\n}\n\n.track-cover {\n\twidth: 32px;\n\theight: 32px;\n\tborder-radius: 6px;\n\tobject-fit: cover;\n\tflex-shrink: 0;\n}\n\n.track-cover-placeholder {\n\twidth: 32px;\n\theight: 32px;\n\tborder-radius: 6px;\n\tbackground: var(--secondary-bg);\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcolor: var(--text-3);\n\tflex-shrink: 0;\n}\n\n.track-info {\n\tflex: 1;\n\tmin-width: 0;\n}\n\n.track-title {\n\tdisplay: block;\n\tfont-size: 0.825rem;\n\tfont-weight: 500;\n\tcolor: var(--text-1);\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tline-height: 1.4;\n}\n\n.track-artist {\n\tdisplay: block;\n\tfont-size: 0.72rem;\n\tcolor: var(--text-3);\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.more-hint {\n\tfont-size: 0.75rem;\n\tcolor: var(--text-3);\n\ttext-align: center;\n\tpadding: 10px 0 4px;\n}\n\n/* ── Skeleton ──────────────────────────────────────────────────────────── */\n.skeleton {\n\tbackground: var(--secondary-bg);\n\tborder-radius: 10px;\n\tanimation: shimmer 1.4s ease-in-out infinite;\n}\n\n@keyframes shimmer {\n\t0%,\n\t100% {\n\t\topacity: 0.5;\n\t}\n\t50% {\n\t\topacity: 1;\n\t}\n}\n\n.cover-skeleton {\n\twidth: 88px;\n\theight: 88px;\n\tborder-radius: 12px;\n\tmargin: 20px 20px 0;\n}\n.title-skeleton {\n\theight: 20px;\n\twidth: 55%;\n\tmargin: 16px 20px 8px;\n}\n.subtitle-skeleton {\n\theight: 14px;\n\twidth: 35%;\n\tmargin: 0 20px 20px;\n}\n.btn-skeleton {\n\theight: 48px;\n\tmargin: 16px 20px 20px;\n\tborder-radius: 12px;\n}\n\n/* ── Buttons ───────────────────────────────────────────────────────────── */\n.button-group {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tpadding: 14px 16px 16px;\n\tflex-shrink: 0;\n\tborder-top: 1px solid var(--border);\n}\n\n.btn {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 8px;\n\tpadding: 13px 20px;\n\tborder-radius: 12px;\n\tfont-size: 0.9rem;\n\tfont-weight: 600;\n\ttext-decoration: none;\n\ttransition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);\n\tcursor: pointer;\n\tline-height: 1;\n}\n\n.btn-primary {\n\tbackground-color: var(--primary);\n\tcolor: var(--primary-fg);\n}\n\n.btn-primary:hover {\n\topacity: 0.85;\n\ttransform: translateY(-1px);\n}\n\n.btn-secondary {\n\tbackground-color: var(--secondary-bg);\n\tcolor: var(--secondary-fg);\n\tfont-weight: 500;\n}\n\n.btn-secondary:hover {\n\topacity: 0.75;\n}\n\n.btn-icon {\n\tflex-shrink: 0;\n}\n\n/* ── Error ─────────────────────────────────────────────────────────────── */\n.error-icon {\n\tcolor: #f87171;\n\tmargin-bottom: 16px;\n}\n\n.error-title {\n\tfont-size: 1.1rem;\n\tfont-weight: 600;\n\tmargin: 0 0 8px;\n\tcolor: var(--text-1);\n}\n\n.error-desc {\n\tfont-size: 0.875rem;\n\tcolor: var(--text-2);\n\tmargin: 0;\n}\n\n/* ── Footer ────────────────────────────────────────────────────────────── */\n.footer {\n\tmargin-top: 12px;\n\ttext-align: center;\n}\n\n.footer-link {\n\tfont-size: 0.78rem;\n\tcolor: var(--text-3);\n\ttext-decoration: none;\n}\n\n.footer-link:hover {\n\tcolor: var(--text-2);\n}\n\n/* ── In-app browser overlay ────────────────────────────────────────────── */\n.browser-overlay {\n\tposition: fixed;\n\tinset: 0;\n\tbackground: rgba(0, 0, 0, 0.88);\n\tbackdrop-filter: blur(10px);\n\tz-index: 9999;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tpadding: 32px;\n}\n\n.overlay-content {\n\ttext-align: center;\n\tcolor: white;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tgap: 14px;\n\tmax-width: 280px;\n}\n\n.overlay-icon {\n\topacity: 0.85;\n}\n\n.overlay-title {\n\tfont-size: 1.4rem;\n\tfont-weight: 700;\n\tmargin: 0;\n}\n\n.overlay-desc {\n\tfont-size: 0.95rem;\n\topacity: 0.75;\n\tline-height: 1.6;\n}\n\n/* ── Responsive ────────────────────────────────────────────────────────── */\n@media (max-width: 480px) {\n\t.page {\n\t\tpadding: 12px 12px 10px;\n\t}\n\n\t.card {\n\t\tborder-radius: 16px;\n\t\tmax-height: calc(100dvh - 60px);\n\t}\n}\n</style>\n"
  },
  {
    "path": "apps/docs/docs/.vitepress/components/ShareTrackPage.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted, computed } from 'vue'\nimport { Play, Tv2, AlertCircle, Music2, ExternalLink } from 'lucide-vue-next'\n\nconst id = ref('')\nconst title = ref('')\nconst cover = ref('')\nconst error = ref('')\nconst bvid = ref('')\nconst cid = ref('')\nconst p = ref('')\nconst isOnInAppBrowser = ref(false)\n\nonMounted(() => {\n\t// Check for in-app browser\n\t// Matches: WeChat, QQ, Weibo, Alipay, DingTalk, Zhihu, Baidu, Bilibili (in-app)\n\tconst ua = navigator.userAgent\n\tisOnInAppBrowser.value =\n\t\t/MicroMessenger|QQ\\/|Weibo|AlipayClient|DingTalk|ZhihuHybrid|BaiduBoxApp/i.test(\n\t\t\tua,\n\t\t)\n\n\tconst params = new URLSearchParams(window.location.search)\n\tid.value = params.get('id') || ''\n\ttitle.value = params.get('title') || ''\n\tcover.value = params.get('cover') || ''\n\tp.value = params.get('p') || ''\n\n\t// Parse bilibili id\n\tif (id.value.startsWith('bilibili::')) {\n\t\tconst parts = id.value.split('::')\n\t\tif (parts.length >= 2) {\n\t\t\tbvid.value = parts[1]\n\t\t\tif (parts.length >= 3) {\n\t\t\t\tcid.value = parts[2]\n\t\t\t}\n\t\t} else {\n\t\t\terror.value = '无效的分享链接'\n\t\t}\n\t} else {\n\t\terror.value = '暂不支持此来源的分享链接'\n\t}\n})\n\nconst bilibiliUrl = computed(() => {\n\tif (!bvid.value) return ''\n\tlet url = `https://www.bilibili.com/video/${bvid.value}`\n\tif (p.value) {\n\t\turl += `?p=${p.value}`\n\t} else if (cid.value) {\n\t\turl += `?p=1`\n\t}\n\treturn url\n})\n\nconst bbplayerAppLinkUrl = computed(() => {\n\tif (!bvid.value) return ''\n\tif (cid.value) {\n\t\treturn `https://app.bbplayer.roitium.com/app/link-to/playlist/remote/multipage/${bvid.value}?cid=${cid.value}`\n\t}\n\treturn `https://app.bbplayer.roitium.com/app/link-to/playlist/remote/search-result/global/${bvid.value}`\n})\n</script>\n\n<template>\n\t<div class=\"page\">\n\t\t<!-- In-app browser overlay -->\n\t\t<div\n\t\t\tv-if=\"isOnInAppBrowser\"\n\t\t\tclass=\"browser-overlay\"\n\t\t>\n\t\t\t<div class=\"overlay-content\">\n\t\t\t\t<div class=\"overlay-icon-wrapper\">\n\t\t\t\t\t<ExternalLink\n\t\t\t\t\t\t:size=\"48\"\n\t\t\t\t\t\tclass=\"overlay-icon\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<h3 class=\"overlay-title\">请在浏览器打开</h3>\n\t\t\t\t<p class=\"overlay-desc\">点击右上角菜单，选择在浏览器打开以继续</p>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div\n\t\t\tv-if=\"!error\"\n\t\t\tclass=\"card\"\n\t\t>\n\t\t\t<div class=\"card-body\">\n\t\t\t\t<div class=\"cover-wrapper\">\n\t\t\t\t\t<img\n\t\t\t\t\t\tv-if=\"cover\"\n\t\t\t\t\t\t:src=\"cover\"\n\t\t\t\t\t\t:alt=\"title\"\n\t\t\t\t\t\tclass=\"cover-image\"\n\t\t\t\t\t\treferrerpolicy=\"no-referrer\"\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tclass=\"cover-placeholder\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<Music2\n\t\t\t\t\t\t\t:size=\"52\"\n\t\t\t\t\t\t\tclass=\"placeholder-icon\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<h1 class=\"track-title\">{{ title || '未知曲目' }}</h1>\n\t\t\t</div>\n\t\t\t<div class=\"button-group\">\n\t\t\t\t<a\n\t\t\t\t\t:href=\"bilibiliUrl\"\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclass=\"btn btn-secondary\"\n\t\t\t\t>\n\t\t\t\t\t<Tv2\n\t\t\t\t\t\tclass=\"btn-icon\"\n\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t/>\n\t\t\t\t\t在 Bilibili 打开\n\t\t\t\t</a>\n\t\t\t\t<a\n\t\t\t\t\t:href=\"bbplayerAppLinkUrl\"\n\t\t\t\t\tclass=\"btn btn-primary\"\n\t\t\t\t>\n\t\t\t\t\t<Play\n\t\t\t\t\t\tclass=\"btn-icon\"\n\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t/>\n\t\t\t\t\t在 BBPlayer 打开\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div\n\t\t\tv-else\n\t\t\tclass=\"card error-card\"\n\t\t>\n\t\t\t<div class=\"error-icon\">\n\t\t\t\t<AlertCircle :size=\"52\" />\n\t\t\t</div>\n\t\t\t<h2 class=\"error-title\">{{ error }}</h2>\n\t\t\t<p class=\"error-desc\">请检查分享链接是否正确</p>\n\t\t</div>\n\n\t\t<div class=\"footer\">\n\t\t\t<a\n\t\t\t\thref=\"https://bbplayer.roitium.com\"\n\t\t\t\ttarget=\"_blank\"\n\t\t\t\tclass=\"footer-link\"\n\t\t\t\t>来自 BBPlayer | 由 Roitium ❤️ 构建</a\n\t\t\t>\n\t\t</div>\n\t</div>\n</template>\n\n<style scoped>\n@import './shared-page.css';\n\n/* ── Card body (cover + title) ─────────────────────────────────────────── */\n\n.card-body {\n\tpadding: 36px 32px 20px;\n\ttext-align: center;\n}\n\n.cover-wrapper {\n\twidth: 196px;\n\theight: 196px;\n\tmargin: 0 auto 24px;\n\tborder-radius: 16px;\n\toverflow: hidden;\n\tbackground: var(--secondary-bg);\n\tbox-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);\n}\n\n.cover-image {\n\twidth: 100%;\n\theight: 100%;\n\tobject-fit: cover;\n}\n\n.cover-placeholder {\n\twidth: 100%;\n\theight: 100%;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcolor: var(--text-3);\n}\n\n.track-title {\n\tfont-size: 1.2rem;\n\tfont-weight: 700;\n\tcolor: var(--text-1);\n\tmargin: 0;\n\tline-height: 1.4;\n\tword-break: break-word;\n}\n\n/* ── Error card ────────────────────────────────────────────────────────── */\n.error-card {\n\tpadding: 40px 32px;\n\ttext-align: center;\n\talign-items: center;\n}\n\n/* ── Responsive ────────────────────────────────────────────────────────── */\n@media (max-width: 480px) {\n\t.card-body {\n\t\tpadding: 28px 24px 16px;\n\t}\n\n\t.cover-wrapper {\n\t\twidth: 156px;\n\t\theight: 156px;\n\t\tmargin-bottom: 18px;\n\t}\n\n\t.track-title {\n\t\tfont-size: 1.05rem;\n\t}\n}\n</style>\n"
  },
  {
    "path": "apps/docs/docs/.vitepress/components/shared-page.css",
    "content": "/* shared-page.css — BBPlayer 共享页面设计系统 */\n\n/* ── Design tokens ─────────────────────────────────────────────────────── */\n.page {\n\t--bg: #dde1e7;\n\t--card-bg: #ffffff;\n\t--text-1: #0f172a;\n\t--text-2: #64748b;\n\t--text-3: #94a3b8;\n\t--primary: #0f172a;\n\t--primary-fg: #ffffff;\n\t--secondary-bg: #f1f5f9;\n\t--secondary-fg: #334155;\n\t--border: rgba(0, 0, 0, 0.08);\n\t--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.06);\n}\n\n@media (prefers-color-scheme: dark) {\n\t.page {\n\t\t--bg: #0a0a0f;\n\t\t--card-bg: #16161e;\n\t\t--text-1: #f1f5f9;\n\t\t--text-2: #94a3b8;\n\t\t--text-3: #475569;\n\t\t--primary: #f1f5f9;\n\t\t--primary-fg: #0f172a;\n\t\t--secondary-bg: #1e1e2a;\n\t\t--secondary-fg: #cbd5e1;\n\t\t--border: rgba(255, 255, 255, 0.08);\n\t\t--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 1px 4px rgba(0, 0, 0, 0.3);\n\t}\n}\n\n/* ── Page layout ───────────────────────────────────────────────────────── */\n.page {\n\tmin-height: 100dvh;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tjustify-content: center;\n\tpadding: 20px 16px 12px;\n\tbackground-color: var(--bg);\n\tfont-family:\n\t\t-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',\n\t\tArial, sans-serif;\n\tcolor: var(--text-1);\n\tbox-sizing: border-box;\n}\n\n/* ── Card ──────────────────────────────────────────────────────────────── */\n.card {\n\tbackground: var(--card-bg);\n\tborder-radius: 20px;\n\tmax-width: 480px;\n\twidth: 100%;\n\tborder: 1px solid var(--border);\n\tbox-shadow: var(--card-shadow);\n\tanimation: fadeUp 0.45s ease-out both;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n@keyframes fadeUp {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(10px);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: translateY(0);\n\t}\n}\n\n/* ── Buttons ───────────────────────────────────────────────────────────── */\n.button-group {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tpadding: 14px 16px 16px;\n\tflex-shrink: 0;\n\tborder-top: 1px solid var(--border);\n}\n\n.btn {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 8px;\n\tpadding: 13px 20px;\n\tborder-radius: 12px;\n\tfont-size: 0.9rem;\n\tfont-weight: 600;\n\ttext-decoration: none;\n\ttransition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);\n\tcursor: pointer;\n\tline-height: 1;\n}\n\n.btn-primary {\n\tbackground-color: var(--primary);\n\tcolor: var(--primary-fg);\n}\n\n.btn-primary:hover {\n\topacity: 0.85;\n\ttransform: translateY(-1px);\n}\n\n.btn-secondary {\n\tbackground-color: var(--secondary-bg);\n\tcolor: var(--secondary-fg);\n\tfont-weight: 500;\n}\n\n.btn-secondary:hover {\n\topacity: 0.75;\n}\n\n.btn-icon {\n\tflex-shrink: 0;\n}\n\n/* ── Error state ───────────────────────────────────────────────────────── */\n.error-icon {\n\tcolor: #f87171;\n\tmargin-bottom: 16px;\n\tdisplay: flex;\n\tjustify-content: center;\n}\n\n.error-title {\n\tfont-size: 1.1rem;\n\tfont-weight: 600;\n\tmargin: 0 0 8px;\n\tcolor: var(--text-1);\n}\n\n.error-desc {\n\tfont-size: 0.875rem;\n\tcolor: var(--text-2);\n\tmargin: 0;\n}\n\n/* ── Footer ────────────────────────────────────────────────────────────── */\n.footer {\n\tmargin-top: 12px;\n\ttext-align: center;\n}\n\n.footer-link {\n\tfont-size: 0.78rem;\n\tcolor: var(--text-3);\n\ttext-decoration: none;\n}\n\n.footer-link:hover {\n\tcolor: var(--text-2);\n}\n\n/* ── In-app browser overlay ────────────────────────────────────────────── */\n.browser-overlay {\n\tposition: fixed;\n\tinset: 0;\n\tbackground: rgba(0, 0, 0, 0.88);\n\tbackdrop-filter: blur(10px);\n\tz-index: 9999;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tpadding: 32px;\n}\n\n.overlay-content {\n\ttext-align: center;\n\tcolor: white;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tgap: 14px;\n\tmax-width: 280px;\n}\n\n.overlay-icon {\n\topacity: 0.85;\n}\n\n.overlay-title {\n\tfont-size: 1.4rem;\n\tfont-weight: 700;\n\tmargin: 0;\n}\n\n.overlay-desc {\n\tfont-size: 0.95rem;\n\topacity: 0.75;\n\tline-height: 1.6;\n\tmargin: 0;\n}\n\n/* ── Responsive ────────────────────────────────────────────────────────── */\n@media (max-width: 480px) {\n\t.page {\n\t\tpadding: 12px 12px 10px;\n\t}\n\n\t.card {\n\t\tborder-radius: 16px;\n\t}\n}\n"
  },
  {
    "path": "apps/docs/docs/.vitepress/config.mts",
    "content": "import { defineConfig } from 'vitepress'\n\n// https://vitepress.dev/reference/site-config\nexport default defineConfig({\n\ttitle: 'BBPlayer',\n\tlang: 'zh-CN',\n\tdescription: '又一个 BiliBili 音乐播放器',\n\thead: [['link', { rel: 'icon', href: '/favicon.ico' }]],\n\tcleanUrls: true,\n\tthemeConfig: {\n\t\t// https://vitepress.dev/reference/default-theme-config\n\t\tlogo: '/icon.png',\n\t\tnav: [\n\t\t\t{ text: '首页', link: '/' },\n\t\t\t{ text: '指南', link: '/guides' },\n\t\t],\n\t\teditLink: {\n\t\t\tpattern:\n\t\t\t\t'https://github.com/bbplayer-app/bbplayer-docs/edit/main/docs/:path',\n\t\t},\n\t\toutline: [2, 5],\n\n\t\tsidebar: [\n\t\t\t{\n\t\t\t\ttext: '指南',\n\t\t\t\tlink: '/guides',\n\t\t\t\titems: [\n\t\t\t\t\t{ text: '安装', link: '/guides/install' },\n\t\t\t\t\t{ text: '搜索', link: '/guides/search' },\n\t\t\t\t\t{ text: '歌单', link: '/guides/playlist' },\n\t\t\t\t\t{ text: '歌词', link: '/guides/lyrics' },\n\t\t\t\t\t{ text: '下载与导出', link: '/guides/download' },\n\t\t\t\t\t{ text: '播放器功能', link: '/guides/player' },\n\t\t\t\t\t{ text: '设置与个性化', link: '/guides/settings' },\n\t\t\t\t\t{ text: '排行榜', link: '/guides/leaderboard' },\n\t\t\t\t\t{ text: '评论区', link: '/guides/comments' },\n\t\t\t\t\t{ text: '导入外部歌单', link: '/guides/external-playlist' },\n\t\t\t\t\t{ text: '共享歌单与协同编辑', link: '/guides/shared-playlist' },\n\t\t\t\t],\n\t\t\t},\n\t\t],\n\n\t\tsocialLinks: [\n\t\t\t{ icon: 'github', link: 'https://github.com/bbplayer-app/bbplayer' },\n\t\t],\n\t},\n})\n"
  },
  {
    "path": "apps/docs/docs/SPL.md",
    "content": "---\ntitle: SPL 歌词规范\neditLink: true\n---\n\n> [!NOTE]\n> 本文档转载自 [Moriafly Official | SPL 格式语法标准](https://moriafly.com/standards/spl.html)。BBPlayer 完美支持该规范，并建议所有歌词文件遵循此标准。\n\n## SPL 格式（Salt Player Lyrics）语法标准\n\n制定时间：2024 年 12 月 16 日\n\n修订时间：2025 年 11 月 14 日\n\n制定作者：不要糖醋放椒盐\n\n## 前言\n\nSalt Player 歌词格式（简称“SPL”），基于且兼容（增强型）LRC，一种阅读友好的歌词格式。\n\n### 为什么选择 SPL？\n\n- LRC 格式拥有 **极多的使用用户** ，SPL 兼容 LRC，并在此基础上对多年来遇到的多种兼容性问题进行了适配，拥有 **优秀的兼容性** 。\n- LRC/SPL **非常简单，对人类阅读友好** ，时间戳为分、秒、毫秒格式，加上歌词文本，没有过多标记字符，使得在极简文本编辑器/编辑框中也可以 **非常方便地编辑** 。\n- LRC/SPL 极简的标记格式拥有 **强大的表现力，占用的字符空间也更少** 。\n- SPL SPL 在对适配各种兼容性问题的解决方案上将它们视为一种“语法糖”，并进行 **标准化，对编辑更加友好方便** ，同时也是本标准的意义。\n\n## 时间戳\n\n时间戳名词在互联网不同的使用场景有多种解释。在本文中，时间戳代表记录时间的文本标记，由分、秒和毫秒组成，并使用 `[` 和 `]` 符号包裹，格式示例：\n\n```\n[05:20.22]你好椒盐音乐\n```\n\n其中 `[05:20.22]` 便是一个时间戳，表示 5 分 20 秒 220 毫秒，分和秒之间由 `:`（半角冒号）分隔，秒和毫秒间由 `.`（半角句号）分割，这和日常书写习惯一致。\n\n### 分、秒和毫秒的数字规范\n\n换算：1 分等于 60 秒，1 秒等于 1000 毫秒。\n\n分限制 1 至 3 位数字，如 `1` 、 `02` 、 `103` 都是支持的写法。\n\n秒限制 1 至 2 位数字，如 `1` 、 `02` 、 `13` 都是支持的写法。\n\n毫秒限制 1 至 6 位数字，如 `1` 、 `02` 、 `130` 、 `450000` 都是支持的写法。不足 3 位的写法将视为在后位省略了 `0` ，如 `1` 将视为 `100` 、 `02` 将视为 `020` ，分别对应 100 毫秒和 20 毫秒，而非 1 毫秒和 2 毫秒。\n\n综合示例，正确写法（ `//` 后是注释）：\n\n错误写法：\n\n## 歌词行\n\n一句歌词由时间戳和时间戳后接文本组成，如：\n\n```\n[05:20.22]你好椒盐音乐\n```\n\n表示这是一句从 5 分 20 秒 220 毫秒开始的歌词，歌词内容是 `你好椒盐音乐` 。\n\n### 显式行结尾\n\n在一句的歌词最后添加时间戳将视为歌词的结尾时间，如：\n\n```\n[05:20.22]你好椒盐音乐[05:21.22]\n```\n\n表示此句歌词从 5 分 20 秒 220 毫秒开始到 5 分 21 秒 220 毫秒结束，共持续 1 秒。\n\n也可换行标记，如：\n\n```\n[05:20.22]你好椒盐音乐\n\n[05:21.22]\n```\n\n和上一种写法一样，注意第二行时间戳后不接任何文本内容。\n\n### 隐式行结尾\n\n如果未在歌词行后标记歌词结束时间，这句歌词将一直持续直到下一句歌词开始，如：\n\n```\n[05:20.22]你好椒盐音乐\n\n[05:22.22]天天开心\n```\n\n第一句歌词将视为 5 分 20 秒 220 毫秒开始到 5 分 22 秒 220 毫秒结束，结束时间为下一行的开始时间。\n\n### 重复行\n\n如果一句歌词重复在歌曲中出现，可以使用多时间戳的方式简写，如：\n\n```\n[05:20.22]你好椒盐音乐\n\n[05:30.22]你好椒盐音乐\n```\n\n可简写为：\n\n```\n[05:20.22][05:30.22]你好椒盐音乐\n```\n\n## 歌词翻译\n\n### 翻译识别\n\n翻译依靠同时间戳识别，在前的一句为主歌词文本，后的一句为翻译歌词文本，如：\n\n其中 `Hello Salt Player` 是 `你好椒盐音乐` 的翻译文本，两句可以不紧挨着，但须保证翻译歌词在主歌词之后。\n\n在 SPL 中也可省略翻译文本时间戳，如：\n\n但 `Hello Salt Player` 必须紧挨着主歌词句子，若：\n\n`Hello Salt Player` 将被视为 `[05:21.22]不要糖醋放椒盐` 的翻译文本。\n\n### 多行翻译\n\nSPL 标准支持多行翻译，\n\n## 逐字歌词\n\n逐字歌词是可以争对一行歌词内的文本进行更精确的时间控制，不局限于对每个字符都精确控制，也可以是词组或者其他一部分，如：\n\n```\n[05:20.22]你好椒盐音乐\n```\n\n歌词中如果 `你好` 两个字持续 3 秒而 `椒盐音乐` 四个字持续 1 秒，这时候行歌词并不能精确的表现，便需要一种方法指定行内的更精确的时间标记。\n\n### 逐字标记时间戳\n\n逐字歌词和行歌词的时间戳逻辑差别不大，如上方提到的需求可以写成：\n\n```\n[05:20.22]你好[05:23.22]椒盐音乐[05:24.22]\n```\n\n`[05:20.22]` 到 `[05:23.22]` 的间隔为 3 秒， `[05:23.22]` 到 `[05:24.22]` 的间隔为 1 秒。\n\n其中 `[05:20.22]` 依旧是行开始时间戳同时也是开始逐字标记时间戳，因为它位于句首，而 \\[05:23.22\\] 是中间的一个逐字标记时间戳，用以分隔 `你好` 和 `椒盐音乐` ，最后的 `[05:24.22]` 是行结束时间戳同时也是结束逐字标记时间戳。\n\n逐字标记时间戳需要递增，如果出现时间戳不在行开始时间和结束时间间或者小于之前的时间戳，那么此逐字标记时间戳会被忽略。\n\n### 局限性\n\n使用逐字歌词将不兼容 [歌词行](https://moriafly.com/standards/#%E6%AD%8C%E8%AF%8D%E8%A1%8C) 中的 [重复行](https://moriafly.com/standards/#%E9%87%8D%E5%A4%8D%E8%A1%8C) 特性，如：\n\n```\n[05:20.22][05:30.22]你好[05:23.22]椒盐音乐[05:24.22]\n```\n\n将被视为：\n\n```\n[05:20.22]你好[05:23.22]椒盐音乐[05:24.22]\n\n[05:30.22]你好[05:23.22]椒盐音乐[05:24.22]\n```\n\n其中第一行正常而第二行逐字标记时间戳存在顺序错误而被忽略，所以在使用逐字标记建议不使用重复行特性。\n\n### 兼容性与延迟逐字标记\n\n非开始逐字标记和结束逐字标记时间戳可以使用 `<` 和 `>` 符号包裹，如：\n\n```\n[05:20.22]你好[05:23.22]椒盐音乐[05:24.22]\n```\n\n可写成：\n\n```\n[05:20.22]你好<05:23.22>椒盐音乐[05:24.22]\n```\n\n这带来一种特性，可以实现歌词行到达而首字未开始，如：\n\n```\n[05:20.22]<05:21.22>你好<05:23.22>椒盐音乐[05:24.22]\n```\n"
  },
  {
    "path": "apps/docs/docs/app-not-exist.md",
    "content": "---\nlayout: false\ntitle: 未检测到应用\n---\n\n<script setup>\nimport AppNotExistPage from './.vitepress/components/AppNotExistPage.vue'\n</script>\n\n<AppNotExistPage />\n"
  },
  {
    "path": "apps/docs/docs/guides/comments.md",
    "content": "---\ntitle: 评论区\neditLink: true\n---\n\n# 评论区\n\nBBPlayer 支持查看 B 站视频对应的评论区，让你在听歌的同时也能看看大家的评论。\n\n## 访问方式\n\n在播放界面页面，点击 **评论** 图标（气泡形状），即可进入当前歌曲（视频）的评论区。\n\n## 功能说明\n\n- **浏览评论**：支持查看精选评论和最新评论。\n- **查看回复**：点击任意一条评论，可以进入详情页查看该评论下的所有回复。（也可以查看图片！）\n- **互动**：目前仅支持浏览与点赞。回复功能尚未支持。\n\n> [!TIP]\n> 评论区内容直接来源于 Bilibili，与视频网页版或 App 端看到的完全一致。\n"
  },
  {
    "path": "apps/docs/docs/guides/download.md",
    "content": "---\ntitle: 下载与导出\neditLink: true\n---\n\n### 下载\n\n我也给 BBPlayer 简单搓了个下载功能：\n\n- **缓存整个歌单**：在播放列表页面点「:arrow_down:」按钮。（是增量下载，已经下载过的就不会再下了）\n- **下载单曲**：在歌曲右侧菜单里点「缓存音频」。\n\n已经下载的音频，歌曲时长旁边会有一个「:white_check_mark:」emoji。同时播放器顶部也会显示「已缓存」\n\n### 导出\n\n::: warning\n如果是第一次使用导出功能的用户，请到 `设置 > 通用 > 点击「下载缺失封面」按钮`，否则可能有些歌曲的封面无法正常显示。只需执行一次\n:::\n\n为了方便你将喜欢的歌曲分享给朋友或在其他播放器中使用，BBPlayer 支持将已缓存的音频导出为带封面、元数据、内嵌歌词的 `.m4a` 文件。\n\n**使用方法：**\n\n1. 在「音乐库」页面，点击右上角的下载按钮（:arrow_down_tray:）进入下载管理页面。\n2. 在列表中选择你想要导出的歌曲。\n3. 点击页面右上角的「导出」按钮。\n4. 在弹出的系统目录选择器中选择你想要保存到的文件夹。\n5. 在下拉菜单中配置文件名、内嵌歌词与裁剪封面等。\n6. 等待导出完成即可！\n\n::: tip\n导出时支持自定义文件名格式，并可选择是否嵌入歌词（包括逐字歌词转换）、裁剪封面等。\n:::\n\n### Q&A\n\n#### 下载的文件格式？占空间吗？耗流量吗？\n\nB 站只给 m4s 格式的音频流，一首歌通常在 5-10MB 左右，别担心流量和空间。\n\n#### 下载的文件藏哪儿了？\n\n为了遵循 Google 的规范，下载文件都放在 App 的私有目录里。手机没 Root 的话你是看不到的。\n\n如果你需要使用这些文件，请使用上述的 **导出** 功能，它可以将音频转换为标准的 `.m4a` 格式并保存到你指定的公开目录。\n"
  },
  {
    "path": "apps/docs/docs/guides/external-playlist.md",
    "content": "# 导入外部歌单\n\nBBPlayer 支持将 **网易云音乐** 和 **QQ 音乐** 的歌单导入到本地。通过 Bilibili 的搜索功能，会自动为你匹配歌单中的每一首歌曲。\n\n## 支持的平台\n\n- 网易云音乐\n- QQ 音乐\n\n## 如何导入\n\n1.  复制网易云音乐或 QQ 音乐的歌单链接。\n2.  在 BBPlayer **库**页面，点击右上角的 **+** 按钮。\n3.  选择 **导入外部歌单**。\n4.  将链接粘贴到输入框中，也可以直接粘贴歌单 ID。\n5.  点击 **获取歌单信息**。\n\n## 匹配歌曲\n\n获取到歌单信息后，BBPlayer 会显示歌单的标题、封面以及包含的歌曲列表。\n\n1.  确认歌单信息无误后，点击 **开始匹配歌曲**。\n2.  应用将自动在 Bilibili 搜索每一首歌曲，并尝试找到最佳匹配的视频。\n3.  你可以随时暂停匹配过程。\n\n> [!TIP]\n> 虽然搜索过程不会使用您的 Cookie，但我们仍不建议您导入包含歌曲数量过多的歌单，因为时间可能过长（具体时长您可以参考匹配进度条旁边的 ETA 信息）\n\n## 手动调整匹配\n\n如果某首歌曲没有找到匹配项，或者自动匹配的结果不准确，你可以进行手动调整：\n\n1.  在匹配结果列表中，找到该首歌曲。\n2.  点击右侧的 **编辑** 按钮（铅笔图标）。\n3.  应用会自动使用歌曲名和歌手名预填搜索框。你可以修改关键词搜索正确的视频。\n4.  在搜索结果中点击选择你认为正确的视频。\n5.  该歌曲将会被更新为你选择的视频。\n\n## 保存歌单\n\n匹配完成后，或者你在中途决定停止匹配：\n\n1.  点击右上角的 **保存** 按钮。\n2.  系统会提示你还有多少歌曲未匹配或未找到匹配项。\n3.  确认保存后，一个新的本地歌单将被创建，其中包含了所有成功匹配的歌曲。\n\n你可以在 **库** -> **本地歌单** 中找到它。\n\n## 注意事项\n\n- **匹配准确率**：由于是基于歌名和歌手名在 Bilibili 进行搜索，匹配结果可能并非 100% 准确。\n- **未匹配歌曲**：如果没有找到合适的视频，该歌曲将被跳过。你可以后续手动在 Bilibili 搜索并添加到歌单。\n- **会员歌曲**：即使原平台是 VIP 歌曲，只要 Bilibili 上有对应的视频资源（如官方 MV、饭制版、搬运等），通常都能成功匹配并播放。\n"
  },
  {
    "path": "apps/docs/docs/guides/index.md",
    "content": "---\ntitle: 指南\neditLink: true\n---\n\n# 指南\n\n虽然 BBPlayer 始终把**「简单易用」**作为开发目标，但受限于作者水平，可能仍具有一点上手门槛。你可以通过阅读本指南，快速上手 BBPlayer。\n"
  },
  {
    "path": "apps/docs/docs/guides/install.md",
    "content": "---\ntitle: 安装\neditLink: true\n---\n\n# 安装\n\n~~由于开发者没有 Mac 电脑，完全无法开发 iOS 端~~，故目前只有 Android 端。\n\n目前正在开发 IOS 版本，但推进较慢，别抱期待，，，\n\nBBPlayer 的每次更新都会发布在：[GitHub Release](https://github.com/bbplayer-app/bbplayer/releases)，你可以在页面上点击「Assets」标签，找到最新版本的 APK 文件并安装。\n"
  },
  {
    "path": "apps/docs/docs/guides/leaderboard.md",
    "content": "---\ntitle: 排行榜\neditLink: true\n---\n\n# 排行榜\n\n既然我们记录了你的播放数据，那么生成一个排行榜自然是理所应当的事情。\n\n## 播放统计\n\nBBPlayer 会根据你的本地播放记录，统计出你听歌最多的曲目和总时长。\n\n### 访问方式\n\n在「音乐库」点击右上角的 **排行榜** 图标（奖杯形状）。\n\n### 功能说明\n\n- **听歌排行**：展示你所有歌曲的播放次数排行。点击歌曲可以直接播放。\n- **总时长统计**：页面顶部会显示你使用 BBPlayer **完整播放**歌曲的总时长。\n\n> [!NOTE]\n> 这里统计的“完整播放”是指歌曲从头播放到尾。切歌或未播放完则不会计入时长统计。\n"
  },
  {
    "path": "apps/docs/docs/guides/lyrics.md",
    "content": "---\ntitle: 歌词\neditLink: true\n---\n\n# 歌词\n\nBBPlayer 拥有一个功能完善的歌词系统。除了基础的展示外，还支持多种进阶特性。\n\n## 获取歌词\n\n- **智能获取**：播放歌曲时，BBPlayer 会自动根据标题和艺术家，从您选择的歌词源（网易云音乐、QQ 音乐、酷狗音乐）搜索最匹配的歌词。\n- **手动搜索**：如果自动匹配不准，点击播放器页面右上角的「三个点」菜单，选择「搜索歌词」即可手动输入关键词查找。\n- **手动粘贴**：你也可以直接将 LRC 或 SPL 格式的歌词文本粘贴到编辑器中。\n- **离线访问**：所有获取到的歌词都会自动保存在本地缓存中，下次播放时无需再次请求。\n\n## 歌词显示\n\n- **滚动歌词**：歌词会随进度自动滚动。点击任意一行歌词，可以直接将播放进度跳转到该时间点。\n- **逐字歌词**：支持精确到字的进度显示，让歌词跳动更自然。（目前主要支持网易云源）\n- **歌词罗马音**：为日语等歌曲提供罗马音注音，方便跟唱。（目前主要支持网易云源）\n- **多语言显示**：支持原文、译文、罗马音的自由切换与组合显示。\n- **纯文本回退**：对于没有时间轴或解析错误的歌词，会自动回退到纯文本模式显示。\n\n## 进阶功能\n\n- **偏移量调整**：B 站音源可能与歌词源存在时间差。你可以通过「调整偏移」功能，以 0.5s 为步进手动校准。\n- **桌面歌词 (Android)**：开启后可在应用后台或锁屏状态下显示悬浮窗歌词。支持调整颜色、大小及位置锁定。 [见下方](#desktop-lyrics)\n- **歌词分享卡片**：长按歌词行可进入分享界面，生成精美的歌词卡片。\n\n## 编辑与手动输入\n\n如果自动搜索的歌词不准确，或者您有更好的歌词源，可以手动进行编辑。\n\n1. **进入编辑模式**：在播放器歌词界面，点击右下角的「铅笔」图标按钮，即可打开歌词编辑器。\n2. **多页编辑**：编辑器提供三个标签页：**主歌词**、**翻译**、**罗马音**。您可以分别填入对应的内容。\n3. **格式校验**：保存时 BBPlayer 会自动校验您的歌词格式。如果发现错误，会提示具体的行号以便修正。\n4. **手动校时**：点击右下角的「上下箭头」图标，可以打开偏移量调整面板，实现快速同步。\n\n## 歌词格式 (SPL)\n\nBBPlayer 内部采用 **SPL (Salt Player Lyirc)** 格式作为歌词存储与交换的标准。\n\n- **LRC 超集**：SPL 是是对传统 LRC 格式的扩展，完美兼容所有 LRC 标签。\n- **丰富特性**：SPL 增加了对逐字时间戳、罗马音、长翻译等特性的支持。\n- **标准规范**：你可以访问 [SPL 标准文档](/SPL.md) 了解更多技术细节。\n\n> [!TIP]\n> 如果你在手动编辑歌词，请确保遵循 SPL 规范以获得最佳的显示效果。\n\n## 桌面歌词 (Android) {#desktop-lyrics}\n\n在 **设置 -> 歌词设置** 中开启。\n开启后，歌词会以悬浮窗的形式显示在屏幕最上层。你可以自由拖动位置，或者在设置中将其 **锁定** 以防止误触。\n\n你也可以点击歌词，在弹出面板中可以调整字体大小、颜色，以及控制歌曲播放及上一曲下一曲。\n\n> [!IMPORTANT]\n> 启用桌面歌词需要授予「悬浮窗」权限。首次开启时，应用会引导你前往系统设置页面进行授权。\n\n## 状态栏歌词 (Status Bar Lyric) {#status-bar-lyric}\n\nBBPlayer 支持通过 Xposed 插件在系统状态栏显示当前播放的歌词。目前支持两种框架：**词幕 (Lyricon)** 和 **SuperLyric**，可在设置页面自由切换。\n\n### 方案一：词幕 (Lyricon) —— **推荐**\n\n词幕是一款基于 LSPosed 的系统级状态栏歌词增强工具，支持**逐字进度**、**歌词翻译**，显示效果最细腻，是 BBPlayer 的首选推荐方案。\n\n1. **环境准备**：确保你的设备已安装 **LSPosed** 环境，且 Android 版本 ≥ 8.1 (API 27)。\n2. **下载并安装词幕**：前往 [proify/lyricon](https://github.com/proify/lyricon/releases) 下载并安装词幕客户端。\n3. **激活模块**：在 LSPosed 管理器中启用\"词幕\"，并确保勾选 **系统界面 (System UI)** 作用域，重启生效。\n4. **开启功能**：打开 BBPlayer，进入 **设置 -> 歌词设置** 页面，将框架选为 **词幕 (Lyricon)**，并启用 **状态栏歌词** 即可。\n\n### 方案二：SuperLyric\n\n1. **环境准备**：确保你的设备已安装 **Xposed** 或 **LSPosed** 环境。\n2. **下载并安装 SuperLyric**：前往 [HChenX/SuperLyric](https://github.com/HChenX/SuperLyric) 下载并安装最新版插件。\n3. **配置作用域**：在 LSPosed/Xposed 模块的作用域设置中勾选 **BBPlayer**，然后重启设备以使插件生效。\n4. **下载并安装状态栏歌词容器**：前往 [Block-Network/StatusBarLyric](https://github.com/Block-Network/StatusBarLyric) 下载并安装最新版应用，并按照其说明完成相关配置。\n5. **开启功能**：打开 BBPlayer，进入 **设置 -> 歌词设置** 页面，将框架选为 **SuperLyric**，并启用 **状态栏歌词** 即可。\n\n> [!IMPORTANT]\n> 该功能目前仍处于实验阶段，可能存在兼容性问题或 Bug。如果你在安装或使用过程中发现无法正常显示，欢迎在 GitHub 上提交 [Issue](https://github.com/Block-Network/BBPlayer/issues) 反馈给我们。\n"
  },
  {
    "path": "apps/docs/docs/guides/player.md",
    "content": "---\ntitle: 播放器功能\neditLink: true\n---\n\n# 播放器功能\n\n除了基本的播放控制，BBPlayer 的播放器页面还藏着一些实用的小功能。\n\n## 定时关闭\n\n睡前听歌神器。\n\n### 设置方法\n\n在播放器页面，点击右上角的 **更多** 菜单（三个点），选择 **定时关闭**。\n你可以选择预设的时间（15/30/45/60 分钟），也可以自定义时间。倒计时结束后，音乐会自动暂停。\n\n## 分享卡片\n\n遇到好听的歌或者有意思的歌词，想分享给朋友？\n\n### 分享歌曲\n\n在播放器页面，点击右上角的 **更多** 菜单，选择 **分享歌曲**。\nBBPlayer 会根据当前歌曲的封面自动提取主题色，生成一张精美的分享卡片。你可以直接分享给朋友，或者保存到相册。\n\n<img src=\"./attachments/share_song.jpg\" alt=\"分享歌曲\" width=\"375\" />\n\n### 分享歌词\n\n1. 同样在播放器页面，点击右上角的 **更多** 菜单，选择 **分享歌词**。\n2. 在弹出的「歌词选择」窗口中，勾选你想分享的歌词行。\n3. 点击右下角的 **分享** 按钮，生成长图，保存或分享！\n\n<img src=\"./attachments/share_lyric.jpg\" alt=\"分享歌词\" width=\"375\" />\n\n## 弹幕\n\n支持在播放器页面直接显示视频弹幕，还原最原汁原味的 B 站体验。\n\n### 开启方法\n\n1. 进入 **设置** -> **播放设置**。\n2. 找到 **启用弹幕** 一栏。\n3. 点击右侧的按钮打开 **弹幕设置** 面板。\n4. 在面板中开启 **启用弹幕** 开关并将 **屏蔽等级** 调整到合适的位置。\n\n> [!NOTICE]\n> 部分老旧机型开启弹幕后可能会出现卡顿发热等现象，请酌情开启。\n\n## 播放队列\n\n点击播放器底部的 **播放列表** 图标（通常在右下角），可以查看当前正在播放的队列。\n你可以删除单曲，或者一键将当前队列保存为本地歌单。\n"
  },
  {
    "path": "apps/docs/docs/guides/playlist.md",
    "content": "---\ntitle: 歌单\neditLink: true\n---\n\n# 歌单\n\nBBPlayer 同时支持在线和本地两种模式，所以歌单系统设计的有那么一点点绕。不过我觉得读完这篇指南，或许就差不多了。\n\n大体上看，歌单分为两大类：**在线歌单**和**本地歌单**。具体表现在「音乐库」页面中，就是这么几个图标：\n\n![音乐库 tab](./attachments/library_header.jpg)\n\n「播放列表」即为本地歌单，后面三个则为在线歌单。\n\n## 在线歌单\n\n在**登录 bilibili 账号**的情况下，你可以直接使用 BBPlayer 播放你收藏夹、订阅合集中的内容。\n\n### 特点\n\n- **保持同步**：与 BiliBili 完全保持同步，收藏的内容会立刻在这里显示\n\n- **需要网络**：废话啦！因为是直接从 API 获取的，所以得有网才能看。\n\n- **无法编辑**：你可以随便听，但不能直接往里面加歌或者删歌。\n\n### 同步\n\n在线歌单的功能比较纯粹，主要是用来试听，我甚至没有加入「播放整个在线歌单」功能。如果你想对它进行更多操作（比如离线、编辑），可以随时把它「同步」成一个本地歌单。\n\n### 「分 p」？\n\n本质上是一个特殊的收藏夹。当你在 BiliBili 创建一个以 `[mp]` 开头的收藏夹时，它里面的所有视频都会被 BBPlayer 视为分 p 视频。点击后不再直接播放，而是显示视频详细信息。（同时该收藏夹也不会再在「收藏夹」页面中出现）\n\n## 本地歌单\n\n> [!NOTE]\n> **离线状态**：无网络时打开本地歌单，列表会自动进入离线模式——只有已下载或被自动缓存（最近播放过的）的曲目可以正常点击播放，其余曲目会变灰并无法选择。\n\n对于本地歌单，则又分为两种：\n\n### 第一种：从在线歌单直接同步来的\n\n当你在在线歌单点一下“同步”按钮创建本地歌单时，它就是这种情况。\n\n#### 特点\n\n- **增量更新**：你可以随时点击标题旁边的「同步」按钮增量更新歌单。\n\n- **离线听**：只要你把歌下载下来了，就算没网也能随时播放这个列表里的音乐。\n\n- **歌曲列表无法编辑**：因为它要和 BiliBili 保持同步，所以你不能手动往里面加歌或删歌。不过，歌单的名字、封面和描述，以及歌曲的显示名称，你都可以随便改。\n\n#### 标记\n\n这类歌单会显示一个「:cloud:」emoji，表示这是一个从 BiliBili 同步的歌单。\n![标记](./attachments/synced_playlist.jpg)\n\n### 第二种：完全本地歌单\n\n顾名思义，这就是传统意义上的歌单，你可以随意添加或删除任何内容。\n\n#### 创建方式\n\n你可以点击「播放列表」页右上角的「+」按钮创建全新的本地歌单。或是在已同步歌单中点击「复制」按钮直接在该歌单基础上创建。\n\n### 动态合并歌单\n\n如果你想把多个歌单放在一起听，但又不想复制出一份固定内容，可以在「播放列表」页右上角点击「+」，选择「动态合并歌单」。\n\n创建时至少需要选择两个源歌单。BBPlayer 会创建一个新的动态歌单，并在你打开它时实时读取这些源歌单的内容：源歌单后来新增、删除或调整歌曲后，动态歌单显示的内容也会随之变化。重复歌曲会自动去重，前面源歌单中的歌曲优先保留。\n\n> [!IMPORTANT]\n> 动态合并歌单是只读视图。你可以播放、搜索、下载其中的歌曲，也可以把它复制为普通本地歌单；但不能在动态歌单里直接添加、删除、排序歌曲，也不能把动态歌单同步到 B 站或设为共享歌单。要修改内容，请回到对应的源歌单操作。\n\n### 2025.11.9 更新——新增「稍后再看」歌单\n\n在 BBPlayer v1.4.0 以上的版本中，当你登录 b 站账号后会出现一个置顶的播放列表，它与你 b 站的「稍后再看」列表同步。\n"
  },
  {
    "path": "apps/docs/docs/guides/search.md",
    "content": "---\ntitle: 搜索\neditLink: true\n---\n\n# 搜索\n\n## 搜索框\n\n打开软件，便可以看到 BBPlayer 的搜索框。它被设计为支持识别多种链接、规则的聚合搜索器，具体来说，他支持以下这几种类型：\n\n1. `BV1GJ411x7h7` 或 `AV114514`：直接跳转到视频详情页\n\n2. 短链接（b23.tv/xxxxxx）：会在解析后根据实际链接动态跳转（会自动删除链接前后的无关文字，所以你可以把从 BiliBili 复制的分享文本直接粘贴过来）\n\n3. `https://space.bilibili.com/`：会根据后面跟随的参数不同动态决定\n   - 如果链接包含`ctype=21`, 则跳转到**合集页**（e.g. `https://space.bilibili.com/114514/favlist?fid=1919810&ctype=21`）\n   - 如果链接包含`ctype=11` 或不含`ctype`，则跳转到**收藏夹页**（e.g. `https://space.bilibili.com/114514/favlist?fid=1919810`）\n   - 如果链接包含`/lists/<id>`，则跳转到**合集页**（e.g. `https://space.bilibili.com/114514/lists/1919810`）\n   - 如果什么都不包含，则跳转到用户主页（e.g. `https://space.bilibili.com/114514`）\n\n4. 如果什么都没解析到，就作为关键词搜索\n\n看不懂没关系！只需要知道你所复制的大部分链接都可以直接扔到搜索框里，而 BBPlayer 会尽力猜你想访问什么！（其实是我在猜...😇）\n\n## Bilibili 移动端分享\n\n在 Bilibili 移动端上，你可以点击视频的分享按钮，在弹出菜单中右滑到底点击「更多」，选择「分享到 BBPlayer」，即可自动跳转到视频详情页：\n\n![菜单](./attachments/bilibili_share.jpg)\n"
  },
  {
    "path": "apps/docs/docs/guides/settings.md",
    "content": "---\ntitle: 设置与个性化\neditLink: true\n---\n\n# 设置与个性化\n\n每个人都有自己的听歌习惯，BBPlayer 提供了丰富的设置选项来满足你的需求。\n\n## 播放设置\n\n在 **设置 -> 播放设置** 中，你可以调整：\n\n- **恢复播放进度**：开启后，应用启动时会尝试恢复上次关闭时的播放进度。\n- **启动时自动播放**：配合上一条使用，打开 App 就接着听（小心社死）。\n- **响度均衡 (实验性)**：尝试利用 b 站的 API 数据将所有歌曲的音量统一到一个标准水平，避免切歌时音量忽大忽小。（可能会导致音质略微下降）\n- ......还有更多\n\n## 歌词设置\n\n在 **设置 -> 歌词设置** 中，你可以管理所有与歌词显示相关的选项：\n\n- **歌词源**：选择自动匹配歌词的来源（网易云/QQ音乐/酷狗音乐/自动）。\n- **逐字歌词**：开启或关闭歌词的精确逐字滚动。\n- **歌词罗马音与翻译**：控制是否显示对应的内容。\n- **桌面歌词**：开启并配置悬浮窗歌词。\n- **状态栏歌词 (实验性)**：配合 Xposed 插件，在系统状态栏显示歌词。[详见安装指南](./lyrics.md#status-bar-lyric)\n"
  },
  {
    "path": "apps/docs/docs/guides/shared-playlist.md",
    "content": "# 共享歌单与协同编辑\n\nBBPlayer 提供了强大的共享歌单与协同编辑功能，让你可以与朋友一起分享和管理音乐。\n\n> [!CAUTION]\n> 该功能目前处于早期阶段，不会导致你的数据损坏，但可能会存在一些体验问题，请酌情考虑～ \\\n> 共享歌单功能需要登录 Bilibili 账号用于验证你的身份。\n\n## 开启共享\n\n如果你想将自己的本地歌单分享给其他人，可以开启歌单共享功能。\n\n1. 在歌单详情页，点击菜单中的“开启共享”选项。\n2. **身份验证**：为了防止滥用，开启共享需要验证你的身份。系统会上传你的 Bilibili Cookie 以确认你是真实用户（BBPlayer 后端完全开源，你可以随时审计相关代码）。\n3. **获取链接**：开启成功后，你将获得两种链接：\n   - **订阅链接（只读）**：发给朋友后，对方可以订阅并收听此歌单，但不能修改。\n   - **协作编辑邀请链接**：包含特殊的邀请码，通过此链接订阅的朋友将获得编辑权限，可以与你一起添加、删除或排序歌曲。\n4. **重置邀请码**：如果邀请码泄露，你可以随时点击“重置协作编辑邀请链接”生成新的邀请码。\n\n> **注意**：目前版本中，歌单一旦开启共享便无法撤销，请谨慎操作。\n\n## 订阅共享歌单\n\n你可以通过朋友分享的链接来订阅他们的歌单。分为两种方式：\n\n### 简单方法\n\n直接点开链接，点击网页中的「在 BBPlayer 中订阅」就会自动打开 BBPlayer 并引导订阅歌单。\n\n### 手动操作\n\n1. 在「音乐库」页面点击右上角的加号，选择「订阅共享歌单」\n2. 粘贴对方分享的链接或歌单 ID。\n3. 如果链接中包含编辑者邀请码，系统会自动填充；你也可以手动输入邀请码以获取编辑权限。\n4. 点击“订阅”前，你可以预览歌单的基本信息（如创建者、歌曲数量）以及前 30 首歌曲。\n5. 确认无误后，点击“订阅共享歌单”即可将其添加到你的库中。\n\n## 角色与权限\n\n共享歌单中有三种不同的角色，对应不同的权限：\n\n- **创建者 (Owner)**：拥有最高权限。可以修改歌单信息（标题、描述、封面）、添加/删除/排序歌曲、以及重置编辑者邀请码。\n- **编辑者 (Editor)**：通过协作邀请链接加入的用户。可以添加、删除和重新排序歌曲，但不能修改歌单的基本信息。\n- **订阅者 (Subscriber)**：通过普通订阅链接加入的用户。仅具有只读权限，可以同步和播放歌单中的歌曲，无法进行任何修改。\n\n## 同步与冲突处理\n\n- **多端同步**：共享歌单的更改（如添加新歌、删除歌曲、调整顺序）会自动同步到云端。其他订阅者在打开歌单时，会获取到最新的歌单内容。\n- **离线支持**：你可以将共享歌单中的歌曲下载到本地，即使在没有网络的情况下也能正常播放。\n- **冲突处理**：在多人同时编辑歌单时，BBPlayer 后端采用了 **LWW** 策略来解决冲突，确保歌单状态的一致性。\n"
  },
  {
    "path": "apps/docs/docs/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: 'BBPlayer'\n  text: '更纯粹的 BiliBili 听歌方式'\n  tagline: '轻量、开源、不妥协。让 B 站音频回归它应有的样子。'\n  actions:\n    - theme: brand\n      text: 快速上手\n      link: /guides\n    - theme: alt\n      text: 下载安装\n      link: /guides/install\n    - theme: alt\n      text: 去 GitHub 看看\n      link: https://github.com/roitium/bbplayer\n\nfeatures:\n  - icon: ✨\n    title: 精致界面\n    details: 基于 Material Design 3 设计，简洁、流畅且耐看。\n\n  - icon: 🔄\n    title: 在线与本地\n    details: 无缝同步 B 站收藏夹与订阅合集，亦可自由构建个人本地歌单。\n    link: /guides/playlist\n    linkText: 深入了解\n\n  - icon: 🔍\n    title: 智能搜索\n    details: 聚合歌曲搜索与 B 站链接解析，支持 BV/AV 号及短链自动识别。\n    link: /guides/search\n    linkText: 深入了解\n\n  - icon: 🎤\n    title: 极致歌词\n    details: 智能匹配、逐字进度、罗马音注音及双语翻译，完美支持 SPL 规范。\n    link: /guides/lyrics\n    linkText: 深入了解\n\n  - icon: ⬇️\n    title: 下载与导出\n    details: 支持缓存单曲或整个歌单，并可导出为带封面与内嵌歌词的 m4a 文件，随时随地享受音频。\n    link: /guides/download\n    linkText: 深入了解\n\n  - icon: ⭐\n    title: 开源共建\n    details: 欢迎 Star 支持。如有改进建议，欢迎提交 Issue 或贡献 PR。\n    link: https://github.com/bbplayer-app/bbplayer\n    linkText: 前往 GitHub\n\n  - icon: ⚛️\n    title: btw, I use React Native\n    details: 基于 React Native 与 Expo 构建。\n---\n"
  },
  {
    "path": "apps/docs/docs/public/.well-known/assetlinks.json",
    "content": "[\n\t{\n\t\t\"relation\": [\"delegate_permission/common.handle_all_urls\"],\n\t\t\"target\": {\n\t\t\t\"namespace\": \"android_app\",\n\t\t\t\"package_name\": \"com.roitium.bbplayer\",\n\t\t\t\"sha256_cert_fingerprints\": [\n\t\t\t\t\"DD:DE:56:26:1C:CB:62:DC:F8:11:75:16:49:9E:04:FF:E9:DD:F3:1E:59:BA:4C:B8:0E:1D:04:7F:D6:97:79:36\"\n\t\t\t]\n\t\t}\n\t},\n\t{\n\t\t\"relation\": [\"delegate_permission/common.handle_all_urls\"],\n\t\t\"target\": {\n\t\t\t\"namespace\": \"android_app\",\n\t\t\t\"package_name\": \"com.roitium.bbplayer.dev\",\n\t\t\t\"sha256_cert_fingerprints\": [\n\t\t\t\t\"75:43:E1:8A:C5:F8:82:76:67:F6:DD:7E:87:3D:78:FD:6A:02:BC:12:16:35:C4:11:AC:EA:E6:DC:3B:E2:F8:C2\"\n\t\t\t]\n\t\t}\n\t},\n\t{\n\t\t\"relation\": [\"delegate_permission/common.handle_all_urls\"],\n\t\t\"target\": {\n\t\t\t\"namespace\": \"android_app\",\n\t\t\t\"package_name\": \"com.roitium.bbplayer.preview\",\n\t\t\t\"sha256_cert_fingerprints\": [\n\t\t\t\t\"BD:36:EF:A3:91:05:49:8F:79:1B:3A:88:23:36:5A:36:3B:BA:29:EE:88:F4:ED:E1:33:FA:C6:6F:5E:65:C5:03\"\n\t\t\t]\n\t\t}\n\t}\n]\n"
  },
  {
    "path": "apps/docs/docs/share/playlist.md",
    "content": "---\nlayout: false\ntitle: 共享歌单\n---\n\n<script setup>\nimport SharePlaylistPage from '../.vitepress/components/SharePlaylistPage.vue'\n</script>\n\n<SharePlaylistPage />\n"
  },
  {
    "path": "apps/docs/docs/share/track.md",
    "content": "---\nlayout: false\ntitle: 分享曲目\n---\n\n<script setup>\nimport ShareTrackPage from '../.vitepress/components/ShareTrackPage.vue'\n</script>\n\n<ShareTrackPage />\n"
  },
  {
    "path": "apps/docs/env.d.ts",
    "content": "/// <reference types=\"vitepress/client\" />\n\ndeclare module '*.vue' {\n\timport type { DefineComponent } from 'vue'\n\t// oxlint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any\n\tconst component: DefineComponent<{}, {}, any>\n\texport default component\n}\n"
  },
  {
    "path": "apps/docs/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/docs\",\n\t\"scripts\": {\n\t\t\"docs:build\": \"vitepress build docs\",\n\t\t\"docs:dev\": \"vitepress dev docs\",\n\t\t\"docs:preview\": \"vitepress preview docs\"\n\t},\n\t\"devDependencies\": {\n\t\t\"lucide-vue-next\": \"^0.562.0\",\n\t\t\"vitepress\": \"2.0.0-alpha.12\",\n\t\t\"vue\": \"^3.5.27\"\n\t}\n}\n"
  },
  {
    "path": "apps/docs/tsconfig.json",
    "content": "{\n\t\"extends\": \"../../tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"module\": \"ESNext\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"jsx\": \"preserve\",\n\t\t\"esModuleInterop\": true,\n\t\t\"lib\": [\"ESNext\", \"DOM\", \"DOM.Iterable\"],\n\t\t\"types\": [\"vitepress/client\"]\n\t},\n\t\"include\": [\"env.d.ts\", \"docs/**/*\", \"docs/.vitepress/**/*\"],\n\t\"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "apps/mobile/.gitignore",
    "content": "\n# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb\n# The following patterns were generated by expo-cli\n\nexpo-env.d.ts\n# @end expo-cli\n\n# google-services.json\n# GoogleService-Info.plist\ngoogle-services.real.json\nGoogleService-Info.real.plist\ngoogle-services-*.json\nGoogleService-Info-*.plist\nsrc/lib/api/bilibili/proto/dm.js\nsrc/lib/api/bilibili/proto/dm.d.ts\nsrc/lib/api/bilibili/proto/dm.ts"
  },
  {
    "path": "apps/mobile/.maestro/comments_flow.yaml",
    "content": "appId: com.roitium.bbplayer\nenv:\n  COOKIE: ${COOKIE}\n---\n- runFlow: common/setup.yaml\n\n# 7. Comments & Image Viewer\n- tapOn:\n    id: 'search-bar'\n- inputText: 'BV17B6iBeEya'\n- pressKey: Enter\n\n# Wait for search results\n- extendedWaitUntil:\n    visible:\n      id: 'track-item-.*'\n    timeout: 10000\n\n# Tap the first item (should be the video)\n- tapOn:\n    id: 'track-item-.*'\n    index: 0\n\n- runFlow:\n    when:\n      visible: '替换播放列表'\n    commands:\n      - tapOn: '确定'\n\n# Open Player\n- runFlow: common/open_player.yaml\n\n# Open Comments\n- tapOn:\n    id: 'player-open-comments'\n\n# Wait for comments to load\n- extendedWaitUntil:\n    visible: '评论区'\n    timeout: 10000\n\n# Find an image to click\n# We scroll down a bit if needed, but usually we just look for the testID\n- scrollUntilVisible:\n    element:\n      id: 'comment-image'\n    direction: DOWN\n    timeout: 10000\n\n- tapOn:\n    id: 'comment-image'\n    index: 0\n"
  },
  {
    "path": "apps/mobile/.maestro/common/open_player.yaml",
    "content": "appId: com.roitium.bbplayer\n---\n- runFlow:\n    when:\n      notVisible:\n        id: 'player-cover'\n    commands:\n      - tapOn:\n          id: 'now-playing-bar'\n      - assertVisible:\n          id: 'player-cover'\n"
  },
  {
    "path": "apps/mobile/.maestro/common/setup.yaml",
    "content": "appId: com.roitium.bbplayer\nenv:\n  COOKIE: ${COOKIE}\n---\n- launchApp:\n    clearState: false\n- waitForAnimationToEnd:\n    timeout: 10000\n\n# 1. Onboarding\n- runFlow:\n    when:\n      visible: '下一步'\n    commands:\n      - tapOn: '下一步'\n      - tapOn: '游客模式'\n\n# 2. Login via Cookie\n# Navigate to Settings -> General\n- tapOn: '设置'\n- tapOn: '通用'\n- assertVisible:\n    id: 'cookie-login-button'\n\n- runFlow:\n    when:\n      visible:\n        id: 'cookie-login-button'\n    commands:\n      - tapOn:\n          id: 'cookie-login-button'\n      - tapOn:\n          id: 'cookie-input'\n      - copyTextFrom:\n          id: 'cookie-input'\n      - runFlow:\n          when:\n            true: ${maestro.copiedText == ''}\n          commands:\n            - tapOn:\n                id: 'cookie-input'\n            - inputText: ${COOKIE}\n            - tapOn:\n                id: 'cookie-login-confirm'\n            # Wait for login processing and toast\n            - waitForAnimationToEnd\n            - assertVisible: 'Cookie 已更新'\n      - runFlow:\n          when:\n            true: ${maestro.copiedText != ''}\n          commands:\n            - tapOn:\n                id: 'cookie-login-confirm'\n\n# Return to Home (optional, but good for consistent state)\n- tapOn: '主页'\n"
  },
  {
    "path": "apps/mobile/.maestro/playback_flow.yaml",
    "content": "appId: com.roitium.bbplayer\nenv:\n  COOKIE: ${COOKIE}\n---\n# Pre-requisite: Play something. We reuse search_flow for this.\n- runFlow: search_flow.yaml\n\n# 5. Playback Controls\n- runFlow: common/open_player.yaml\n\n# Pause\n- tapOn:\n    id: 'player-play-pause'\n# Verify paused state (optional, might be hard to verify icon change without ID, but we can verify UI didn't crash)\n\n# Play\n- tapOn:\n    id: 'player-play-pause'\n\n# Next\n- tapOn:\n    id: 'player-next'\n\n# Prev\n- tapOn:\n    id: 'player-prev'\n\n# Shuffle\n- tapOn:\n    id: 'player-mode-shuffle'\n\n# Repeat\n- tapOn:\n    id: 'player-mode-repeat'\n"
  },
  {
    "path": "apps/mobile/.maestro/playlist_flow.yaml",
    "content": "appId: com.roitium.bbplayer\nenv:\n  COOKIE: ${COOKIE}\n---\n- runFlow: common/setup.yaml\n\n# 6. Local Playlist Management\n- tapOn: '音乐库'\n# Ensure we are on Local tab (swipe right to be safe? Default is Local)\n- swipe:\n    direction: RIGHT\n\n# Create Playlist\n- tapOn:\n    id: 'create-playlist-button'\n\n- tapOn:\n    id: 'create-playlist-title-input'\n- inputText: 'Test Playlist'\n\n- tapOn: '确定'\n\n# Verify Created\n- extendedWaitUntil:\n    visible: 'Test Playlist'\n    timeout: 5000\n\n# Enter Playlist\n- tapOn: 'Test Playlist'\n\n# Verify Header\n- assertVisible: 'Test Playlist'\n\n- tapOn: '播放全部'\n"
  },
  {
    "path": "apps/mobile/.maestro/search_flow.yaml",
    "content": "appId: com.roitium.bbplayer\nenv:\n  COOKIE: ${COOKIE}\n---\n- runFlow: common/setup.yaml\n\n# 4. Search & Play\n- tapOn:\n    id: 'search-bar'\n- inputText: 'Bilibili'\n# Wait for suggestions\n- extendedWaitUntil:\n    visible:\n      id: 'search-suggestion-0'\n    timeout: 5000\n- tapOn:\n    id: 'search-suggestion-0'\n\n# Wait for search results (External Playlist or Tracks)\n# The search result page usually lists tracks.\n- extendedWaitUntil:\n    visible:\n      id: 'track-item-.*'\n    timeout: 10000\n\n# Play the first track\n- tapOn:\n    id: 'track-item-.*'\n    index: 0\n\n- runFlow:\n    when:\n      visible: '替换播放列表'\n    commands:\n      - tapOn: '确定'\n\n# Player should open\n- runFlow: common/open_player.yaml\n"
  },
  {
    "path": "apps/mobile/.maestro/sync_flow.yaml",
    "content": "appId: com.roitium.bbplayer\nenv:\n  COOKIE: ${COOKIE}\n---\n- runFlow: common/setup.yaml\n\n# 3. Library & Favorites Sync\n- tapOn: '音乐库'\n- swipe:\n    direction: LEFT\n# Wait for list to render\n- extendedWaitUntil:\n    visible:\n      id: 'favorite-folder-.*'\n    timeout: 10000\n\n# Enter first favorite folder\n- tapOn:\n    id: 'favorite-folder-.*'\n    index: 0\n\n# Confirm render\n- assertVisible:\n    id: 'playlist-header-main-button'\n\n# Click Sync\n- tapOn:\n    id: 'playlist-header-main-button'\n\n# Wait for \"同步完成\"\n- extendedWaitUntil:\n    visible: '同步完成'\n    timeout: 60000\n- tapOn: '关闭'\n\n# Now we should see tracks in the list\n- tapOn:\n    id: 'track-item-.*'\n    index: 0\n\n- runFlow:\n    when:\n      visible: '替换播放列表'\n    commands:\n      - tapOn: '确定'\n\n# Player should open\n- runFlow: common/open_player.yaml\n\n# Open Lyrics\n- tapOn:\n    id: 'player-cover'\n- assertVisible:\n    id: 'player-lyrics-view'\n"
  },
  {
    "path": "apps/mobile/AGENTS.md",
    "content": "# BBPlayer Mobile App\n\n**Location:** `apps/mobile/`\n**Type:** React Native (Expo) Application\n\n---\n\n## OVERVIEW\n\nMain BBPlayer mobile application. Bilibili audio player with offline playback, lyrics, and Material Design 3 UI.\n\n**Entry Point:** `index.js` (initializes Orpheus native module before expo-router)\n\n---\n\n## STRUCTURE\n\n```\nsrc/\n├── app/                    # Expo Router routes (file-based)\n│   ├── _layout.tsx        # Root layout (providers, Sentry)\n│   ├── (tabs)/            # Tab navigation\n│   │   ├── index.tsx      # Home screen\n│   │   ├── library/       # Library tab\n│   │   └── settings/      # Settings tab\n│   ├── player.tsx         # Full-screen player\n│   ├── playlist/          # Playlist routes\n│   ├── comments/          # Comments view\n│   └── settings/          # Settings sub-pages\n├── components/            # Shared UI components\n├── features/              # Domain-organized modules\n│   ├── player/           # Player UI components\n│   ├── playlist/         # Playlist management\n│   ├── home/             # Home screen features\n│   ├── downloads/        # Download management\n│   └── library/          # Library features\n├── hooks/                 # Global hooks\n│   ├── stores/           # Zustand stores\n│   ├── queries/          # TanStack Query hooks\n│   ├── mutations/        # TanStack Query mutations\n│   └── player/           # Player-specific hooks\n├── lib/                   # Business logic\n│   ├── api/bilibili/     # Bilibili API integration\n│   ├── db/               # Drizzle ORM schema\n│   ├── facades/          # Facade layer (transactions)\n│   ├── services/         # Service layer (domain logic)\n│   └── workers/          # Background workers\n├── types/                 # TypeScript definitions\n└── utils/                 # Utility functions\n```\n\n---\n\n## WHERE TO LOOK\n\n| Task                 | Location                                            | Notes                          |\n| -------------------- | --------------------------------------------------- | ------------------------------ |\n| **Routes/Screens**   | `src/app/`                                          | Expo Router file-based routing |\n| **Navigation**       | `src/app/_layout.tsx`, `src/app/(tabs)/_layout.tsx` | Stack + Tabs configuration     |\n| **Player UI**        | `src/features/player/`                              | Player controls, lyrics        |\n| **State Management** | `src/hooks/stores/`                                 | Zustand stores                 |\n| **Data Fetching**    | `src/hooks/queries/`, `src/hooks/mutations/`        | TanStack Query                 |\n| **Database**         | `src/lib/db/`                                       | Drizzle schema + migrations    |\n| **Business Logic**   | `src/lib/facades/`, `src/lib/services/`             | Facade + Service pattern       |\n| **Bilibili API**     | `src/lib/api/bilibili/`                             | API clients + protobuf         |\n\n---\n\n## CONVENTIONS\n\n### Import Aliases\n\n```typescript\n// Use @/* for all imports (enforced by ESLint)\nimport { Player } from '@/components/player'\nimport { usePlayerStore } from '@/hooks/stores/usePlayerStore'\n\n// NOT relative paths\n// import { Player } from '../components/player'  // ❌\n```\n\n### Expo Router Patterns\n\n```typescript\n// Route params typed via hooks\nimport { useLocalSearchParams } from 'expo-router'\nconst { id } = useLocalSearchParams<{ id: string }>()\n\n// Navigation\nimport { router } from 'expo-router'\nrouter.push('/playlist/local')\nrouter.back()\n```\n\n### FlashList (MANDATORY)\n\n```typescript\n// Define OUTSIDE component - NOT inside, NOT useCallback\nconst renderPlaylistItem = ({ item }: { item: Playlist }) => (\n  <PlaylistItem playlist={item} />\n)\n\n// In component:\n<FlashList\n  data={playlists}\n  renderItem={renderPlaylistItem}\n  extraData={useMemo(() => ({ selectedId }), [selectedId])}\n/>\n```\n\n### Zustand Stores\n\n```typescript\n// Separate stores by domain\nimport usePlayerStore from '@/hooks/stores/usePlayerStore'\nimport useAppStore from '@/hooks/stores/useAppStore'\n\n// Store file pattern: use<Domain>Store.ts\n```\n\n### TanStack Query\n\n```typescript\n// Query hook pattern: src/hooks/queries/<domain>/use<Name>.ts\nexport function usePlaylistQuery(id: string) {\n\treturn useQuery({\n\t\tqueryKey: ['playlist', id],\n\t\tqueryFn: () => fetchPlaylist(id),\n\t})\n}\n\n// Mutation hook pattern: src/hooks/mutations/<domain>/use<Name>.ts\nexport function useCreatePlaylistMutation() {\n\treturn useMutation({\n\t\tmutationFn: createPlaylist,\n\t})\n}\n```\n\n---\n\n## ANTI-PATTERNS\n\n### 🚫 NEVER\n\n- Use Expo Go - requires custom dev build\n- Define FlashList `renderItem` inside component\n- Throw errors in Facades/Services (use neverthrow)\n- Use `console.log` (enforced by oxlint)\n\n### ⚠️ CAUTION\n\n- iOS is \"birth without nurture\" - Android focus\n- Bilibili Multi-P videos may have duplicate records\n- MMKV migration code in `useAppStore.ts` - don't remove\n- 27 `@ts-expect-error` workarounds exist - read comments before changing\n\n---\n\n## UNIQUE STYLES\n\n### Facade + Service Architecture\n\n```typescript\n// Facade (lib/facades/playlistFacade.ts)\nexport async function addTrackToPlaylist(\n\tplaylistId: string,\n\ttrack: Track,\n): Promise<Result<void, Error>> {\n\treturn db.transaction(async (tx) => {\n\t\t// Orchestrates multiple services\n\t\tconst trackResult = await TrackService.createTrack(tx, track)\n\t\tif (trackResult.isErr()) return err(trackResult.error)\n\n\t\treturn PlaylistService.addTrack(tx, playlistId, trackResult.value.id)\n\t})\n}\n\n// Service (lib/services/trackService.ts)\nexport const TrackService = {\n\tasync createTrack(tx, track) {\n\t\t// Single domain logic, DB access\n\t\treturn ok(await tx.insert(tracks).values(track))\n\t},\n}\n```\n\n### Error Handling with neverthrow\n\n```typescript\nimport { ok, err, Result } from 'neverthrow'\n\n// Always return Result, never throw\nasync function fetchData(): Promise<Result<Data, Error>> {\n\ttry {\n\t\tconst data = await api.getData()\n\t\treturn ok(data)\n\t} catch (e) {\n\t\treturn err(new ApiError('Failed to fetch', e))\n\t}\n}\n\n// Caller must handle both cases\nconst result = await fetchData()\nif (result.isErr()) {\n\t// Handle error\n}\n```\n\n### Custom Hooks Pattern\n\n```typescript\n// src/hooks/player/useCurrentTrack.ts\nexport function useCurrentTrack() {\n\tconst { currentTrackId } = usePlayerStore()\n\treturn useQuery({\n\t\tqueryKey: ['track', currentTrackId],\n\t\tqueryFn: () => TrackService.getById(currentTrackId),\n\t\tenabled: !!currentTrackId,\n\t})\n}\n```\n\n---\n\n## COMMANDS\n\n```bash\n# Development\ncd apps/mobile\npnpm start              # Start Metro (WITH_ROZENITE=true)\npnpm android            # Build & run Android\n\n# Building (requires VERSION_CODE)\nVERSION_CODE=$(git rev-list --count HEAD) \\\n  eas build --profile dev --platform android --local\n\n# Testing\npnpm test               # Jest watch mode\n\n# Database\npnpm db:generate        # Drizzle generate migrations\npnpm db:migrate         # Run migrations\npnpm db:studio          # Drizzle Studio\n\n# Protobuf\npnpm prepare            # Regenerate proto files\n```\n\n---\n\n## NOTES\n\n### Rozenite Metro Plugins\n\nCustom plugins in `metro.config.js`:\n\n- `@rozenite/mmkv-plugin` - MMKV optimization\n- `@rozenite/tanstack-query-plugin` - Query profiling\n- `@rozenite/require-profiler-plugin` - Bundle analysis\n\n### Environment Variables\n\nRequired for builds:\n\n- `VERSION_CODE` - Build version (use `git rev-list --count HEAD`)\n- `SENTRY_AUTH_TOKEN` - For production builds\n\n### Firebase\n\n- Mock configs in `assets/config/google-services/`\n- Real configs: `google-services.real.json`, `GoogleService-Info.real.plist`\n\n### Development Build\n\nExpo Go won't work - must use custom dev build:\n\n```bash\neas build --profile dev --platform android --local\n```\n\n### iOS Limitations\n\nNot actively maintained. Missing features:\n\n- Desktop lyrics (impossible)\n- Spectrum visualizer\n- Seamless playback\n- Loudness normalization\n- Cover download for offline\n"
  },
  {
    "path": "apps/mobile/CHANGELOG.md",
    "content": "# Changelog\n\n项目的所有显著更改都将记录在这个文件中。\n\n项目的 CHANGELOG 格式符合 [Keep a Changelog]，\n且版本号遵循 [Semantic Versioning]。 ~~(然而，事实上遵循的是 [Pride Versioning])~~\n\n## [2.4.5] - 2026-05-09\n\n### Changed\n\n- Orpheus: 优化歌词系统架构\n\n### Added\n\n- 支持车载歌词（Android：通过把当前歌词写入 MediaMetadata.title，在蓝牙 AVRCP 车机上显示）\n- 支持自动下载新版并安装\n\n### Fixed\n\n- 修复因为开发者脑子进水导致的又一次无法上传播放记录的问题\n\n## [2.4.4] - 2026-04-18\n\n### Fixed\n\n- 修复随机播放功能开销过大导致卡死的问题\n- 修复离线模式下播放器进入错误状态后无法改出的问题\n- 移除预加载歌词功能\n- 修复部分手机阉割了 SAF 框架导致无法选择导出目录的问题（默认导出到 Music/BBPlayer）\n- 修复上报播放记录到 b 站功能不可用的问题\n- 支持在下载页面播放歌曲\n- 修复最近播放页面点击歌曲无法播放的问题\n- 修复从外部播放列表同步时无法恢复的问题\n\n### Changed\n\n- 把 player 相关监听器注册统一封装到 PlayerSideEffects 中\n- Orpheus: 不再在主线程上运行所有异步函数，只把 player 调用部分放在主线程\n\n## [2.4.3]\n\n### Added\n\n- 主页面集成播放历史热力图（GitHub 风格），展示每日播放统计\n- 完全重构主页\n- 桌面歌词坐标记忆功能（Y 坐标持久化）\n- 歌词预加载下一首功能，提升切歌体验\n- 歌单合并功能，支持多选本地歌单并去重合并\n- 桌面/状态栏歌词在歌词修改或偏移调整时自动同步更新\n- 桌面歌词面板新增「清空歌词」快捷按钮，点击后跳过该曲目的歌词自动获取，并在应用内显示提示；用户可随时通过手动搜索或编辑歌词来重新启用\n- 无歌词（包括已跳过/未找到）时，自动隐藏桌面歌词面板和状态栏歌词，而非显示空白\n- orpheus：重构随机播放模式，开启时直接将播放队列替换为随机后的顺序（当前歌曲置顶），播完一轮后自动重新打乱\n\n### Changed\n\n- 播放器主页标题平滑渐变效果重构为独立 Hook，实现 UI 与动画逻辑解耦\n- 重构手机登录模块，采用自定义 Hook (`usePhoneLogin`) 与分步组件化架构，大幅简化状态逻辑并提升可维护性\n- 新增 `useGeetest` Hook：将极验验证与发送验证码逻辑独立，实现验证逻辑的解耦与复用\n- 模块化 `PlaylistSyncWorker`，解耦复杂的同步逻辑与 API 请求处理\n- 为 `lyricService`、`lottie` 及 `crypto` 中的 `JSON.parse` 调用增加安全处理，防止非法数据导致崩溃\n- 清理项目内多处未使用的导入及变量，优化代码体积\n\n## [2.4.2] - 2026-03-12\n\n### Added\n\n- 支持 Lyricon 作为状态栏歌词后端\n- 支持桌面歌词显示罗马音/翻译、逐字歌词\n\n## [2.4.1] - 2026-03-01\n\n### Changed\n\n- 歌单支持同名，不再进行同名判断\n\n### Added\n\n- 歌单共享、协同编辑功能\n- 状态栏歌词\n- 导出歌曲\n\n## [2.3.2] - 2023-02-25\n\n### Added\n\n- 为设置页面的所有子页面增加 NowPlayingBar\n- 支持显示本地歌单播放完成所需的总时长\n- orpheus：支持歌曲封面与音频同步下载及清理，支持补齐缺失封面，提升无网播放体验\n- orpheus：引入全局图片本地缓存机制（基于 Glide 默认 LRU 策略，上限默认 250MB）\n- 优化无网状态下的本地播放列表显示逻辑，高亮已下载和自动缓存的歌曲\n- 本地播放列表支持拖拽排序：在多选模式下长按右侧拖拽手柄即可拖动曲目，自动滚动，松手后持久化新顺序\n\n### Changed\n\n- 播放器主页的主控制按钮替换为 Lottie 动画，并支持乐观更新状态\n- 本地播放列表排序从整数 `order` 迁移至 Fractional Indexing（字符串键），排序时只更新单行，无需全量位移；旧数据启动时自动迁移\n- orpheus：优化媒体通知的构建逻辑，优先加载本地已下载的封面图片\n- orpheus：优化播放器生命周期，在实例被销毁后重新点击播放时自动触发重建\n- 重构弹幕加载逻辑，避免无网或弱网状态下无限加载\n- 优化歌词加载策略：无网络且无本地缓存时直接返回未找到，不再发起无效网络请求\n- 替换 Material 3 动态颜色获取方案，由 `@pchmn/expo-material3-theme` 迁移至 Expo Router 内置的 `Color` API\n- 优化 Sentry 异常上报规则，屏蔽播放器非关键性错误（如 Bilibili API 异常或常规网络错误）\n- 替换 `react-native-paper` 按钮组件的底层实现为 RNGH 组件，提升交互性能\n- 调整 protobuf 编译流程，将生成脚本移至 `prepare` 阶段，实现依赖安装时自动生成 `dm.js` 与 `dm.d.ts`\n- 恢复播放器页面的滑动交互样式\n- 重构歌词页面，底层使用 ScrollView 以提升滚动表现\n- 重构首页用户信息的展示逻辑\n- 重构设置页面路由结构，将其作为独立 stack 页面\n\n### Fixed\n\n- 修复本地播放列表在分页未加载完成时，将歌曲拖拽到当前列表底部会导致其被移动到全列表底部的问题\n- 修复播放器主控件 Lottie 图标 `colorFilters` 不生效（始终显示红色）的问题，根本原因是 JSON 文件中 Stroke 图层颜色硬编码为红色且 lottie-react-native 对 Stroke 图层的 colorFilters 支持有限，已将三个 Lottie JSON 的 Stroke 颜色改为白色以使主题色正确叠加\n- 修复应用启动后断网导致本地播放列表和数据触发无限加载的问题\n- orpheus：修复桌面歌词锁定后重启应用，导致歌词无法移动且阻挡底层点击操作的问题\n- 修复 `b23.tv` 短链接解析失败的问题（调整为从 HTML 响应中提取目标链接）\n- 修复在开启系统三键导航的设备上，播放器底部控件可能与系统导航栏重叠的问题\n- 修复获取网易云音乐歌单时，因 `playlist` 或 `creator` 等字段缺失引起的闪退\n- 修复连续点击导致的分享失败问题，并补充了分享按钮的加载状态反馈\n- orpheus：修复播放器因数据 (`data`) 为空时引发的解析异常\n- 修复播放器页面底部偶现的异常白块问题\n- 修复无网状态下，频繁弹出网络报错提示的问题\n- orpheus：修复桌面歌词拖拽边界判定失效的问题，防止歌词被拖入状态栏区域导致无法触达\n- orpheus：修复 `onDestroy` 方法在非预期线程执行的问题\n\n## [2.3.0] - 2026-02-07\n\n### Added\n\n- 基于 `react-native-gesture-handler` 封装了 `Button` 组件，样式与 `react-native-paper` 保持一致\n- 支持酷狗音乐歌词搜索\n- 集成 Firebase Analytics\n- 支持从 QQ 音乐 / 网易云音乐导入歌单并匹配 B 站视频\n- 为关键 UI 组件添加 `testID` 以支持 Maestro E2E 测试\n- 懒加载模态框加载时显示 `ActivityIndicator`\n- 支持双击播放列表顶部回到顶端\n- 实现播放器页面标题平滑渐变效果\n- 播放列表页面背景支持封面主题色\n- 支持下滑关闭播放器页面\n- 支持网易云罗马音及逐字歌词，并支持在翻译与罗马音间切换\n- 增加歌词编辑格式校验及行号错误提示\n- 支持在播放器页面显示弹幕\n\n### Changed\n\n- 优化数据库迁移检查，通过缓存 Schema 版本跳过冗余 SQL 查询\n- 移除 trackService 中的标题重复检查\n- 播放器网络库（orpheus）从 Cronet 切换至 OkHttp\n- 启用 R8 混淆并移除 reanimated 的 Static Flags\n- 重构 RootLayout 的 SplashScreen 显示逻辑\n- 增强播放器后台留存能力\n- 重构 `PlayerLyrics.tsx`，实现歌词偏移面板与解析逻辑解耦\n- 优化 `KaraokeWord` 组件性能，仅在当前行监听播放时间以减少冗余渲染\n- 优化频谱在暂停时的回落动画\n- 将 `eslint-plugin-modal` 移出 `apps/mobile` 并作为一个单独的包 `@bbplayer/eslint-plugin` 放在 `packages` 目录下\n- 将所有 `@roitium` 作用域的包迁移至 `@bbplayer` 作用域\n- 更新文档和 README，补充逐字歌词和歌词罗马音的功能说明\n- 重构设置页面，将歌词相关设置移动到独立的「歌词」分类中\n\n### Fixed\n\n- 修复单曲循环模式下播放完最后一首不循环的问题 (Thanks to @k88936 #199)\n- 修复 `reportErrorToSentry` 上报非 Error 类型错误时显示为 `[object Object]` 的问题\n- 修复 `DonationQRModal` 在部分 Android 设备上因导入方式错误导致的崩溃\n- 修复歌词搜索失败时错误上报 `FileSystemError` 到 Sentry 的问题\n- 修复 `ToastContext` 未初始化导致的应用崩溃\n- 修复因 Cookie 键名包含无效字符（如换行符）导致的崩溃，并增加自动修复提示\n- 修复播放列表结束后点击播放按钮无效的问题，现会从头开始播放\n- 修复 `external-sync` 和 `useExternalPlaylistSyncStore` 中的 React Compiler 优化跳过问题\n- 优化播放列表在屏幕较窄时的布局显示\n- 修复播放队列模态框中使用 `RectButton` 无法点击的问题，并移除删除按钮的涟漪效果\n- 修复播放器页面在部分小屏设备上无法滚动的问题\n- 优化播放器页面在小屏设备上的显示，支持滚动查看完整内容\n\n## [2.2.4] - 2026-01-30\n\n### Added\n\n- 显示频谱功能\n\n### Changed\n\n- 改为 monorepo\n- 将 TypeScript 及相关依赖统一管理到 root package.json\n- 使用 `@nandorojo/galeria` 替代 `react-native-awesome-gallery`\n- 使用 `react-native-fast-squircle` 替换主要 UI 元素的圆角矩形为 squircle\n- 统一列表项的设计风格（尺寸、圆角）\n- 将 `apps/bbplayer` 重命名为 `apps/mobile`\n\n### Fixed\n\n- 修复搜索播放列表时，错误地过滤了远程播放列表的问题\n- 修复播放器页面 ANR 问题\n\n## [2.2.3] - 2026-01-28\n\n### Added\n\n- 集成 commitlint 和 lefthook 以规范 commit 信息\n- 同步本地歌单到 b 站收藏夹（不稳定，容易被风控）\n- 收藏夹同步现在会显示详细的进度模态框\n- 对 IOS 进行基础的适配\n- 使用 useDeferredValue 优化本地播放列表、本地歌单详情页和首页搜索的输入响应速度\n- 使用 useTransition 优化音乐库 Tab 切换体验，减少卡顿感\n- 重构播放器 Hooks，使用全局 Zustand Store 管理播放状态，减少 JS 与 Native 之间的通信开销\n\n### Changed\n\n- 重构 `RemoteTrackList` 和 `LocalTrackList` 组件的 Props，将选择相关状态合并为 `selection` 对象，并直接继承 `FlashList` 的 Props以获得更好的灵活性\n- 使用 react-native-keyboard-controller 的 API 重构 AnimatedModalOverlay\n- 重构 `src/lib/api/bilibili/api.ts` 为 Class\n- 修复冷启动时 Deep Link 无法跳转的问题\n- 创建/修改歌曲或播放列表时，禁止使用重复的名称\n- 将 `app.bbplayer.roitium.com` 作为 Deep Link 的 host\n- 关闭 dolby / hires 音源\n- 启用 reanimated 的 Static Flags：`ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS`、`IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS`、`USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS`\n\n## [2.2.2] - 2026-01-25\n\n### Changed\n\n- 重构分享卡片组件，优化预览生成逻辑，并支持带有分 P 参数的分享链接\n- 支持播放器页面显示缓冲进度\n- 升级到 expo55-beta\n- 优化 version code 逻辑，使用 commit 数量作为 version code\n- 增加 nightly 构建\n- 切换到 sonner-native\n- 升级 expo-image-theme-colors 依赖到 0.2.1，支持传入图片 url 提取封面色\n- 升级 expo-orpheus 到 0.9.4，支持断开蓝牙时暂停播放\n\n### Added\n\n- prevent progress bar regression & add debounce to PlayButton (Thanks to @longlin10086 #153)\n- fix: update PlaySlide info after song's change (Thanks to @longlin10086 #159)\n- feat: add PlayControls overlay to LyricPage (Thanks to @longlin10086 #164)\n\n## [2.2.0] - 2026-01-23\n\n### Changed\n\n- 升级依赖\n\n### Added\n\n- 添加本地播放列表搜索功能\n- 为播放列表模态框增加遮罩（Thanks to @longlin10086 #146）\n- 支持跳转到分 p 视频播放列表时滚动并高亮指定分 p\n- 支持分享歌曲、歌词卡片\n- 使用 TrueSheet 替换 @gorhom/bottom-sheet\n- 部分下拉菜单重构为 bottom sheet 样式，更清晰\n\n## [2.1.9] - 2026-01-22\n\n### Fixed\n\n- BBPLAYER-5N\n\n### Changed\n\n- ci 增加构建 armabi-v7a、x86、x86_64 的工作流\n- 使用 React.lazy() 动态导入模态组件并用 Suspense 边界包装渲染\n\n### Added\n\n- 为 Playlist 和 Library 页面增加 Skeleton\n- 支持 qq 音乐作为歌词源\n- 搜索时高亮搜索结果中的关键字\n- 支持播放器页面播放速度调整\n- 支持将播放队列保存为播放列表\n\n## [2.1.8] - 2026-01-13\n\n### Added\n\n- 重新设计播放器进度条\n- 增加~~讨口子~~捐赠页面\n- 桌面歌词\n- 通知栏增加切换循环模式按钮\n- 尝试启用 dolby / hires 音源\n\n### Changed\n\n- 移除了未使用的依赖\n\n### Fixed\n\n- 修复登录二维码可能为空导致的报错\n- 修复部分 bilibili api 返回 data 为 null 导致的报错\n\n## [2.1.6] - 2026-01-06\n\n### Fixed\n\n- 再次尝试修复播放器页面卡顿问题（😭）\n- 尝试修复 `cannot use a recycled source in createBitmap` 错误（expo-orpheus@0.7.2）(然而问题依然存在)\n\n### Added\n\n- 新增启动时自动播放功能\n- 重构设置页面，增加二级目录，更简洁\n- 评论区功能\n\n### Changed\n\n- 升级了 expo 相关依赖库版本\n\n## [2.1.5] - 2025-12-31\n\n### Fixed\n\n- remove unexpected white space above bottom tabs (Thanks to @imoyy #107)\n- 修复歌曲播放完成后点击播放，无法重新播放的问题\n\n### Added\n\n- 增加 NowPlayingBar 底部沉浸样式 (Thanks to @imoyy #110)\n- 增加 NowPlayingBar 滑动手势操作 (Thanks to @imoyy #110)\n- 支持边下边播缓存\n\n## [2.1.4] - 2025-12-20\n\n### Added\n\n- 切换到 Orpheus 音频库，取代 RNTP\n\n### Fixed\n\n- 尝试修复播放器页面卡顿的问题\n\n## [1.4.3] - 2025-12-01\n\n### Added\n\n- 支持实验性响度均衡（默认不启用）\n- 支持在软件启动时恢复上次播放进度（默认不启用）\n\n### Fixed\n\n- **Refactored `PhoneLoginModal`** into a modular, hook-based architecture.\n- **`usePhoneLogin` FSM Refactor**: Further refined the login hook by implementing a **Finite State Machine (FSM)** using `useReducer`. This consolidated scattered state variables (like `isSendingCode`, `isLoggingIn`, and various error strings) into a single, predictable state object, reducing potential bugs from invalid state combinations.\n- **Refined with FSM**: Implemented a **Finite State Machine (FSM)** using `useReducer` within the hook to consolidate many `isXXX` and `xxError` variables into a single, predictable state object.\n- **`useGeetest` Hook Extraction**: Extracted the Geetest captcha parsing and SMS sending logic into a dedicated `useGeetest` hook. This further modularizes the authentication flow and makes the captcha logic reusable for other potential entry points.\n- Splitting the UI into modular step components: `InputPhoneStep`, `GeetestVerifyStep`, `InputCodeStep`, and `SuccessStep`.\n- **Decoupled database and store initialization** in `db.ts` to prevent startup race conditions.\n- 修复 `DatabaseLauncher has already started. Create a new instance in order to launch a new version.` 错误\n\n## [1.4.2] - 2025-11-09\n\n### Added\n\n- 完善「稍后再看」页面功能\n- 支持多种播放器背景风格——渐变、流光、默认 md3 固定背景\n- 支持在「开发者页面」设置热更新渠道\n- 增加了一些 Sentry Spans 埋点，试图提高项目可观测性\n\n### Changed\n\n- 优化歌词页面\n\n### Fixed\n\n- 修复合集 ps 过大，导致 api 返回数据错误的问题\n- 修复 Cover Placeholder 乱码问题\n- 不再尝试使用 dolby/hi-res 音源，避免 `android-failed-runtime-check` 错误\n\n## [1.4.0] - 2025-11-02\n\n### Added\n\n- 清除所有歌词缓存（在「开发者页面」）\n- 基于 B 站视频 bgm 识别结果精准搜索歌词\n- 切换到 expo-router\n- 改进了歌词页面与交互逻辑（灵感来自 Salt Player + Spotify，给前辈们磕头了咚咚咚）\n- 可通过播放器页的下拉菜单跳转视频详情页\n- 将 B 站「稍后再看」作为播放列表（置顶在「播放列表」页面）\n\n### Fixed\n\n- 一些减少 rerender 次数的优化\n- 使用 [react-native-paper/4807](https://github.com/callstack/react-native-paper/issues/4807) 中提到的 Menu 组件修复方法，移除 patch\n\n## [1.3.6] - 2025-10-26\n\n### Added\n\n- 给视频/播放列表封面加了个渐变 placeholder\n- 本地播放列表使用基于游标的无限滚动\n- 定时关闭功能\n- 点击通知可跳转到下载页面\n\n### Fixed\n\n- 对 NowPlayingBar 的 ProgressBar 的颜色和位置进行一点修复，更符合直觉\n- 直接在 Sentry.init 中忽略 ExpoHaptics 的错误\n- 这次真的修复了模态框错位的问题（确信）\n\n## [1.3.5] - 2025-10-26\n\n### Fixed\n\n- 因图片缓存在内存导致的 OOM\n- 部分用户手机不支持振动反馈\n- 合集/分 p 同步时与原始顺序不一致\n- 修复在导航未初始化完成前尝试打开更新模态框\n\n### Added\n\n- 播放排行榜页面支持点击直接播放，且支持无限滚动查看所有播放记录\n\n### Changed\n\n- 增加了 issue 模板\n- 支持构建 preview 版本，并分离了不同版本的包名\n- 删除了 gemini-cli 的 workflow\n\n## [1.3.4] - 2025-10-15\n\n### Fixed\n\n- 修复 App Linking 不生效的问题\n\n## [1.3.3] - 2025-10-15\n\n### Added\n\n- 手动检查更新\n- 增加 `CHANGELOG.md` 文件\n\n### Changed\n\n- 将所有源代码移入 `src` 目录\n- `update.json` 中增加一个 `listed_notes` 字段，用于更清晰展示更新日志\n\n### Fixed\n\n- 修复了强制更新不生效的问题\n\n## [1.3.2] - 2025-10-14\n\n### Added\n\n- 为一部分交互添加了触觉反馈\n\n### Changed\n\n- 修改一部分组件使其符合 React Compiler 规范\n- 升级了一些依赖包\n- 移除了页面加载时强制显示的 ActivityIndicator\n\n### Fixed\n\n- 修复了更新音频流时抛出的 BilibiliApiError 会被错误上报的问题\n\n<!-- Links -->\n\n[keep a changelog]: https://keepachangelog.com/en/1.0.0/\n[semantic versioning]: https://semver.org/spec/v2.0.0.html\n[pride versioning]: https://pridever.org/\n\n<!-- Versions -->\n\n[unreleased]: https://github.com/bbplayer-app/BBPlayer/compare/v2.4.4...HEAD\n[1.3.2]: https://github.com/bbplayer-app/BBPlayer/compare/v1.3.1...v1.3.2\n[1.3.3]: https://github.com/bbplayer-app/BBPlayer/compare/v1.3.2...v1.3.3\n[1.3.4]: https://github.com/bbplayer-app/BBPlayer/compare/v1.3.3...v1.3.4\n[1.3.5]: https://github.com/bbplayer-app/BBPlayer/compare/v1.3.4...v1.3.5\n[1.3.6]: https://github.com/bbplayer-app/BBPlayer/compare/v1.3.5...v1.3.6\n[1.4.0]: https://github.com/bbplayer-app/BBPlayer/compare/v1.3.6...v1.4.0\n[1.4.2]: https://github.com/bbplayer-app/BBPlayer/compare/v1.4.0...v1.4.2\n[1.4.3]: https://github.com/bbplayer-app/BBPlayer/compare/v1.4.2...v1.4.3\n[2.1.4]: https://github.com/bbplayer-app/BBPlayer/compare/v1.4.3...v2.1.4\n[2.1.5]: https://github.com/bbplayer-app/BBPlayer/compare/v2.1.4...v2.1.5\n[2.1.6]: https://github.com/bbplayer-app/BBPlayer/compare/v2.1.5...v2.1.6\n[2.1.8]: https://github.com/bbplayer-app/BBPlayer/compare/v2.1.6...v2.1.8\n[2.1.9]: https://github.com/bbplayer-app/BBPlayer/compare/v2.1.8...v2.1.9\n[2.2.0]: https://github.com/bbplayer-app/BBPlayer/compare/v2.1.9...v2.2.0\n[2.2.2]: https://github.com/bbplayer-app/BBPlayer/compare/v2.2.0...v2.2.2\n[2.2.3]: https://github.com/bbplayer-app/BBPlayer/compare/v2.2.2...v2.2.3\n[2.2.4]: https://github.com/bbplayer-app/BBPlayer/compare/v2.2.3...v2.2.4\n[2.3.0]: https://github.com/bbplayer-app/BBPlayer/compare/v2.2.4...v2.3.0\n[2.3.2]: https://github.com/bbplayer-app/BBPlayer/compare/v2.3.0...v2.3.2\n[2.4.1]: https://github.com/bbplayer-app/BBPlayer/compare/v2.3.2...v2.4.1\n[2.4.2]: https://github.com/bbplayer-app/BBPlayer/compare/v2.4.1...v2.4.2\n[2.4.4]: https://github.com/bbplayer-app/BBPlayer/compare/v2.4.1...v2.4.4\n"
  },
  {
    "path": "apps/mobile/README.md",
    "content": "# @bbplayer/mobile\n\nBBPlayer 移动端应用的主程序。\n\n## 简介\n\n此目录包含 BBPlayer 移动端应用的核心源代码。基于 React Native 和 Expo 构建，旨在提供从 Bilibili 同步音频并在本地流畅播放的体验。\n\n## 文档\n\n所有的开发指南、架构说明以及最佳实践都位于仓库 GitHub Wiki 中： https://github.com/bbplayer-app/BBPlayer/wiki\n"
  },
  {
    "path": "apps/mobile/app.config.ts",
    "content": "import { execSync } from 'child_process'\nimport fs from 'fs'\nimport path from 'path'\n\nimport type { ConfigContext, ExpoConfig } from 'expo/config'\n\nimport { version } from './package.json'\n\nconst IS_DEV = process.env.APP_VARIANT === 'development'\nconst IS_PREVIEW = process.env.APP_VARIANT === 'preview'\n\n// 使用 git commit 数量作为 versionCode\nconst getVersionCode = (): number => {\n\tconst versionCodeEnv =\n\t\t// env 获取到的不可能是 string，我们这么做只是为了让 eslint 开心\n\t\t(process.env.VERSION_CODE as string | undefined | number) ?? undefined\n\tconst pwd = process.cwd()\n\t// EAS 环境的行为很奇怪，似乎不会复制 .git 目录，所以需要特殊强制外部提供 versionCode\n\tconst isInEAS = pwd.includes('eas-build-local-nodejs')\n\tif (typeof versionCodeEnv === 'string') {\n\t\tconst versionCode = parseInt(versionCodeEnv, 10)\n\t\tif (!isNaN(versionCode) && versionCode > 0) {\n\t\t\treturn versionCode\n\t\t}\n\t} else if (!isInEAS) {\n\t\tconst versionCodeString = execSync('git rev-list --count HEAD')\n\t\t\t.toString()\n\t\t\t.trim()\n\t\tconst versionCode = parseInt(versionCodeString, 10)\n\t\tif (!isNaN(versionCode) && versionCode > 0) {\n\t\t\treturn versionCode\n\t\t}\n\t}\n\n\tthrow new Error('VERSION_CODE environment variable is required or not in EAS')\n}\n\nconst versionCode = getVersionCode()\n\nconst getUniqueIdentifier = () => {\n\tif (IS_DEV) {\n\t\treturn 'com.roitium.bbplayer.dev'\n\t}\n\n\tif (IS_PREVIEW) {\n\t\treturn 'com.roitium.bbplayer.preview'\n\t}\n\n\treturn 'com.roitium.bbplayer'\n}\n\nconst getAppName = () => {\n\tif (IS_DEV) {\n\t\treturn 'BBPlayer (Dev)'\n\t}\n\n\tif (IS_PREVIEW) {\n\t\treturn 'BBPlayer (Preview)'\n\t}\n\n\treturn 'BBPlayer'\n}\n\n// oxlint-disable-next-line @typescript-eslint/no-unused-vars\nexport default ({ config }: ConfigContext): ExpoConfig => {\n\tconst googleServicesJsonPath =\n\t\t'./assets/config/google-services/google-services.json'\n\tconst googleServicesJsonRealPath =\n\t\t'./assets/config/google-services/google-services.real.json'\n\tconst googleServicesPlistPath =\n\t\t'./assets/config/google-services/GoogleService-Info.plist'\n\tconst googleServicesPlistRealPath =\n\t\t'./assets/config/google-services/GoogleService-Info.real.plist'\n\n\tconst getGoogleServicesFile = (defaultPath: string, realPath: string) => {\n\t\tif (fs.existsSync(path.resolve(process.cwd(), realPath))) {\n\t\t\treturn realPath\n\t\t}\n\t\treturn defaultPath\n\t}\n\n\treturn {\n\t\tname: getAppName(),\n\t\tslug: 'bbplayer',\n\t\tversion: version,\n\t\torientation: 'portrait',\n\t\ticon: './assets/images/icon.png',\n\t\tscheme: 'bbplayer',\n\t\tuserInterfaceStyle: 'automatic',\n\t\tplatforms: ['android', 'ios'],\n\t\tandroid: {\n\t\t\tadaptiveIcon: {\n\t\t\t\tforegroundImage: './assets/images/adaptive-icon.png',\n\t\t\t\tmonochromeImage: './assets/images/adaptive-icon.png',\n\t\t\t\tbackgroundColor: '#ffffff',\n\t\t\t},\n\t\t\tgoogleServicesFile: getGoogleServicesFile(\n\t\t\t\tgoogleServicesJsonPath,\n\t\t\t\tgoogleServicesJsonRealPath,\n\t\t\t),\n\t\t\tpackage: getUniqueIdentifier(),\n\t\t\tversionCode: versionCode,\n\t\t\truntimeVersion: version,\n\t\t\tintentFilters: [\n\t\t\t\t{\n\t\t\t\t\taction: 'VIEW',\n\t\t\t\t\tautoVerify: true,\n\t\t\t\t\tdata: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tscheme: 'https',\n\t\t\t\t\t\t\thost: 'bbplayer.roitium.com',\n\t\t\t\t\t\t\tpathPrefix: '/app/link-to',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tcategory: ['BROWSABLE', 'DEFAULT'],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\taction: 'VIEW',\n\t\t\t\t\tautoVerify: true,\n\t\t\t\t\tdata: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tscheme: 'https',\n\t\t\t\t\t\t\thost: 'app.bbplayer.roitium.com',\n\t\t\t\t\t\t\tpathPrefix: '/app/link-to',\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\tcategory: ['BROWSABLE', 'DEFAULT'],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\tplugins: [\n\t\t\t'./expo-plugins/withKotlinSerialization',\n\t\t\t// './expo-plugins/withAndroidPlugin',\n\t\t\t'./expo-plugins/withAndroidGradleProperties',\n\t\t\t[\n\t\t\t\t'./expo-plugins/withAbiFilters',\n\t\t\t\t{\n\t\t\t\t\tabiFilters:\n\t\t\t\t\t\ttypeof process.env.ABI_FILTERS === 'string'\n\t\t\t\t\t\t\t? process.env.ABI_FILTERS.split(',')\n\t\t\t\t\t\t\t: ['arm64-v8a'],\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\t'expo-dev-client',\n\t\t\t\t{\n\t\t\t\t\tlaunchMode: 'most-recent',\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\t'expo-splash-screen',\n\t\t\t\t{\n\t\t\t\t\timage: './assets/images/splash-icon.png',\n\t\t\t\t\timageWidth: 200,\n\t\t\t\t\tresizeMode: 'contain',\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\t'@sentry/react-native/expo',\n\t\t\t\t{\n\t\t\t\t\turl: 'https://sentry.io/',\n\t\t\t\t\tproject: 'bbplayer',\n\t\t\t\t\torganization: 'roitium',\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\t'expo-build-properties',\n\t\t\t\t{\n\t\t\t\t\tandroid: {\n\t\t\t\t\t\tusesCleartextTraffic: true,\n\t\t\t\t\t\tenableMinifyInReleaseBuilds: true,\n\t\t\t\t\t\tenableShrinkResourcesInReleaseBuilds: true,\n\t\t\t\t\t\tminSdkVersion: 26,\n\t\t\t\t\t\tpackagingOptions: {\n\t\t\t\t\t\t\tpickFirst: ['lib/*/libNitroModules.so'],\n\t\t\t\t\t\t},\n\t\t\t\t\t\textraProguardRules: `\n-dontwarn expo.modules.kotlin.**\n-dontwarn expo.modules.webview.**\n# --- 修复模态框打不开的问题 ---\n-keepclassmembers class * {\n    void updatePath();\n}\n# --- 修复模态框打不开的问题 ---\n# --- 来自 retrofit2.pro ---\n-keepattributes Signature, InnerClasses, EnclosingMethod\n-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations\n-keepattributes AnnotationDefault\n-keepclassmembers,allowshrinking,allowobfuscation interface * {\n    @retrofit2.http.* <methods>;\n}\n-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement\n-dontwarn javax.annotation.**\n-dontwarn kotlin.Unit\n-dontwarn retrofit2.KotlinExtensions\n-dontwarn retrofit2.KotlinExtensions$*\n-if interface * { @retrofit2.http.* <methods>; }\n-keep,allowobfuscation interface <1>\n-if interface * { @retrofit2.http.* <methods>; }\n-keep,allowobfuscation interface * extends <1>\n-keep,allowoptimization,allowshrinking,allowobfuscation class kotlin.coroutines.Continuation\n-if interface * { @retrofit2.http.* public *** *(...); }\n-keep,allowoptimization,allowshrinking,allowobfuscation class <3>\n-keep,allowoptimization,allowshrinking,allowobfuscation class retrofit2.Response\n# --- 来自 retrofit2.pro ---\n# --- 来自 SuperLyricApi ---\n-keep class com.hchen.superlyricapi.* {*;}\n# --- 来自 SuperLyricApi ---\n# --- 来自 Lyricon ---\n-keep class io.github.proify.lyricon.** {*;}\n# --- 来自 Lyricon ---\n-dontwarn java.awt.**\n-dontwarn javax.imageio.**\n-dontwarn org.jaudiotagger.**\n-keep class org.jaudiotagger.** { *; }\n-keep class expo.modules.kotlin.services.FilePermissionService$** { *; }\n-keep class expo.modules.kotlin.services.FilePermissionService { *; }\n\t\t\t\t\t`,\n\t\t\t\t\t},\n\t\t\t\t\tios: {\n\t\t\t\t\t\tuseFrameworks: 'static',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\t'expo-asset',\n\t\t\t\t{\n\t\t\t\t\tassets: ['./assets/images/media3_notification_small_icon.png'],\n\t\t\t\t},\n\t\t\t],\n\t\t\t'expo-font',\n\t\t\t[\n\t\t\t\t'react-native-bottom-tabs',\n\t\t\t\t{\n\t\t\t\t\ttheme: 'material3-dynamic',\n\t\t\t\t},\n\t\t\t],\n\t\t\t[\n\t\t\t\t'react-native-edge-to-edge',\n\t\t\t\t{\n\t\t\t\t\tandroid: {\n\t\t\t\t\t\tparentTheme: 'Material3',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t'expo-web-browser',\n\t\t\t'expo-sqlite',\n\t\t\t'expo-router',\n\t\t\t'@rnrepo/expo-config-plugin',\n\t\t\t[\n\t\t\t\t'expo-media-library',\n\t\t\t\t{\n\t\t\t\t\tphotosPermission: '允许 $(PRODUCT_NAME) 访问您的相册',\n\t\t\t\t\tsavePhotosPermission: '允许 $(PRODUCT_NAME) 保存图片到您的相册',\n\t\t\t\t\tisAccessMediaLocationEnabled: true,\n\t\t\t\t},\n\t\t\t],\n\t\t\t'@react-native-firebase/app',\n\t\t\t'expo-image',\n\t\t\t[\n\t\t\t\t'expo-sharing',\n\t\t\t\t{\n\t\t\t\t\tios: {\n\t\t\t\t\t\tenabled: false,\n\t\t\t\t\t},\n\t\t\t\t\tandroid: {\n\t\t\t\t\t\tenabled: true,\n\t\t\t\t\t\tsingleShareMimeTypes: ['text/*'],\n\t\t\t\t\t\tmultipleShareMimeTypes: ['text/*'],\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t],\n\t\texperiments: {\n\t\t\treactCompiler: true,\n\t\t\ttypedRoutes: true,\n\t\t},\n\t\textra: {\n\t\t\teas: {\n\t\t\t\tprojectId: '1cbd8d50-e322-4ead-98b6-4ee8b6f2a707',\n\t\t\t},\n\t\t\tupdateManifestUrl: 'https://be.bbplayer.roitium.com/update.json',\n\t\t},\n\t\towner: 'roitium',\n\t\tupdates: {\n\t\t\turl: 'https://u.expo.dev/1cbd8d50-e322-4ead-98b6-4ee8b6f2a707',\n\t\t\tenableBsdiffPatchSupport: true,\n\t\t},\n\t\tios: {\n\t\t\tbundleIdentifier: 'com.roitium.bbplayer',\n\t\t\truntimeVersion: {\n\t\t\t\tpolicy: 'appVersion',\n\t\t\t},\n\t\t\tgoogleServicesFile: getGoogleServicesFile(\n\t\t\t\tgoogleServicesPlistPath,\n\t\t\t\tgoogleServicesPlistRealPath,\n\t\t\t),\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/assets/config/google-services/GoogleService-Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>API_KEY</key>\n\t<string>AIzaSyMockKeyForIosBBPlayer123456</string>\n\t<key>GCM_SENDER_ID</key>\n\t<string>123456789012</string>\n\t<key>PLIST_VERSION</key>\n\t<string>1</string>\n\t<key>BUNDLE_ID</key>\n\t<string>com.roitium.bbplayer</string>\n\t<key>PROJECT_ID</key>\n\t<string>bbplayer-mock</string>\n\t<key>STORAGE_BUCKET</key>\n\t<string>bbplayer-mock.firebasestorage.app</string>\n\t<key>IS_ADS_ENABLED</key>\n\t<false></false>\n\t<key>IS_ANALYTICS_ENABLED</key>\n\t<false></false>\n\t<key>IS_APPINVITE_ENABLED</key>\n\t<true></true>\n\t<key>IS_GCM_ENABLED</key>\n\t<true></true>\n\t<key>IS_SIGNIN_ENABLED</key>\n\t<true></true>\n\t<key>GOOGLE_APP_ID</key>\n\t<string>1:123456789012:ios:mockiosappid123456</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "apps/mobile/assets/config/google-services/google-services.json",
    "content": "{\n\t\"project_info\": {\n\t\t\"project_number\": \"123456789012\",\n\t\t\"project_id\": \"bbplayer-mock\",\n\t\t\"storage_bucket\": \"bbplayer-mock.firebasestorage.app\"\n\t},\n\t\"client\": [\n\t\t{\n\t\t\t\"client_info\": {\n\t\t\t\t\"mobilesdk_app_id\": \"1:123456789012:android:mockandroidappid01\",\n\t\t\t\t\"android_client_info\": {\n\t\t\t\t\t\"package_name\": \"com.roitium.bbplayer\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"oauth_client\": [],\n\t\t\t\"api_key\": [\n\t\t\t\t{\n\t\t\t\t\t\"current_key\": \"AIzaSyMockKeyForAndroidBBPlayer123456\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"services\": {\n\t\t\t\t\"appinvite_service\": {\n\t\t\t\t\t\"other_platform_oauth_client\": []\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"client_info\": {\n\t\t\t\t\"mobilesdk_app_id\": \"1:123456789012:android:mockandroidappid02\",\n\t\t\t\t\"android_client_info\": {\n\t\t\t\t\t\"package_name\": \"com.roitium.bbplayer.dev\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"oauth_client\": [],\n\t\t\t\"api_key\": [\n\t\t\t\t{\n\t\t\t\t\t\"current_key\": \"AIzaSyMockKeyForAndroidBBPlayerDev123456\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"services\": {\n\t\t\t\t\"appinvite_service\": {\n\t\t\t\t\t\"other_platform_oauth_client\": []\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"client_info\": {\n\t\t\t\t\"mobilesdk_app_id\": \"1:123456789012:android:mockandroidappid03\",\n\t\t\t\t\"android_client_info\": {\n\t\t\t\t\t\"package_name\": \"com.roitium.bbplayer.preview\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"oauth_client\": [],\n\t\t\t\"api_key\": [\n\t\t\t\t{\n\t\t\t\t\t\"current_key\": \"AIzaSyMockKeyForAndroidBBPlayerPreview123456\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"services\": {\n\t\t\t\t\"appinvite_service\": {\n\t\t\t\t\t\"other_platform_oauth_client\": []\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t],\n\t\"configuration_version\": \"1\"\n}\n"
  },
  {
    "path": "apps/mobile/babel.config.js",
    "content": "export default (api) => {\n\tapi.cache(true)\n\treturn {\n\t\tpresets: [['babel-preset-expo']],\n\t\tenv: {\n\t\t\tproduction: {\n\t\t\t\tplugins: ['react-native-paper/babel', 'transform-remove-console'],\n\t\t\t},\n\t\t},\n\t\tplugins: [\n\t\t\t[\n\t\t\t\t'babel-plugin-react-compiler',\n\t\t\t\t{\n\t\t\t\t\tlogLevel: 'verbose',\n\n\t\t\t\t\tlogger: {\n\t\t\t\t\t\tlogEvent(filename, event) {\n\t\t\t\t\t\t\tswitch (event.kind) {\n\t\t\t\t\t\t\t\tcase 'CompileSuccess': {\n\t\t\t\t\t\t\t\t\tconsole.log(`✅ Compiled: ${filename}`)\n\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tcase 'CompileError': {\n\t\t\t\t\t\t\t\t\tconsole.log(\n\t\t\t\t\t\t\t\t\t\t`❌ Skipped: ${filename} [reason: ${event.detail.reason}] [description: ${event.detail.description}] [loc: ${event.detail.loc.start.line}, ${event.detail.loc.start.column}] [suggestion: ${event.detail.suggestions}]`,\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tdefault: {\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t['inline-import', { extensions: ['.sql'] }],\n\t\t],\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/docs/ARCHITECTURE.md",
    "content": "# 项目架构指南\n\nBBPlayer 是一个基于 **React Native (Expo)** 的现代化移动端应用。本文档详细介绍了项目的整体架构、目录结构以及核心开发模式。\n\n## 🛠 技术栈概览\n\n- **核心框架**: [Expo](https://expo.dev), [React Native](https://reactnative.dev)\n- **路由导航**: [Expo Router](https://docs.expo.dev/router/introduction/) (文件系统路由)\n- **状态管理**:\n  - [Zustand](https://github.com/pmndrs/zustand) (全局客户端状态)\n  - [TanStack Query](https://tanstack.com/query/latest) (服务端/异步状态管理)\n- **数据库**: [Drizzle ORM](https://orm.drizzle.team/) + `expo-sqlite`\n- **UI 组件**: [React Native Paper](https://reactnativepaper.com/) + 自定义组件\n- **错误处理**: [neverthrow](https://github.com/supermacro/neverthrow) (函数式错误处理)\n\n## 🏗 目录结构\n\n```\nsrc/\n├── app/                 # Expo Router 路由定义 (Pages)\n├── components/          # 通用 UI 组件\n├── features/            # 功能模块 (按业务领域划分)\n│   ├── player/\n│   ├── playlist/\n│   └── ...\n├── hooks/               # 全局通用 Hooks\n├── lib/                 # 核心逻辑与基础设施\n│   ├── api/             # 外部 API 集成 (如 Bilibili)\n│   ├── config/          # 应用配置\n│   ├── db/              # Drizzle 数据库 Schema 与配置\n│   ├── facades/         # [架构核心] Facade 层\n│   └── services/        # [架构核心] Service 层\n├── types/               # TypeScript 类型定义\n└── utils/               # 工具函数\n\n注：以上是 `apps/mobile` 内的源码结构。在 Monorepo 根目录下，我们还有 `packages/` 目录用于存放共享包，例如：\n- `@bbplayer/orpheus`: 播放器核心逻辑\n- `@bbplayer/image-theme-colors`: 图片主题色提取\n- `@bbplayer/logs`: 日志工具库\n```\n\n## 🧩 核心架构模式\n\n本项目采用了 **分层架构 (Layered Architecture)** 与 **功能切片 (Feature Slices)** 相结合的模式。\n\n### 1. 分层架构 (Layered Architecture)\n\n为了分离关注点，我们将业务逻辑分为以下几层：\n\n#### **UI Layer (Components & Hooks)**\n\n- **位置**: `src/app`, `src/features/*/components`, `src/features/*/hooks`\n- **职责**: 处理视图展示、用户交互。\n- **原则**: 尽量少包含复杂业务逻辑，主要通过调用 Hooks 或 Facades 来获取数据和执行操作。\n\n#### **Facade Layer (外观模式)**\n\n- **位置**: `src/lib/facades`\n- **职责**:\n  - 作为 UI 层与底层逻辑的**统一入口**。\n  - **编排**多个 Service 的调用。\n  - 管理**数据库事务 (Transactions)**，确保操作的原子性。\n- **示例**: `PlaylistFacade` 可能同时调用 `PlaylistService` (创建歌单) 和 `TrackService` (添加歌曲)。\n\n#### **Service Layer (领域服务)**\n\n- **位置**: `src/lib/services`\n- **职责**:\n  - 处理单一领域的**核心业务逻辑**。\n  - 直接与数据库 (Drizzle) 交互。\n  - 不关心 UI，也不关心事务的开启（通常由 Facade 管理，或者在 Service 内部处理简单查询）。\n- **示例**: `PlaylistService` 只负责对 `playlist` 表的增删改查。\n\n#### **Infrastructure / Data Layer**\n\n- **位置**: `src/lib/api`, `src/lib/db`\n- **职责**: 处理外部 API 请求和底层数据库连接。\n\n### 2. 错误处理 (Error Handling)\n\n本项目**严禁**在业务逻辑中随意抛出异常 (Throwing Errors)。我们使用 `neverthrow` 库采用 **Result 模式** 进行错误处理。\n\n- **原则**: 函数应返回 `Result<T, E>` 或 `ResultAsync<T, E>`。\n- **优势**: 强制调用方处理错误，类型安全，避免隐式崩溃。\n\n## 💾 数据与播放器设计说明\n\n### 播放器数据约束\n\n- **原则**: 传递给 Player 的 Track `uniqueKey` **必须**在本地数据库中有记录。\n- **原因**: `currentTrack` hook 依赖 `uniqueKey` 来查询和关联数据库中的扩展元数据。\n\n### 分 P 视频处理 (Bilibili)\n\n目前项目中存在两种视频入口：\n\n1. **整视频** (`isMultiPage = false`)\n2. **分 P 视频** (`isMultiPage = true`, 已知 `cid`)\n\n**难点**: 导入时难以标准化为同一键值（获取 `cid` 成本高），可能导致数据库中存在逻辑上重复的记录。目前的策略是尽量保持现状，后续在 `TECHNICAL_DEBT.md` 中有详细记录。\n"
  },
  {
    "path": "apps/mobile/docs/BEST_PRACTICES.md",
    "content": "# 开发规范与最佳实践\n\n## 🎨 UI 开发规范\n\n### FlashList 性能优化\n\n项目中大量使用了 `FlashList` 进行列表渲染。为了保证滚动性能，请严格遵守以下规范：\n\n1.  **renderItem 定义**: `renderItem` 函数**必须**在组件函数外部定义（并不推荐使用 `useCallback`）\n2.  **extraData 使用**: 所有 `renderItem` 依赖的外部变量（除了 `item` 本身），都必须放入 `extraData` 属性中。\n3.  **Memoization**: `extraData` 对象必须使用 `useMemo` 包裹，避免因引用变化导致不必要的重渲染。\n\n## 📝 代码风格\n\n- **Oxfmt**: 项目配置了 Oxfmt，请确保编辑器开启了保存自动格式化。\n- **Oxlint/ESLint**: 提交前请修复所有的 lint 警告。\n- **组件命名**: 使用帕斯卡命名法 (PascalCase)，如 `MyComponent.tsx`。\n- **Hook 命名**: 使用 `use` 前缀，如 `usePlayerState.ts`。\n\n## 🪵 日志规范\n\n- **Service/Facade 层**: 关键业务路径应记录日志。\n- **Error Handling**: 捕获到错误时，应记录错误堆栈。\n- **Debug**: 开发环境下的调试日志请使用 `console.debug`，生产环境构建会自动移除。\n"
  },
  {
    "path": "apps/mobile/docs/CONTRIBUTING.md",
    "content": "# 贡献指南 (Contributing Guide)\n\n欢迎来到 BBPlayer 项目！我们非常感谢你对开源社区的贡献。在开始之前，请花一点时间阅读以下指南，这将帮助你更高效地参与开发。\n\n## 🚀 快速开始 (Getting Started)\n\n### 1. 环境准备\n\n- **包管理器**: 必须使用 **pnpm**。\n- **Android 环境**: 配置好 Android Studio 和 SDK。\n- **mise (可选)**: 我们推荐使用 [mise](https://mise.jdx.dev/) 来管理环境变量和任务脚本。\n\n### 2. 安装依赖\n\n在项目根目录下运行：\n\n```bash\npnpm install\n```\n\n### 3. 配置环境变量\n\n你可以通过 `.env.local` 文件或 export 命令配置以下环境变量：\n\n- **VERSION_CODE**: (必须) 用于标记构建版本。\n  - 推荐命令: `git rev-list --count HEAD`\n- **SENTRY_AUTH_TOKEN**: (可选) Sentry 错误追踪。\n  - **dev 构建**: 不需要此 Token\n  - **production / preview 构建**: 需要真实 Token 以上传符号表。\n\n### 4. 构建基座 (Development Build)\n\n本项目包含原生代码，**不能**直接使用 Expo Go 运行。你需要先构建自定义基座。\n\n**方式 A: 使用 EAS (推荐)**\n\n参考 `apps/mobile/mise.toml`，运行构建命令：\n\n```bash\n# 如果安装了 mise (需要传入 version 参数)\nmise run builddev --version 1.0.0\n\n# 或者直接运行 eas 命令\ncd apps/mobile\nVERSION_CODE=$(git rev-list --count HEAD) eas build --profile dev --platform android --local --output=./temp-builds/bbplayer-1.0.0-dev.apk\n```\n\n**方式 B: 传统 Prebuild**\n\n如果你更习惯使用原生工具链：\n\n```bash\ncd apps/mobile\n# 生成原生目录 (android/ios). 推荐加上 --clean 以确保配置生效\nnpx expo prebuild --clean\n\n# 编译并安装到设备\nnpx expo run:android\n```\n\n### 5. 启动开发\n\n构建并安装应用后，启动 Metro 服务器进行开发：\n\n```bash\ncd apps/mobile\npnpm expo start\n```\n\n> [!IMPORTANT]\n> **Firebase 配置 (Firebase Configuration)**\n>\n> 项目包含模拟的 Firebase 配置文件 (`google-services.json` 和 `GoogleService-Info.plist`)，你可以直接运行项目。\n>\n> 如果你需要使用真实的 Firebase 功能（如 Analytics），请将你的真实配置文件重命名为：\n>\n> - `google-services.real.json`\n> - `GoogleService-Info.real.plist`\n>\n> 并放在 `apps/mobile/assets/config/google-services/` 目录下。使用 eas 构建时会自动优先使用真实文件。（如果不使用 eas 构建，则需要在放置真实文件后，运行 `npx expo prebuild --clean`）\n\n## 📂 文档导航\n\n为了更好地理解项目，建议按以下顺序阅读文档：\n\n1.  **[架构指南 (ARCHITECTURE.md)](./ARCHITECTURE.md)**: 必读。了解项目的核心架构、分层模式（Facade/Service）以及目录结构。\n2.  **[开发规范 (BEST_PRACTICES.md)](./BEST_PRACTICES.md)**: 了解 UI 开发优化（FlashList）、代码风格等最佳实践。\n3.  **[发版流程 (RELEASE.md)](./RELEASE.md)**: 版本发布的操作指南。\n4.  **[技术债与路线图 (TECHNICAL_DEBT.md)](./TECHNICAL_DEBT.md)**: 了解当前已知问题和待改进项。\n\n## 💻 开发工作流\n\n### 分支管理\n\n- **master**: 主分支，对应最新版本的代码。\n- **dev**: 开发分支，所有的 PR 请提交到此分支。\n- **feat/xyz**: 新功能分支。\n- **fix/xyz**: 问题修复分支。\n\n### 提交规范\n\n我们推荐使用语义化提交信息 (Conventional Commits)：\n\n- `feat`: 新功能\n- `fix`: 修复 bug\n- `docs`: 文档变更\n- `style`: 代码格式修改 (不影响逻辑)\n- `refactor`: 代码重构\n- `chore`: 构建过程或辅助工具的变动\n\n### 代码质量\n\n我们使用 lefthook 来自动执行代码检查和格式化（oxlint, oxfmt, eslint），请确保你配置好了 lefthook。\n\n## 🤝 贡献代码\n\n1. Fork 本仓库。\n2. 基于 `dev` 分支创建你的功能分支 (`git checkout -b feat/amazing-feature`)。\n3. 提交你的修改。\n4. 推送到你的 Fork 仓库。\n5. 提交 Pull Request 到本仓库的 `dev` 分支。\n\n感谢你的参与！\n"
  },
  {
    "path": "apps/mobile/docs/Home.md",
    "content": "# BBPlayer 开发文档\n\n> [!NOTE]\n> 如果您是最终用户，请访问 [BBPlayer 官网](https://bbplayer.roitium.com) 获取使用说明。\n\n---\n\n欢迎查阅 BBPlayer 开发文档。这里包含了项目的架构设计、开发规范以及贡献指南。\n\n（以下内容大部分为 AI 编写，我进行了校对和审核）\n\n## 📚 文档目录\n\n- **[贡献指南 (CONTRIBUTING.md)](CONTRIBUTING)**\n  - 新手必读！包含环境搭建、开发工作流和提交规范。\n\n- **[架构指南 (ARCHITECTURE.md)](ARCHITECTURE)**\n  - 深入了解分层架构、Facade 模式、Service 层以及数据流向。\n\n- **[开发规范 (BEST_PRACTICES.md)](BEST_PRACTICES)**\n  - UI 开发技巧 (FlashList 优化)、代码风格与日志规范。\n\n- **[发版流程 (RELEASE.md)](RELEASE)**\n  - 版本发布步骤与 update.json 维护说明。\n\n- **[技术债与路线图 (TECHNICAL_DEBT.md)](TECHNICAL_DEBT)**\n  - 当前已知的问题、待办事项及长期规划。\n"
  },
  {
    "path": "apps/mobile/docs/RELEASE.md",
    "content": "# 发版流程 (Release Process)\n\n本文档描述了 BBPlayer 的版本发布流程。\n\n## 1. 准备版本\n\n- **更新 `package.json`**：同步修改 `version` 字段与 `android.versionCode`。\n- **编写变更说明**：整理本次发布的要点，更新 `apps/mobile/CHANGELOG.md` 文件。\n\n## 2. 发起更新\n\n- 提交一个 Pull Request (PR)，将 `dev` 分支的更改合并到 `master` 分支。\n- PR 合并后，GitHub Actions 会自动触发。\n- 在审批 (Approve) 通过后，CI 将开始运行构建流程并生成 Draft Release。\n- 在 Draft Release 中填写详细的发布说明 (Release Notes)，确认无误后点击 Publish。\n\n## 3. 更新 update.json\n\n用于应用内检查更新。\n\n- **推荐方式**：运行 `pnpm publish:update`，在 TUI 中选择 GitHub Release，确认生成的 update metadata，并发布到 Cloudflare Workers KV。\n- **存储位置**：Cloudflare Workers KV 的 `update_json` key，由 backend 的 `/update.json` 路由读取。\n- **字段说明**：\n  - `version`：语义化版本号（如 `1.2.3`）。\n  - `url`：GitHub Release 页面，作为回退链接。\n  - `downloads.android`：按 ABI 保存的 APK 直链，例如 `arm64-v8a`。\n  - `notes`：更新说明（支持多行文本）。\n  - `listed_notes`：更新说明列表（推荐）。当存在此字段时，`notes` 会被忽略。\n  - `forced`：布尔值，是否强制用户更新。\n"
  },
  {
    "path": "apps/mobile/docs/TECHNICAL_DEBT.md",
    "content": "# 技术债与路线图 (Technical Debt & Roadmap)\n\n本文档记录了项目当前已知的设计缺陷、待改进项以及长期的技术规划。\n\n## ⚠️ 错误处理与日志 (Error Handling & Logging)\n\n### 现状\n\n当前项目内的错误管理较为混乱，尤其是 `playerStore` 中。\n\n- 很多函数有潜在抛出错误的可能，但未被显式捕获。\n- `playerStore` 的调用方大多是 \"fire-and-forget\"，只有少部分处理了错误。\n\n### 改进目标\n\n- **引入 Result 模式**: 使用 `neverthrow` 包装潜在的错误操作，替代 `try-catch`。\n- **日志标准化**: 在 Service 和 Facade 层添加更详细的结构化日志。\n- **Sentry 上报策略**: 明确上报边界。建议：**非三方 API 调用**导致的系统内部错误，都应该上报。\n\n## 🐛 已知问题：分 P 视频处理\n\n**目前 Bilibili API 的限制导致无法完美解决此问题。**\n\n### 问题描述\n\nB 站视频唯一可播放单位是 `(bvid, cid)`，但在本项目中存在两种入口：\n\n1. **整视频入口** (`isMultiPage = false`): 无 `cid`，默认播放第一 P。\n2. **分 P 入口** (`isMultiPage = true`): 已知 `cid`。\n\n这导致同一个视频的第一 P 可能对应数据库中的两条记录，且无法简单去重。\n\n### 困难点\n\n1. **可靠性**: 分 P 顺序不可靠，UP 主可能删除分 P。\n2. **性能**: 批量获取 `cid` 成本过高，容易触发风控，导致导入歌单时无法预先获取 `cid` 进行去重。\n\n### 暂行方案\n\n目前维持现状，允许并在 UI 上展示这两种状态。\n"
  },
  {
    "path": "apps/mobile/drizzle/0000_productive_joystick.sql",
    "content": "CREATE TABLE `artists` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`name` text NOT NULL,\n\t`avatar_url` text,\n\t`signature` text,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE `playlist_tracks` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`playlist_id` integer NOT NULL,\n\t`track_id` integer NOT NULL,\n\t`order` integer,\n\tFOREIGN KEY (`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE TABLE `playlists` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`title` text NOT NULL,\n\t`author_id` integer,\n\t`description` text,\n\t`cover_url` text,\n\t`item_count` integer DEFAULT 0 NOT NULL,\n\t`type` text NOT NULL,\n\t`last_synced_at` integer,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tFOREIGN KEY (`author_id`) REFERENCES `artists`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nCREATE TABLE `search_history` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`query` text NOT NULL,\n\t`timestamp` integer DEFAULT (unixepoch() * 1000) NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `query_unq` ON `search_history` (`query`);--> statement-breakpoint\nCREATE TABLE `tracks` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`bvid` text NOT NULL,\n\t`cid` integer,\n\t`title` text NOT NULL,\n\t`artist_id` integer,\n\t`cover_url` text,\n\t`duration` integer,\n\t`play_count_sequence` text DEFAULT '[]',\n\t`is_multi_page` integer NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tFOREIGN KEY (`artist_id`) REFERENCES `artists`(`id`) ON UPDATE no action ON DELETE set null\n);\n"
  },
  {
    "path": "apps/mobile/drizzle/0001_fast_trauma.sql",
    "content": "ALTER TABLE `tracks` ADD `source` text;"
  },
  {
    "path": "apps/mobile/drizzle/0002_groovy_maximus.sql",
    "content": "CREATE TABLE `bilibili_metadata` (\n\t`track_id` integer PRIMARY KEY NOT NULL,\n\t`bvid` text NOT NULL,\n\t`cid` integer,\n\t`is_multi_part` integer NOT NULL,\n\t`create_at` integer NOT NULL,\n\tFOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE TABLE `local_metadata` (\n\t`track_id` integer PRIMARY KEY NOT NULL,\n\t`local_path` text NOT NULL,\n\tFOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nDROP TABLE `search_history`;--> statement-breakpoint\nPRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_artists` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`name` text NOT NULL,\n\t`avatar_url` text,\n\t`signature` text,\n\t`source` text NOT NULL,\n\t`remote_id` text,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_artists`(\"id\", \"name\", \"avatar_url\", \"signature\", \"source\", \"remote_id\", \"created_at\") SELECT \"id\", \"name\", \"avatar_url\", \"signature\", \"source\", \"remote_id\", \"created_at\" FROM `artists`;--> statement-breakpoint\nDROP TABLE `artists`;--> statement-breakpoint\nALTER TABLE `__new_artists` RENAME TO `artists`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `source_remote_id_unq` ON `artists` (`source`,`remote_id`);--> statement-breakpoint\nCREATE TABLE `__new_playlists` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`title` text NOT NULL,\n\t`author_id` integer,\n\t`description` text,\n\t`cover_url` text,\n\t`item_count` integer DEFAULT 0 NOT NULL,\n\t`type` text NOT NULL,\n\t`remote_sync_id` integer,\n\t`last_synced_at` integer,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tFOREIGN KEY (`author_id`) REFERENCES `artists`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nINSERT INTO `__new_playlists`(\"id\", \"title\", \"author_id\", \"description\", \"cover_url\", \"item_count\", \"type\", \"remote_sync_id\", \"last_synced_at\", \"created_at\") SELECT \"id\", \"title\", \"author_id\", \"description\", \"cover_url\", \"item_count\", \"type\", \"remote_sync_id\", \"last_synced_at\", \"created_at\" FROM `playlists`;--> statement-breakpoint\nDROP TABLE `playlists`;--> statement-breakpoint\nALTER TABLE `__new_playlists` RENAME TO `playlists`;--> statement-breakpoint\nALTER TABLE `tracks` DROP COLUMN `bvid`;--> statement-breakpoint\nALTER TABLE `tracks` DROP COLUMN `cid`;--> statement-breakpoint\nALTER TABLE `tracks` DROP COLUMN `is_multi_page`;"
  },
  {
    "path": "apps/mobile/drizzle/0003_glamorous_psylocke.sql",
    "content": "ALTER TABLE `bilibili_metadata` DROP COLUMN `create_at`;"
  },
  {
    "path": "apps/mobile/drizzle/0004_smiling_beast.sql",
    "content": "CREATE INDEX `bilibili_metadata_bvid_cid_idx` ON `bilibili_metadata` (`bvid`,`cid`);"
  },
  {
    "path": "apps/mobile/drizzle/0005_spotty_exiles.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_playlist_tracks` (\n\t`playlist_id` integer NOT NULL,\n\t`track_id` integer NOT NULL,\n\t`order` integer NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tPRIMARY KEY(`playlist_id`, `track_id`),\n\tFOREIGN KEY (`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nINSERT INTO `__new_playlist_tracks`(\"playlist_id\", \"track_id\", \"order\", \"created_at\") SELECT \"playlist_id\", \"track_id\", \"order\", \"created_at\" FROM `playlist_tracks`;--> statement-breakpoint\nDROP TABLE `playlist_tracks`;--> statement-breakpoint\nALTER TABLE `__new_playlist_tracks` RENAME TO `playlist_tracks`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE INDEX `playlist_tracks_playlist_idx` ON `playlist_tracks` (`playlist_id`);--> statement-breakpoint\nCREATE INDEX `playlist_tracks_track_idx` ON `playlist_tracks` (`track_id`);--> statement-breakpoint\nCREATE TABLE `__new_tracks` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`unique_key` text NOT NULL,\n\t`title` text NOT NULL,\n\t`artist_id` integer,\n\t`cover_url` text,\n\t`duration` integer,\n\t`play_count_sequence` text DEFAULT '[]',\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`source` text NOT NULL,\n\tFOREIGN KEY (`artist_id`) REFERENCES `artists`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nINSERT INTO `__new_tracks`(\"id\", \"unique_key\", \"title\", \"artist_id\", \"cover_url\", \"duration\", \"play_count_sequence\", \"created_at\", \"source\") SELECT \"id\", \"unique_key\", \"title\", \"artist_id\", \"cover_url\", \"duration\", \"play_count_sequence\", \"created_at\", \"source\" FROM `tracks`;--> statement-breakpoint\nDROP TABLE `tracks`;--> statement-breakpoint\nALTER TABLE `__new_tracks` RENAME TO `tracks`;--> statement-breakpoint\nCREATE UNIQUE INDEX `tracks_unique_key_unique` ON `tracks` (`unique_key`);--> statement-breakpoint\nCREATE INDEX `tracks_artist_idx` ON `tracks` (`artist_id`);--> statement-breakpoint\nCREATE INDEX `tracks_title_idx` ON `tracks` (`title`);--> statement-breakpoint\nCREATE INDEX `tracks_source_idx` ON `tracks` (`source`);--> statement-breakpoint\nCREATE INDEX `artists_name_idx` ON `artists` (`name`);--> statement-breakpoint\nCREATE INDEX `playlists_title_idx` ON `playlists` (`title`);--> statement-breakpoint\nCREATE INDEX `playlists_type_idx` ON `playlists` (`type`);--> statement-breakpoint\nCREATE INDEX `playlists_author_idx` ON `playlists` (`author_id`);"
  },
  {
    "path": "apps/mobile/drizzle/0006_breezy_jigsaw.sql",
    "content": "ALTER TABLE `bilibili_metadata` RENAME COLUMN \"is_multi_part\" TO \"is_multi_page\";"
  },
  {
    "path": "apps/mobile/drizzle/0007_legal_thor.sql",
    "content": "ALTER TABLE `tracks` ADD `play_history` text DEFAULT '[]';--> statement-breakpoint\nALTER TABLE `tracks` DROP COLUMN `play_count_sequence`;"
  },
  {
    "path": "apps/mobile/drizzle/0008_overrated_jimmy_woo.sql",
    "content": "ALTER TABLE `bilibili_metadata` ADD `video_is_valid` integer DEFAULT true NOT NULL;"
  },
  {
    "path": "apps/mobile/drizzle/0009_lethal_marten_broadcloak.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_artists` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`name` text NOT NULL,\n\t`avatar_url` text,\n\t`signature` text,\n\t`source` text NOT NULL,\n\t`remote_id` text,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tCONSTRAINT \"source_integrity_check\" CHECK(\n        (source = 'local' AND remote_id IS NULL) \n        OR \n        (source != 'local' AND remote_id IS NOT NULL)\n      )\n);\n--> statement-breakpoint\nINSERT INTO `__new_artists`(\"id\", \"name\", \"avatar_url\", \"signature\", \"source\", \"remote_id\", \"created_at\", \"updated_at\") SELECT \"id\", \"name\", \"avatar_url\", \"signature\", \"source\", \"remote_id\", \"created_at\", \"updated_at\" FROM `artists`;--> statement-breakpoint\nDROP TABLE `artists`;--> statement-breakpoint\nALTER TABLE `__new_artists` RENAME TO `artists`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `source_remote_id_unq` ON `artists` (`source`,`remote_id`) WHERE source != 'local';--> statement-breakpoint\nCREATE UNIQUE INDEX `local_artist_unq` ON `artists` (`name`) WHERE source = 'local';--> statement-breakpoint\nCREATE INDEX `artists_name_idx` ON `artists` (`name`);--> statement-breakpoint\nALTER TABLE `playlists` ADD `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL;--> statement-breakpoint\nALTER TABLE `tracks` ADD `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL;"
  },
  {
    "path": "apps/mobile/drizzle/0010_brainy_anita_blake.sql",
    "content": "ALTER TABLE `bilibili_metadata` ADD `main_track_title` text;"
  },
  {
    "path": "apps/mobile/drizzle/0011_grey_echo.sql",
    "content": "CREATE TABLE `track_downloads` (\n\t`track_id` integer PRIMARY KEY NOT NULL,\n\t`downloadedAt` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`status` text NOT NULL,\n\t`file_size` integer,\n\tFOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE INDEX `track_downloads_track_idx` ON `track_downloads` (`track_id`);"
  },
  {
    "path": "apps/mobile/drizzle/0012_blushing_human_fly.sql",
    "content": "DROP TABLE `track_downloads`;"
  },
  {
    "path": "apps/mobile/drizzle/0013_jittery_randall.sql",
    "content": "ALTER TABLE `playlist_tracks` ADD `sort_key` text NOT NULL DEFAULT '';--> statement-breakpoint\nCREATE INDEX `playlist_tracks_sort_key_idx` ON `playlist_tracks` (`playlist_id`,`sort_key`);"
  },
  {
    "path": "apps/mobile/drizzle/0014_flippant_sebastian_shaw.sql",
    "content": "DROP INDEX `playlist_tracks_playlist_idx`;"
  },
  {
    "path": "apps/mobile/drizzle/0015_flippant_skaar.sql",
    "content": "CREATE TABLE `playlist_sync_queue` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`playlist_id` integer NOT NULL,\n\t`operation` text NOT NULL,\n\t`payload` text NOT NULL,\n\t`status` text DEFAULT 'pending' NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`last_attempt_at` integer,\n\t`failure_reason` text,\n\t`operation_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tFOREIGN KEY (`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nALTER TABLE `playlists` ADD `share_id` text;--> statement-breakpoint\nALTER TABLE `playlists` ADD `share_role` text;--> statement-breakpoint\nALTER TABLE `playlists` ADD `last_share_sync_at` integer;"
  },
  {
    "path": "apps/mobile/drizzle/0016_cheerful_stark_industries.sql",
    "content": "ALTER TABLE `playlist_sync_queue` DROP COLUMN `attempts`;--> statement-breakpoint\nALTER TABLE `playlist_sync_queue` DROP COLUMN `last_attempt_at`;--> statement-breakpoint\nALTER TABLE `playlist_sync_queue` DROP COLUMN `failure_reason`;"
  },
  {
    "path": "apps/mobile/drizzle/0017_rare_lifeguard.sql",
    "content": "CREATE INDEX `playlist_sync_queue_status_idx` ON `playlist_sync_queue` (`status`);--> statement-breakpoint\nCREATE INDEX `playlist_sync_queue_playlist_id_idx` ON `playlist_sync_queue` (`playlist_id`);--> statement-breakpoint\nCREATE INDEX `playlists_share_id_idx` ON `playlists` (`share_id`);"
  },
  {
    "path": "apps/mobile/drizzle/0018_green_dracula.sql",
    "content": "CREATE TABLE `play_history` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`track_id` integer NOT NULL,\n\t`start_time` integer NOT NULL,\n\t`duration_played` integer NOT NULL,\n\t`completed` integer NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tFOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE INDEX `play_history_track_idx` ON `play_history` (`track_id`);--> statement-breakpoint\nCREATE INDEX `play_history_start_time_idx` ON `play_history` (`start_time`);\n"
  },
  {
    "path": "apps/mobile/drizzle/0019_icy_mandarin.sql",
    "content": "CREATE TABLE `dynamic_playlist_sources` (\n\t`playlist_id` integer NOT NULL,\n\t`source_playlist_id` integer NOT NULL,\n\t`position` integer NOT NULL,\n\t`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,\n\tPRIMARY KEY(`playlist_id`, `source_playlist_id`),\n\tFOREIGN KEY (`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`source_playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE INDEX `dynamic_playlist_sources_playlist_idx` ON `dynamic_playlist_sources` (`playlist_id`);--> statement-breakpoint\nCREATE INDEX `dynamic_playlist_sources_source_idx` ON `dynamic_playlist_sources` (`source_playlist_id`);"
  },
  {
    "path": "apps/mobile/drizzle/0020_ambitious_sheva_callister.sql",
    "content": "CREATE INDEX `dynamic_playlist_sources_playlist_position_idx` ON `dynamic_playlist_sources` (`playlist_id`,`position`);\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0000_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"81739eb6-6932-4793-94af-5772e1e0f6cd\",\n\t\"prevId\": \"00000000-0000-0000-0000-000000000000\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"search_history\": {\n\t\t\t\"name\": \"search_history\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"query\": {\n\t\t\t\t\t\"name\": \"query\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"timestamp\": {\n\t\t\t\t\t\"name\": \"timestamp\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"query_unq\": {\n\t\t\t\t\t\"name\": \"query_unq\",\n\t\t\t\t\t\"columns\": [\"query\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0001_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"75da2b11-e2bf-414d-b2f1-494b79899231\",\n\t\"prevId\": \"81739eb6-6932-4793-94af-5772e1e0f6cd\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"search_history\": {\n\t\t\t\"name\": \"search_history\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"query\": {\n\t\t\t\t\t\"name\": \"query\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"timestamp\": {\n\t\t\t\t\t\"name\": \"timestamp\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"query_unq\": {\n\t\t\t\t\t\"name\": \"query_unq\",\n\t\t\t\t\t\"columns\": [\"query\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0002_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"ab9b4fb1-8f85-4c45-a234-a3c016493c63\",\n\t\"prevId\": \"75da2b11-e2bf-414d-b2f1-494b79899231\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_part\": {\n\t\t\t\t\t\"name\": \"is_multi_part\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"create_at\": {\n\t\t\t\t\t\"name\": \"create_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0003_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"825a53df-1145-43a3-ba0f-968f42511a8c\",\n\t\"prevId\": \"ab9b4fb1-8f85-4c45-a234-a3c016493c63\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_part\": {\n\t\t\t\t\t\"name\": \"is_multi_part\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0004_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"81f73733-963f-402e-9201-378310e220a6\",\n\t\"prevId\": \"825a53df-1145-43a3-ba0f-968f42511a8c\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_part\": {\n\t\t\t\t\t\"name\": \"is_multi_part\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0005_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"2573ead5-558c-479c-920b-1a065ade1c0e\",\n\t\"prevId\": \"81f73733-963f-402e-9201-378310e220a6\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_part\": {\n\t\t\t\t\t\"name\": \"is_multi_part\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0006_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"a45b56db-b386-49b7-83e3-e863d9199e82\",\n\t\"prevId\": \"2573ead5-558c-479c-920b-1a065ade1c0e\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_count_sequence\": {\n\t\t\t\t\t\"name\": \"play_count_sequence\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {\n\t\t\t\"\\\"bilibili_metadata\\\".\\\"is_multi_part\\\"\": \"\\\"bilibili_metadata\\\".\\\"is_multi_page\\\"\"\n\t\t}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0007_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"ec2725a8-34d0-4437-93e4-78d4a033b08a\",\n\t\"prevId\": \"a45b56db-b386-49b7-83e3-e863d9199e82\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0008_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"7e7942c7-ccfa-4b6c-a846-4ea356ae7dee\",\n\t\"prevId\": \"ec2725a8-34d0-4437-93e4-78d4a033b08a\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0009_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"3da47614-a62f-4173-961a-e8b238af0dae\",\n\t\"prevId\": \"7e7942c7-ccfa-4b6c-a846-4ea356ae7dee\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0010_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"6c17f4d1-bd70-4052-9a24-250d42de868d\",\n\t\"prevId\": \"3da47614-a62f-4173-961a-e8b238af0dae\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0011_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"f20429b4-0237-45df-8274-6d3a282d16a8\",\n\t\"prevId\": \"6c17f4d1-bd70-4052-9a24-250d42de868d\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"track_downloads\": {\n\t\t\t\"name\": \"track_downloads\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"downloadedAt\": {\n\t\t\t\t\t\"name\": \"downloadedAt\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"file_size\": {\n\t\t\t\t\t\"name\": \"file_size\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"track_downloads_track_idx\": {\n\t\t\t\t\t\"name\": \"track_downloads_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"track_downloads_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"track_downloads_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"track_downloads\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0012_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"1c3c1015-206b-4997-90f4-04dfe6f6f2a8\",\n\t\"prevId\": \"f20429b4-0237-45df-8274-6d3a282d16a8\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"order\": {\n\t\t\t\t\t\"name\": \"order\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0013_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"89873c64-ec04-4f62-a78e-38796ad6b8be\",\n\t\"prevId\": \"1c3c1015-206b-4997-90f4-04dfe6f6f2a8\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_playlist_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0014_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"a3add204-1a72-44d6-aef0-9168b5e7d4cf\",\n\t\"prevId\": \"89873c64-ec04-4f62-a78e-38796ad6b8be\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0015_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"a639464c-4977-4ba6-a9d7-7f60542a2f8e\",\n\t\"prevId\": \"a3add204-1a72-44d6-aef0-9168b5e7d4cf\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_sync_queue\": {\n\t\t\t\"name\": \"playlist_sync_queue\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation\": {\n\t\t\t\t\t\"name\": \"operation\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"payload\": {\n\t\t\t\t\t\"name\": \"payload\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'pending'\"\n\t\t\t\t},\n\t\t\t\t\"attempts\": {\n\t\t\t\t\t\"name\": \"attempts\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"last_attempt_at\": {\n\t\t\t\t\t\"name\": \"last_attempt_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"failure_reason\": {\n\t\t\t\t\t\"name\": \"failure_reason\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation_at\": {\n\t\t\t\t\t\"name\": \"operation_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_sync_queue_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_sync_queue\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_id\": {\n\t\t\t\t\t\"name\": \"share_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_role\": {\n\t\t\t\t\t\"name\": \"share_role\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_share_sync_at\": {\n\t\t\t\t\t\"name\": \"last_share_sync_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0016_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"2c3abf30-f1cc-49ea-96f1-3b725e5398dd\",\n\t\"prevId\": \"a639464c-4977-4ba6-a9d7-7f60542a2f8e\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_sync_queue\": {\n\t\t\t\"name\": \"playlist_sync_queue\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation\": {\n\t\t\t\t\t\"name\": \"operation\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"payload\": {\n\t\t\t\t\t\"name\": \"payload\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'pending'\"\n\t\t\t\t},\n\t\t\t\t\"operation_at\": {\n\t\t\t\t\t\"name\": \"operation_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_sync_queue_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_sync_queue\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_id\": {\n\t\t\t\t\t\"name\": \"share_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_role\": {\n\t\t\t\t\t\"name\": \"share_role\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_share_sync_at\": {\n\t\t\t\t\t\"name\": \"last_share_sync_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0017_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"31911814-f083-419a-b906-aa508f89b7b5\",\n\t\"prevId\": \"2c3abf30-f1cc-49ea-96f1-3b725e5398dd\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_sync_queue\": {\n\t\t\t\"name\": \"playlist_sync_queue\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation\": {\n\t\t\t\t\t\"name\": \"operation\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"payload\": {\n\t\t\t\t\t\"name\": \"payload\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'pending'\"\n\t\t\t\t},\n\t\t\t\t\"operation_at\": {\n\t\t\t\t\t\"name\": \"operation_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_sync_queue_status_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_status_idx\",\n\t\t\t\t\t\"columns\": [\"status\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_sync_queue_playlist_id_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_sync_queue_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_sync_queue\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_id\": {\n\t\t\t\t\t\"name\": \"share_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_role\": {\n\t\t\t\t\t\"name\": \"share_role\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_share_sync_at\": {\n\t\t\t\t\t\"name\": \"last_share_sync_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_share_id_idx\": {\n\t\t\t\t\t\"name\": \"playlists_share_id_idx\",\n\t\t\t\t\t\"columns\": [\"share_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"play_history\": {\n\t\t\t\t\t\"name\": \"play_history\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'[]'\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0018_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"d574920c-fa44-4436-8d85-72401b2c03b9\",\n\t\"prevId\": \"31911814-f083-419a-b906-aa508f89b7b5\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"play_history\": {\n\t\t\t\"name\": \"play_history\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"start_time\": {\n\t\t\t\t\t\"name\": \"start_time\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration_played\": {\n\t\t\t\t\t\"name\": \"duration_played\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"completed\": {\n\t\t\t\t\t\"name\": \"completed\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"play_history_track_idx\": {\n\t\t\t\t\t\"name\": \"play_history_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"play_history_start_time_idx\": {\n\t\t\t\t\t\"name\": \"play_history_start_time_idx\",\n\t\t\t\t\t\"columns\": [\"start_time\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"play_history_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"play_history_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"play_history\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_sync_queue\": {\n\t\t\t\"name\": \"playlist_sync_queue\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation\": {\n\t\t\t\t\t\"name\": \"operation\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"payload\": {\n\t\t\t\t\t\"name\": \"payload\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'pending'\"\n\t\t\t\t},\n\t\t\t\t\"operation_at\": {\n\t\t\t\t\t\"name\": \"operation_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_sync_queue_status_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_status_idx\",\n\t\t\t\t\t\"columns\": [\"status\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_sync_queue_playlist_id_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_sync_queue_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_sync_queue\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_id\": {\n\t\t\t\t\t\"name\": \"share_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_role\": {\n\t\t\t\t\t\"name\": \"share_role\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_share_sync_at\": {\n\t\t\t\t\t\"name\": \"last_share_sync_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_share_id_idx\": {\n\t\t\t\t\t\"name\": \"playlists_share_id_idx\",\n\t\t\t\t\t\"columns\": [\"share_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0019_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"b7a69c01-9852-4c0d-bcbd-9b6735aebe3f\",\n\t\"prevId\": \"d574920c-fa44-4436-8d85-72401b2c03b9\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"dynamic_playlist_sources\": {\n\t\t\t\"name\": \"dynamic_playlist_sources\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source_playlist_id\": {\n\t\t\t\t\t\"name\": \"source_playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"position\": {\n\t\t\t\t\t\"name\": \"position\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"dynamic_playlist_sources_playlist_idx\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"dynamic_playlist_sources_source_idx\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_source_idx\",\n\t\t\t\t\t\"columns\": [\"source_playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"dynamic_playlist_sources_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"dynamic_playlist_sources\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"dynamic_playlist_sources_source_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_source_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"dynamic_playlist_sources\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"source_playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"dynamic_playlist_sources_playlist_id_source_playlist_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"source_playlist_id\"],\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_id_source_playlist_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"play_history\": {\n\t\t\t\"name\": \"play_history\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"start_time\": {\n\t\t\t\t\t\"name\": \"start_time\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration_played\": {\n\t\t\t\t\t\"name\": \"duration_played\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"completed\": {\n\t\t\t\t\t\"name\": \"completed\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"play_history_track_idx\": {\n\t\t\t\t\t\"name\": \"play_history_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"play_history_start_time_idx\": {\n\t\t\t\t\t\"name\": \"play_history_start_time_idx\",\n\t\t\t\t\t\"columns\": [\"start_time\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"play_history_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"play_history_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"play_history\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_sync_queue\": {\n\t\t\t\"name\": \"playlist_sync_queue\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation\": {\n\t\t\t\t\t\"name\": \"operation\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"payload\": {\n\t\t\t\t\t\"name\": \"payload\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'pending'\"\n\t\t\t\t},\n\t\t\t\t\"operation_at\": {\n\t\t\t\t\t\"name\": \"operation_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_sync_queue_status_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_status_idx\",\n\t\t\t\t\t\"columns\": [\"status\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_sync_queue_playlist_id_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_sync_queue_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_sync_queue\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_id\": {\n\t\t\t\t\t\"name\": \"share_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_role\": {\n\t\t\t\t\t\"name\": \"share_role\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_share_sync_at\": {\n\t\t\t\t\t\"name\": \"last_share_sync_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_share_id_idx\": {\n\t\t\t\t\t\"name\": \"playlists_share_id_idx\",\n\t\t\t\t\t\"columns\": [\"share_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/0020_snapshot.json",
    "content": "{\n\t\"version\": \"6\",\n\t\"dialect\": \"sqlite\",\n\t\"id\": \"fb480ae8-7103-416c-8090-0c473fb06f10\",\n\t\"prevId\": \"b7a69c01-9852-4c0d-bcbd-9b6735aebe3f\",\n\t\"tables\": {\n\t\t\"artists\": {\n\t\t\t\"name\": \"artists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"name\": {\n\t\t\t\t\t\"name\": \"name\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"avatar_url\": {\n\t\t\t\t\t\"name\": \"avatar_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"signature\": {\n\t\t\t\t\t\"name\": \"signature\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_id\": {\n\t\t\t\t\t\"name\": \"remote_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"source_remote_id_unq\": {\n\t\t\t\t\t\"name\": \"source_remote_id_unq\",\n\t\t\t\t\t\"columns\": [\"source\", \"remote_id\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source != 'local'\"\n\t\t\t\t},\n\t\t\t\t\"local_artist_unq\": {\n\t\t\t\t\t\"name\": \"local_artist_unq\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": true,\n\t\t\t\t\t\"where\": \"source = 'local'\"\n\t\t\t\t},\n\t\t\t\t\"artists_name_idx\": {\n\t\t\t\t\t\"name\": \"artists_name_idx\",\n\t\t\t\t\t\"columns\": [\"name\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {\n\t\t\t\t\"source_integrity_check\": {\n\t\t\t\t\t\"name\": \"source_integrity_check\",\n\t\t\t\t\t\"value\": \"\\n        (source = 'local' AND remote_id IS NULL) \\n        OR \\n        (source != 'local' AND remote_id IS NOT NULL)\\n      \"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"bilibili_metadata\": {\n\t\t\t\"name\": \"bilibili_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"bvid\": {\n\t\t\t\t\t\"name\": \"bvid\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cid\": {\n\t\t\t\t\t\"name\": \"cid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"is_multi_page\": {\n\t\t\t\t\t\"name\": \"is_multi_page\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"main_track_title\": {\n\t\t\t\t\t\"name\": \"main_track_title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"video_is_valid\": {\n\t\t\t\t\t\"name\": \"video_is_valid\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": true\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"bilibili_metadata_bvid_cid_idx\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_bvid_cid_idx\",\n\t\t\t\t\t\"columns\": [\"bvid\", \"cid\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"bilibili_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"bilibili_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"bilibili_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"dynamic_playlist_sources\": {\n\t\t\t\"name\": \"dynamic_playlist_sources\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"source_playlist_id\": {\n\t\t\t\t\t\"name\": \"source_playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"position\": {\n\t\t\t\t\t\"name\": \"position\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"dynamic_playlist_sources_playlist_idx\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"dynamic_playlist_sources_playlist_position_idx\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_position_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"position\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"dynamic_playlist_sources_source_idx\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_source_idx\",\n\t\t\t\t\t\"columns\": [\"source_playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"dynamic_playlist_sources_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"dynamic_playlist_sources\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"dynamic_playlist_sources_source_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_source_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"dynamic_playlist_sources\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"source_playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"dynamic_playlist_sources_playlist_id_source_playlist_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"source_playlist_id\"],\n\t\t\t\t\t\"name\": \"dynamic_playlist_sources_playlist_id_source_playlist_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"local_metadata\": {\n\t\t\t\"name\": \"local_metadata\",\n\t\t\t\"columns\": {\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"local_path\": {\n\t\t\t\t\t\"name\": \"local_path\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"local_metadata_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"local_metadata_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"local_metadata\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"play_history\": {\n\t\t\t\"name\": \"play_history\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"start_time\": {\n\t\t\t\t\t\"name\": \"start_time\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration_played\": {\n\t\t\t\t\t\"name\": \"duration_played\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"completed\": {\n\t\t\t\t\t\"name\": \"completed\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"play_history_track_idx\": {\n\t\t\t\t\t\"name\": \"play_history_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"play_history_start_time_idx\": {\n\t\t\t\t\t\"name\": \"play_history_start_time_idx\",\n\t\t\t\t\t\"columns\": [\"start_time\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"play_history_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"play_history_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"play_history\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_sync_queue\": {\n\t\t\t\"name\": \"playlist_sync_queue\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"operation\": {\n\t\t\t\t\t\"name\": \"operation\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"payload\": {\n\t\t\t\t\t\"name\": \"payload\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"status\": {\n\t\t\t\t\t\"name\": \"status\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"'pending'\"\n\t\t\t\t},\n\t\t\t\t\"operation_at\": {\n\t\t\t\t\t\"name\": \"operation_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_sync_queue_status_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_status_idx\",\n\t\t\t\t\t\"columns\": [\"status\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_sync_queue_playlist_id_idx\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_sync_queue_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_sync_queue_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_sync_queue\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlist_tracks\": {\n\t\t\t\"name\": \"playlist_tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"playlist_id\": {\n\t\t\t\t\t\"name\": \"playlist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"track_id\": {\n\t\t\t\t\t\"name\": \"track_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"sort_key\": {\n\t\t\t\t\t\"name\": \"sort_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlist_tracks_track_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_idx\",\n\t\t\t\t\t\"columns\": [\"track_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_sort_key_idx\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_sort_key_idx\",\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"sort_key\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_playlists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_playlists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"playlists\",\n\t\t\t\t\t\"columnsFrom\": [\"playlist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t},\n\t\t\t\t\"playlist_tracks_track_id_tracks_id_fk\": {\n\t\t\t\t\t\"name\": \"playlist_tracks_track_id_tracks_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlist_tracks\",\n\t\t\t\t\t\"tableTo\": \"tracks\",\n\t\t\t\t\t\"columnsFrom\": [\"track_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"cascade\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {\n\t\t\t\t\"playlist_tracks_playlist_id_track_id_pk\": {\n\t\t\t\t\t\"columns\": [\"playlist_id\", \"track_id\"],\n\t\t\t\t\t\"name\": \"playlist_tracks_playlist_id_track_id_pk\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"playlists\": {\n\t\t\t\"name\": \"playlists\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"author_id\": {\n\t\t\t\t\t\"name\": \"author_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"description\": {\n\t\t\t\t\t\"name\": \"description\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"item_count\": {\n\t\t\t\t\t\"name\": \"item_count\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": 0\n\t\t\t\t},\n\t\t\t\t\"type\": {\n\t\t\t\t\t\"name\": \"type\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"remote_sync_id\": {\n\t\t\t\t\t\"name\": \"remote_sync_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_synced_at\": {\n\t\t\t\t\t\"name\": \"last_synced_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_id\": {\n\t\t\t\t\t\"name\": \"share_id\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"share_role\": {\n\t\t\t\t\t\"name\": \"share_role\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"last_share_sync_at\": {\n\t\t\t\t\t\"name\": \"last_share_sync_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"playlists_title_idx\": {\n\t\t\t\t\t\"name\": \"playlists_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_type_idx\": {\n\t\t\t\t\t\"name\": \"playlists_type_idx\",\n\t\t\t\t\t\"columns\": [\"type\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_author_idx\": {\n\t\t\t\t\t\"name\": \"playlists_author_idx\",\n\t\t\t\t\t\"columns\": [\"author_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"playlists_share_id_idx\": {\n\t\t\t\t\t\"name\": \"playlists_share_id_idx\",\n\t\t\t\t\t\"columns\": [\"share_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"playlists_author_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"playlists_author_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"playlists\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"author_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t},\n\t\t\"tracks\": {\n\t\t\t\"name\": \"tracks\",\n\t\t\t\"columns\": {\n\t\t\t\t\"id\": {\n\t\t\t\t\t\"name\": \"id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": true,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": true\n\t\t\t\t},\n\t\t\t\t\"unique_key\": {\n\t\t\t\t\t\"name\": \"unique_key\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"title\": {\n\t\t\t\t\t\"name\": \"title\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"artist_id\": {\n\t\t\t\t\t\"name\": \"artist_id\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"cover_url\": {\n\t\t\t\t\t\"name\": \"cover_url\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"duration\": {\n\t\t\t\t\t\"name\": \"duration\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": false,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"created_at\": {\n\t\t\t\t\t\"name\": \"created_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t},\n\t\t\t\t\"source\": {\n\t\t\t\t\t\"name\": \"source\",\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false\n\t\t\t\t},\n\t\t\t\t\"updated_at\": {\n\t\t\t\t\t\"name\": \"updated_at\",\n\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\"primaryKey\": false,\n\t\t\t\t\t\"notNull\": true,\n\t\t\t\t\t\"autoincrement\": false,\n\t\t\t\t\t\"default\": \"(unixepoch() * 1000)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"indexes\": {\n\t\t\t\t\"tracks_unique_key_unique\": {\n\t\t\t\t\t\"name\": \"tracks_unique_key_unique\",\n\t\t\t\t\t\"columns\": [\"unique_key\"],\n\t\t\t\t\t\"isUnique\": true\n\t\t\t\t},\n\t\t\t\t\"tracks_artist_idx\": {\n\t\t\t\t\t\"name\": \"tracks_artist_idx\",\n\t\t\t\t\t\"columns\": [\"artist_id\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_title_idx\": {\n\t\t\t\t\t\"name\": \"tracks_title_idx\",\n\t\t\t\t\t\"columns\": [\"title\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t},\n\t\t\t\t\"tracks_source_idx\": {\n\t\t\t\t\t\"name\": \"tracks_source_idx\",\n\t\t\t\t\t\"columns\": [\"source\"],\n\t\t\t\t\t\"isUnique\": false\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"foreignKeys\": {\n\t\t\t\t\"tracks_artist_id_artists_id_fk\": {\n\t\t\t\t\t\"name\": \"tracks_artist_id_artists_id_fk\",\n\t\t\t\t\t\"tableFrom\": \"tracks\",\n\t\t\t\t\t\"tableTo\": \"artists\",\n\t\t\t\t\t\"columnsFrom\": [\"artist_id\"],\n\t\t\t\t\t\"columnsTo\": [\"id\"],\n\t\t\t\t\t\"onDelete\": \"set null\",\n\t\t\t\t\t\"onUpdate\": \"no action\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"compositePrimaryKeys\": {},\n\t\t\t\"uniqueConstraints\": {},\n\t\t\t\"checkConstraints\": {}\n\t\t}\n\t},\n\t\"views\": {},\n\t\"enums\": {},\n\t\"_meta\": {\n\t\t\"schemas\": {},\n\t\t\"tables\": {},\n\t\t\"columns\": {}\n\t},\n\t\"internal\": {\n\t\t\"indexes\": {}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/meta/_journal.json",
    "content": "{\n\t\"version\": \"7\",\n\t\"dialect\": \"sqlite\",\n\t\"entries\": [\n\t\t{\n\t\t\t\"idx\": 0,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1752671021496,\n\t\t\t\"tag\": \"0000_productive_joystick\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 1,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1752675517437,\n\t\t\t\"tag\": \"0001_fast_trauma\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 2,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1752730018660,\n\t\t\t\"tag\": \"0002_groovy_maximus\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 3,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1752764634399,\n\t\t\t\"tag\": \"0003_glamorous_psylocke\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 4,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1752765994431,\n\t\t\t\"tag\": \"0004_smiling_beast\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 5,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1752841803161,\n\t\t\t\"tag\": \"0005_spotty_exiles\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 6,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1754280698628,\n\t\t\t\"tag\": \"0006_breezy_jigsaw\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 7,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1754301066604,\n\t\t\t\"tag\": \"0007_legal_thor\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 8,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1754392244608,\n\t\t\t\"tag\": \"0008_overrated_jimmy_woo\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 9,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1754567195657,\n\t\t\t\"tag\": \"0009_lethal_marten_broadcloak\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 10,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1754922068529,\n\t\t\t\"tag\": \"0010_brainy_anita_blake\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 11,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1759414938750,\n\t\t\t\"tag\": \"0011_grey_echo\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 12,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1765119026759,\n\t\t\t\"tag\": \"0012_blushing_human_fly\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 13,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1771928969832,\n\t\t\t\"tag\": \"0013_jittery_randall\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 14,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1771933736787,\n\t\t\t\"tag\": \"0014_flippant_sebastian_shaw\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 15,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1772029574604,\n\t\t\t\"tag\": \"0015_flippant_skaar\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 16,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1772092756024,\n\t\t\t\"tag\": \"0016_cheerful_stark_industries\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 17,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1772169556066,\n\t\t\t\"tag\": \"0017_rare_lifeguard\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 18,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1774146549533,\n\t\t\t\"tag\": \"0018_green_dracula\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 19,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1777691519059,\n\t\t\t\"tag\": \"0019_icy_mandarin\",\n\t\t\t\"breakpoints\": true\n\t\t},\n\t\t{\n\t\t\t\"idx\": 20,\n\t\t\t\"version\": \"6\",\n\t\t\t\"when\": 1777696930773,\n\t\t\t\"tag\": \"0020_ambitious_sheva_callister\",\n\t\t\t\"breakpoints\": true\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "apps/mobile/drizzle/migrations.js",
    "content": "// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo\n\nimport m0000 from './0000_productive_joystick.sql'\nimport m0001 from './0001_fast_trauma.sql'\nimport m0002 from './0002_groovy_maximus.sql'\nimport m0003 from './0003_glamorous_psylocke.sql'\nimport m0004 from './0004_smiling_beast.sql'\nimport m0005 from './0005_spotty_exiles.sql'\nimport m0006 from './0006_breezy_jigsaw.sql'\nimport m0007 from './0007_legal_thor.sql'\nimport m0008 from './0008_overrated_jimmy_woo.sql'\nimport m0009 from './0009_lethal_marten_broadcloak.sql'\nimport m0010 from './0010_brainy_anita_blake.sql'\nimport m0011 from './0011_grey_echo.sql'\nimport m0012 from './0012_blushing_human_fly.sql'\nimport m0013 from './0013_jittery_randall.sql'\nimport m0014 from './0014_flippant_sebastian_shaw.sql'\nimport m0015 from './0015_flippant_skaar.sql'\nimport m0016 from './0016_cheerful_stark_industries.sql'\nimport m0017 from './0017_rare_lifeguard.sql'\nimport m0018 from './0018_green_dracula.sql'\nimport m0019 from './0019_icy_mandarin.sql'\nimport m0020 from './0020_ambitious_sheva_callister.sql'\nimport journal from './meta/_journal.json'\n\nexport default {\n\tjournal,\n\tmigrations: {\n\t\tm0000,\n\t\tm0001,\n\t\tm0002,\n\t\tm0003,\n\t\tm0004,\n\t\tm0005,\n\t\tm0006,\n\t\tm0007,\n\t\tm0008,\n\t\tm0009,\n\t\tm0010,\n\t\tm0011,\n\t\tm0012,\n\t\tm0013,\n\t\tm0014,\n\t\tm0015,\n\t\tm0016,\n\t\tm0017,\n\t\tm0018,\n\t\tm0019,\n\t\tm0020,\n\t},\n}\n"
  },
  {
    "path": "apps/mobile/drizzle.config.ts",
    "content": "import type { Config } from 'drizzle-kit'\nexport default {\n\tschema: './src/lib/db/schema.ts',\n\tout: './drizzle',\n\tdialect: 'sqlite',\n\tdriver: 'expo',\n} satisfies Config\n"
  },
  {
    "path": "apps/mobile/eas.json",
    "content": "{\n\t\"cli\": {\n\t\t\"version\": \">= 13.4.2\",\n\t\t\"appVersionSource\": \"local\"\n\t},\n\t\"build\": {\n\t\t\"dev\": {\n\t\t\t\"developmentClient\": true,\n\t\t\t\"distribution\": \"internal\",\n\t\t\t\"channel\": \"development\",\n\t\t\t\"android\": {\n\t\t\t\t\"gradleCommand\": \":app:assembleDebug\"\n\t\t\t},\n\t\t\t\"env\": {\n\t\t\t\t\"APP_VARIANT\": \"development\",\n\t\t\t\t\"ABI_FILTERS\": \"arm64-v8a\"\n\t\t\t}\n\t\t},\n\t\t\"prod-v8a\": {\n\t\t\t\"autoIncrement\": false,\n\t\t\t\"android\": {\n\t\t\t\t\"buildType\": \"apk\",\n\t\t\t\t\"gradleCommand\": \":app:assembleRelease\"\n\t\t\t},\n\t\t\t\"channel\": \"production\",\n\t\t\t\"env\": {\n\t\t\t\t\"APP_VARIANT\": \"production\",\n\t\t\t\t\"ABI_FILTERS\": \"arm64-v8a\"\n\t\t\t}\n\t\t},\n\t\t\"prod-ci\": {\n\t\t\t\"autoIncrement\": false,\n\t\t\t\"android\": {\n\t\t\t\t\"buildType\": \"apk\",\n\t\t\t\t\"gradleCommand\": \":app:assembleRelease\"\n\t\t\t},\n\t\t\t\"channel\": \"production\",\n\t\t\t\"env\": {\n\t\t\t\t\"APP_VARIANT\": \"production\"\n\t\t\t}\n\t\t},\n\t\t\"prod-universal\": {\n\t\t\t\"autoIncrement\": false,\n\t\t\t\"android\": {\n\t\t\t\t\"buildType\": \"apk\",\n\t\t\t\t\"gradleCommand\": \":app:assembleRelease\"\n\t\t\t},\n\t\t\t\"channel\": \"production\",\n\t\t\t\"env\": {\n\t\t\t\t\"APP_VARIANT\": \"production\",\n\t\t\t\t\"ABI_FILTERS\": \"armeabi-v7a,arm64-v8a,x86,x86_64\"\n\t\t\t}\n\t\t},\n\t\t\"preview\": {\n\t\t\t\"autoIncrement\": false,\n\t\t\t\"distribution\": \"internal\",\n\t\t\t\"android\": {\n\t\t\t\t\"buildType\": \"apk\",\n\t\t\t\t\"gradleCommand\": \":app:assembleRelease\"\n\t\t\t},\n\t\t\t\"channel\": \"preview\",\n\t\t\t\"env\": {\n\t\t\t\t\"APP_VARIANT\": \"preview\",\n\t\t\t\t\"ABI_FILTERS\": \"arm64-v8a\"\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/expo-plugins/withAbiFilters.js",
    "content": "const {\n\twithGradleProperties,\n\twithAppBuildGradle,\n} = require('expo/config-plugins')\n\nconst withAbiFilters = (config, { abiFilters = ['arm64-v8a'] } = {}) => {\n\t// Set gradle.properties\n\tconfig = withGradleProperties(config, (config) => {\n\t\t// Convert array to comma-separated string for gradle.properties\n\t\tconst architecturesString = abiFilters.join(',')\n\n\t\t// Set the reactNativeArchitectures property\n\t\tconfig.modResults = config.modResults.filter(\n\t\t\t(item) => !item.key || item.key !== 'reactNativeArchitectures',\n\t\t)\n\n\t\tconfig.modResults.push({\n\t\t\ttype: 'property',\n\t\t\tkey: 'reactNativeArchitectures',\n\t\t\tvalue: architecturesString,\n\t\t})\n\n\t\treturn config\n\t})\n\n\t// Set build.gradle ndk.abiFilters\n\tconfig = withAppBuildGradle(config, (config) => {\n\t\tconst abiFiltersString = abiFilters.map((abi) => `\"${abi}\"`).join(', ')\n\n\t\t// Add ndk abiFilters to defaultConfig\n\t\tif (config.modResults.contents.includes('defaultConfig {')) {\n\t\t\tconfig.modResults.contents = config.modResults.contents.replace(\n\t\t\t\t/(defaultConfig\\s*\\{[^}]*versionName\\s+[^}]*)/,\n\t\t\t\t`$1\n        \n        ndk {\n            abiFilters ${abiFiltersString}\n        }`,\n\t\t\t)\n\t\t}\n\n\t\treturn config\n\t})\n\n\treturn config\n}\n\nmodule.exports = withAbiFilters\n"
  },
  {
    "path": "apps/mobile/expo-plugins/withAndroidGradleProperties.js",
    "content": "const { withGradleProperties } = require('expo/config-plugins')\n\nconst GRADLE_XMX = process.env.GRADLE_XMX || '4g'\nconst KOTLIN_XMX = process.env.KOTLIN_XMX || '2g'\nconst WORKERS_MAX =\n\tprocess.env.ORG_GRADLE_WORKERS_MAX || process.env.GRADLE_WORKERS || '4'\n\nconst newProperties = {\n\t'org.gradle.jvmargs': `-Xmx${GRADLE_XMX} -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8`,\n\t'kotlin.daemon.jvm.args': `-Xmx${KOTLIN_XMX}`,\n\t'org.gradle.workers.max': WORKERS_MAX,\n}\n\nconst withAndroidGradleProperties = (config) => {\n\treturn withGradleProperties(config, (config) => {\n\t\tfor (const [key, value] of Object.entries(newProperties)) {\n\t\t\tconst existingProp = config.modResults.find(\n\t\t\t\t(prop) => prop.type === 'property' && prop.key === key,\n\t\t\t)\n\n\t\t\tif (existingProp) {\n\t\t\t\texistingProp.value = value\n\t\t\t} else {\n\t\t\t\tconfig.modResults.push({\n\t\t\t\t\ttype: 'property',\n\t\t\t\t\tkey: key,\n\t\t\t\t\tvalue: value,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\treturn config\n\t})\n}\n\nmodule.exports = withAndroidGradleProperties\n"
  },
  {
    "path": "apps/mobile/expo-plugins/withAndroidPlugin.js",
    "content": "const configPlugins = require('expo/config-plugins')\n\nconst { withAndroidManifest, withStringsXml } = configPlugins\n\nconst withAndroidPlugin = (config) => {\n\tconst configWithStrings = withStringsXml(config, (config) => {\n\t\tconst strings = config?.modResults?.resources?.string\n\n\t\tif (strings) {\n\t\t\tstrings.push({\n\t\t\t\t$: {\n\t\t\t\t\tname: 'rntp_temporary_channel_id',\n\t\t\t\t},\n\t\t\t\t_: 'bbplayer',\n\t\t\t})\n\t\t\tstrings.push({\n\t\t\t\t$: {\n\t\t\t\t\tname: 'rntp_temporary_channel_name',\n\t\t\t\t},\n\t\t\t\t_: 'bbplayer',\n\t\t\t})\n\t\t\tstrings.push({\n\t\t\t\t$: {\n\t\t\t\t\tname: 'playback_channel_name',\n\t\t\t\t},\n\t\t\t\t_: 'BBPlayer',\n\t\t\t})\n\t\t}\n\n\t\treturn config\n\t})\n\n\treturn withAndroidManifest(configWithStrings, (config) => {\n\t\tconst intents = config?.modResults?.manifest?.queries?.[0]?.intent\n\n\t\tif (intents) {\n\t\t\tintents[0].data?.push({\n\t\t\t\t$: {\n\t\t\t\t\t'android:mimeType': 'text/plain',\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\treturn config\n\t})\n}\n\nmodule.exports = withAndroidPlugin\n"
  },
  {
    "path": "apps/mobile/expo-plugins/withKotlinSerialization.js",
    "content": "const { withProjectBuildGradle } = require('expo/config-plugins')\n\nconst withKotlinSerialization = (config) => {\n\treturn withProjectBuildGradle(config, (config) => {\n\t\tif (config.modResults.language === 'groovy') {\n\t\t\tconst contents = config.modResults.contents\n\t\t\tif (!contents.includes('org.jetbrains.kotlin:kotlin-serialization')) {\n\t\t\t\tconfig.modResults.contents = contents.replace(\n\t\t\t\t\t/classpath\\('org.jetbrains.kotlin:kotlin-gradle-plugin'\\)/,\n\t\t\t\t\t`classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')\\n        classpath('org.jetbrains.kotlin:kotlin-serialization')`,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\treturn config\n\t})\n}\n\nmodule.exports = withKotlinSerialization\n"
  },
  {
    "path": "apps/mobile/index.js",
    "content": "import { playerSideEffects } from './src/lib/player/PlayerSideEffects'\n\nplayerSideEffects.initialize()\n\nimport 'expo-router/entry'\n"
  },
  {
    "path": "apps/mobile/metro.config.js",
    "content": "/* oxlint-disable @typescript-eslint/no-require-imports */\nconst path = require('path')\nconst { withRozenite } = require('@rozenite/metro')\nconst { getSentryExpoConfig } = require('@sentry/react-native/metro')\nconst {\n\twithRozeniteRequireProfiler,\n} = require('@rozenite/require-profiler-plugin/metro')\nconst {\n\twithRozeniteBundleDiscoveryPlugin,\n} = require('react-native-bundle-discovery-rozenite-plugin')\nconst {\n\twrapWithReanimatedMetroConfig,\n} = require('react-native-reanimated/metro-config')\n\nmodule.exports = withRozenite(\n\t(async () => {\n\t\tconst config = getSentryExpoConfig(__dirname, {\n\t\t\tannotateReactComponents: true,\n\t\t})\n\n\t\tconfig.resolver.unstable_enablePackageExports = true\n\t\tconfig.resolver.sourceExts.push('sql')\n\n\t\treturn wrapWithReanimatedMetroConfig(config)\n\t})(),\n\t{\n\t\tenabled: process.env.WITH_ROZENITE === 'true',\n\t\tenhanceMetroConfig: (config) =>\n\t\t\twithRozeniteBundleDiscoveryPlugin(withRozeniteRequireProfiler(config)),\n\t},\n)\n"
  },
  {
    "path": "apps/mobile/mise.toml",
    "content": "[tasks.buildprod]\ndescription = \"Build BBPlayer production release\"\nenv = { NODE_ENV = \"production\", BUILD_MODE = \"v8a\", VERSION_CODE = \"{{exec(command='git rev-list --count HEAD')}}\" }\nrun = 'eas build --profile prod-v8a --platform android --local --output=./temp-builds/{{arg(name=\"version\")}}-prod.apk'\n[tasks.builddev]\ndescription = \"Build BBPlayer development release\"\nenv = { NODE_ENV = \"development\", BUILD_MODE = \"v8a\", VERSION_CODE = \"{{exec(command='git rev-list --count HEAD')}}\" }\nrun = 'eas build --profile dev --platform android --local --output=./temp-builds/{{arg(name=\"version\")}}-dev.apk'\n[tasks.buildpreview]\ndescription = \"Build BBPlayer preview release\"\nenv = { NODE_ENV = \"development\", BUILD_MODE = \"v8a\", VERSION_CODE = \"{{exec(command='git rev-list --count HEAD')}}\" }\nrun = 'eas build --profile preview --platform android --local --output=./temp-builds/{{arg(name=\"version\")}}-preview.apk'\n[env]\n_.file = { path = \".env.local\", redact = true }\n"
  },
  {
    "path": "apps/mobile/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/mobile\",\n\t\"version\": \"2.4.6\",\n\t\"private\": true,\n\t\"main\": \"index.js\",\n\t\"scripts\": {\n\t\t\"android\": \"WITH_ROZENITE=true expo run:android\",\n\t\t\"format\": \"eslint . --fix && oxfmt --write .\",\n\t\t\"lint\": \"eslint .\",\n\t\t\"postinstall\": \"patch-package\",\n\t\t\"prepare\": \"pbjs -t static-module -w commonjs -o src/lib/api/bilibili/proto/dm.js src/lib/api/bilibili/proto/dm.proto && pbts -o src/lib/api/bilibili/proto/dm.d.ts src/lib/api/bilibili/proto/dm.js\",\n\t\t\"start\": \"WITH_ROZENITE=true APP_VARIANT=development expo start\",\n\t\t\"test\": \"jest --watchAll\"\n\t},\n\t\"dependencies\": {\n\t\t\"@bbplayer/heatmap\": \"workspace:*\",\n\t\t\"@bbplayer/image-theme-colors\": \"workspace:*\",\n\t\t\"@bbplayer/logs\": \"workspace:*\",\n\t\t\"@bbplayer/native\": \"workspace:*\",\n\t\t\"@bbplayer/orpheus\": \"workspace:*\",\n\t\t\"@bbplayer/splash\": \"workspace:*\",\n\t\t\"@bottom-tabs/react-navigation\": \"^0.10.2\",\n\t\t\"@expo/metro-runtime\": \"~55.0.6\",\n\t\t\"@gorhom/bottom-sheet\": \"^5.2.8\",\n\t\t\"@lodev09/react-native-true-sheet\": \"^3.7.3\",\n\t\t\"@nandorojo/galeria\": \"^2.0.2\",\n\t\t\"@react-native-community/netinfo\": \"^11.5.2\",\n\t\t\"@react-native-community/slider\": \"^5.1.2\",\n\t\t\"@react-native-firebase/analytics\": \"^23.8.6\",\n\t\t\"@react-native-firebase/app\": \"^23.8.6\",\n\t\t\"@react-native-masked-view/masked-view\": \"^0.3.2\",\n\t\t\"@react-native-vector-icons/get-image\": \"^12.3.0\",\n\t\t\"@react-native-vector-icons/material-design-icons\": \"^12.4.0\",\n\t\t\"@react-navigation/devtools\": \"^7.0.47\",\n\t\t\"@react-navigation/elements\": \"^2.9.5\",\n\t\t\"@react-navigation/native\": \"^7.1.8\",\n\t\t\"@rnrepo/expo-config-plugin\": \"0.1.0-beta.0\",\n\t\t\"@sentry/cli\": \"^2.58.4\",\n\t\t\"@sentry/react-native\": \"~7.11.0\",\n\t\t\"@shopify/flash-list\": \"^2.2.2\",\n\t\t\"@shopify/react-native-skia\": \"2.4.18\",\n\t\t\"@tanstack/react-query\": \"^5.90.19\",\n\t\t\"babel-preset-expo\": \"~55.0.10\",\n\t\t\"color\": \"^4.2.3\",\n\t\t\"cookie\": \"^1.0.2\",\n\t\t\"crypto-js\": \"^4.2.0\",\n\t\t\"dayjs\": \"^1.11.19\",\n\t\t\"drizzle-orm\": \"^0.44.7\",\n\t\t\"expo\": \"55.0.4\",\n\t\t\"expo-application\": \"~55.0.8\",\n\t\t\"expo-asset\": \"~55.0.8\",\n\t\t\"expo-blur\": \"~55.0.8\",\n\t\t\"expo-build-properties\": \"~55.0.9\",\n\t\t\"expo-clipboard\": \"~55.0.8\",\n\t\t\"expo-constants\": \"~55.0.7\",\n\t\t\"expo-dev-client\": \"~55.0.10\",\n\t\t\"expo-document-picker\": \"~55.0.8\",\n\t\t\"expo-file-system\": \"55.0.10\",\n\t\t\"expo-font\": \"~55.0.4\",\n\t\t\"expo-haptics\": \"~55.0.8\",\n\t\t\"expo-image\": \"~55.0.5\",\n\t\t\"expo-insights\": \"~55.0.10\",\n\t\t\"expo-linear-gradient\": \"~55.0.8\",\n\t\t\"expo-linking\": \"~55.0.7\",\n\t\t\"expo-media-library\": \"~55.0.9\",\n\t\t\"expo-router\": \"55.0.3\",\n\t\t\"expo-sharing\": \"~55.0.11\",\n\t\t\"expo-splash-screen\": \"~55.0.10\",\n\t\t\"expo-sqlite\": \"~55.0.10\",\n\t\t\"expo-system-ui\": \"~55.0.9\",\n\t\t\"expo-updates\": \"~55.0.12\",\n\t\t\"expo-web-browser\": \"~55.0.9\",\n\t\t\"fractional-indexing\": \"^3.2.0\",\n\t\t\"he\": \"^1.2.0\",\n\t\t\"hono\": \"^4.12.2\",\n\t\t\"immer\": \"^10.2.0\",\n\t\t\"lottie-react-native\": \"~7.3.6\",\n\t\t\"md5\": \"^2.3.0\",\n\t\t\"mitt\": \"^3.0.1\",\n\t\t\"neverthrow\": \"^8.2.0\",\n\t\t\"node-forge\": \"1.3.2\",\n\t\t\"patch-package\": \"^8.0.1\",\n\t\t\"protobufjs\": \"^8.0.0\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\",\n\t\t\"react-native-bottom-tabs\": \"^0.10.2\",\n\t\t\"react-native-edge-to-edge\": \"^1.7.0\",\n\t\t\"react-native-fast-shimmer\": \"^1.3.4\",\n\t\t\"react-native-fast-squircle\": \"^1.1.1\",\n\t\t\"react-native-gesture-handler\": \"~2.30.0\",\n\t\t\"react-native-gradle-plugin\": \"^0.71.19\",\n\t\t\"react-native-keyboard-controller\": \"1.20.7\",\n\t\t\"react-native-mmkv\": \"^4.1.1\",\n\t\t\"react-native-nitro-modules\": \"^0.33.2\",\n\t\t\"react-native-pager-view\": \"8.0.0\",\n\t\t\"react-native-paper\": \"^5.14.5\",\n\t\t\"react-native-qrcode-svg\": \"^6.3.21\",\n\t\t\"react-native-reanimated\": \"~4.2.2\",\n\t\t\"react-native-safe-area-context\": \"~5.6.2\",\n\t\t\"react-native-screens\": \"~4.23.0\",\n\t\t\"react-native-svg\": \"15.15.3\",\n\t\t\"react-native-tab-view\": \"^4.2.2\",\n\t\t\"react-native-text-ticker\": \"^1.15.0\",\n\t\t\"react-native-view-shot\": \"^4.0.3\",\n\t\t\"react-native-webview\": \"~13.15.0\",\n\t\t\"react-native-worklets\": \"0.7.4\",\n\t\t\"runes2\": \"^1.1.4\",\n\t\t\"set-cookie-parser\": \"^2.7.2\",\n\t\t\"shaka-player\": \"^4.14.10\",\n\t\t\"sonner-native\": \"^0.23.0\",\n\t\t\"zustand\": \"^5.0.10\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@babel/core\": \"^7.27.1\",\n\t\t\"@bbplayer/backend\": \"workspace:*\",\n\t\t\"@bbplayer/eslint-plugin\": \"workspace:*\",\n\t\t\"@eslint/js\": \"^9.39.2\",\n\t\t\"@faker-js/faker\": \"^9.9.0\",\n\t\t\"@jest/globals\": \"^29.7.0\",\n\t\t\"@react-native-community/cli\": \"latest\",\n\t\t\"@rozenite/metro\": \"^1.3.0\",\n\t\t\"@rozenite/mmkv-plugin\": \"^1.3.0\",\n\t\t\"@rozenite/require-profiler-plugin\": \"^1.3.0\",\n\t\t\"@rozenite/tanstack-query-plugin\": \"^1.3.0\",\n\t\t\"@tanstack/eslint-plugin-query\": \"^5.91.4\",\n\t\t\"@testing-library/react-native\": \"^13.3.3\",\n\t\t\"@types/color\": \"^4.2.0\",\n\t\t\"@types/crypto-js\": \"^4.2.2\",\n\t\t\"@types/he\": \"^1.2.3\",\n\t\t\"@types/jest\": \"^29.5.14\",\n\t\t\"@types/md5\": \"^2.3.6\",\n\t\t\"@types/node\": \"^25.2.3\",\n\t\t\"@types/node-forge\": \"^1.3.14\",\n\t\t\"@types/react\": \"~19.2.9\",\n\t\t\"@types/react-native-vector-icons\": \"^6.4.18\",\n\t\t\"@types/set-cookie-parser\": \"^2.4.10\",\n\t\t\"babel-plugin-inline-import\": \"^3.0.0\",\n\t\t\"babel-plugin-react-compiler\": \"19.1.0-rc.1\",\n\t\t\"babel-plugin-transform-remove-console\": \"^6.9.4\",\n\t\t\"drizzle-kit\": \"^0.31.9\",\n\t\t\"eslint\": \"^9.39.2\",\n\t\t\"eslint-config-expo\": \"~55.0.0\",\n\t\t\"eslint-config-prettier\": \"^10.1.8\",\n\t\t\"eslint-plugin-drizzle\": \"^0.2.3\",\n\t\t\"eslint-plugin-react-compiler\": \"19.1.0-rc.1\",\n\t\t\"globals\": \"^16.5.0\",\n\t\t\"jest-expo\": \"^55.0.5\",\n\t\t\"lefthook\": \"^1.13.6\",\n\t\t\"oxfmt\": \"^0.27.0\",\n\t\t\"oxlint\": \"^1.47.0\",\n\t\t\"prettier-plugin-organize-imports\": \"^4.3.0\",\n\t\t\"protobufjs-cli\": \"^2.0.0\",\n\t\t\"react-native-bundle-discovery\": \"^1.2.4\",\n\t\t\"react-native-bundle-discovery-rozenite-plugin\": \"^1.0.0\",\n\t\t\"ts-jest\": \"^29.4.1\",\n\t\t\"ts-node\": \"^10.9.2\",\n\t\t\"tsconfig-paths\": \"^4.2.0\",\n\t\t\"tsx\": \"^4.21.0\",\n\t\t\"typescript\": \"~5.9.3\",\n\t\t\"typescript-eslint\": \"^8.55.0\"\n\t},\n\t\"jest\": {\n\t\t\"preset\": \"jest-expo\",\n\t\t\"testPathIgnorePatterns\": [\n\t\t\t\"/node_modules/\",\n\t\t\t\"app/test.tsx\"\n\t\t]\n\t},\n\t\"reanimated\": {\n\t\t\"staticFeatureFlags\": {\n\t\t\t\"ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS\": false,\n\t\t\t\"IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS\": false,\n\t\t\t\"USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS\": false\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/app/(tabs)/_layout.tsx",
    "content": "import type {\n\tNativeBottomTabNavigationEventMap,\n\tNativeBottomTabNavigationOptions,\n} from '@bottom-tabs/react-navigation'\nimport { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'\nimport Icon from '@react-native-vector-icons/material-design-icons'\nimport type {\n\tParamListBase,\n\tTabNavigationState,\n} from '@react-navigation/native'\nimport { withLayoutContext } from 'expo-router'\nimport { useTheme } from 'react-native-paper'\n\nconst BottomTabNavigator = createNativeBottomTabNavigator().Navigator\n\nconst Tabs = withLayoutContext<\n\tNativeBottomTabNavigationOptions,\n\ttypeof BottomTabNavigator,\n\tTabNavigationState<ParamListBase>,\n\tNativeBottomTabNavigationEventMap\n>(BottomTabNavigator)\n\ninterface nonNullableIcon {\n\turi: string\n\tscale: number\n}\n\nconst homeIcon = Icon.getImageSourceSync('home', 24) as nonNullableIcon\nconst libraryIcon = Icon.getImageSourceSync('bookshelf', 24) as nonNullableIcon\nconst settingsIcon = Icon.getImageSourceSync('cog', 24) as nonNullableIcon\n\nexport default function TabLayout() {\n\tconst themes = useTheme().colors\n\n\treturn (\n\t\t<Tabs\n\t\t\tdisablePageAnimations\n\t\t\ttabBarActiveTintColor={themes.primary}\n\t\t\tactiveIndicatorColor={themes.primaryContainer}\n\t\t\ttabBarStyle={{ backgroundColor: themes.elevation.level1 }}\n\t\t\tinitialRouteName='index'\n\t\t>\n\t\t\t<Tabs.Screen\n\t\t\t\tname='index'\n\t\t\t\toptions={{\n\t\t\t\t\ttitle: '主页',\n\t\t\t\t\ttabBarIcon: () => homeIcon,\n\t\t\t\t\ttabBarLabel: '主页',\n\t\t\t\t\tlazy: true,\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<Tabs.Screen\n\t\t\t\tname='library/[tab]'\n\t\t\t\toptions={{\n\t\t\t\t\ttitle: '音乐库',\n\t\t\t\t\ttabBarIcon: () => libraryIcon,\n\t\t\t\t\ttabBarLabel: '音乐库',\n\t\t\t\t\tlazy: true,\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<Tabs.Screen\n\t\t\t\tname='settings/index'\n\t\t\t\toptions={{\n\t\t\t\t\ttitle: '设置',\n\t\t\t\t\ttabBarIcon: () => settingsIcon,\n\t\t\t\t\ttabBarLabel: '设置',\n\t\t\t\t\tlazy: true,\n\t\t\t\t}}\n\t\t\t/>\n\t\t</Tabs>\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/app/(tabs)/index.tsx",
    "content": "import { WeeklyHeatMap } from '@bbplayer/heatmap'\nimport type { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport Color from 'color'\nimport dayjs from 'dayjs'\nimport { eq } from 'drizzle-orm'\nimport { useLiveQuery } from 'drizzle-orm/expo-sqlite'\nimport { Image } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { useIncomingShare } from 'expo-sharing'\nimport {\n\tuseCallback,\n\tuseDeferredValue,\n\tuseEffect,\n\tuseRef,\n\tuseState,\n} from 'react'\nimport {\n\tKeyboard,\n\tPlatform,\n\tScrollView,\n\tStyleSheet,\n\tToastAndroid,\n\tView,\n} from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { useMMKVObject } from 'react-native-mmkv'\nimport {\n\tActivityIndicator,\n\tIcon,\n\tSearchbar,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useAnimatedRef } from 'react-native-reanimated'\nimport Animated from 'react-native-reanimated'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport IconButton from '@/components/common/IconButton'\nimport { alert } from '@/components/modals/AlertModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport SearchSuggestions, {\n\ttype SearchHistoryItem,\n} from '@/features/home/SearchSuggestions'\nimport { SyncFailuresSheet } from '@/features/playlist/local/components/SyncFailuresSheet'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport { usePlayHistoryHeatmap } from '@/hooks/queries/playHistory'\nimport { useRecentPlaylists } from '@/hooks/queries/useRecentPlaylists'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { queryClient } from '@/lib/config/queryClient'\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport {\n\tmatchSearchStrategies,\n\tnavigateWithSearchStrategy,\n} from '@/utils/search'\nimport toast from '@/utils/toast'\n\nconst SEARCH_HISTORY_KEY = 'bilibili_search_history'\nconst MAX_SEARCH_HISTORY = 10\n\nconst getGreetingMsg = () => {\n\tconst hour = new Date().getHours()\n\tif (hour >= 0 && hour < 6) return '凌晨好'\n\tif (hour >= 6 && hour < 12) return '早上好'\n\tif (hour >= 12 && hour < 18) return '下午好'\n\tif (hour >= 18 && hour < 24) return '晚上好'\n\treturn '你好'\n}\n\nfunction HomePage() {\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst insets = useSafeAreaInsets()\n\tconst router = useRouter()\n\tconst [searchQuery, setSearchQuery] = useState('')\n\tconst deferredSearchQuery = useDeferredValue(searchQuery)\n\tconst [searchHistory, setSearchHistory] =\n\t\tuseMMKVObject<SearchHistoryItem[]>(SEARCH_HISTORY_KEY)\n\tconst [isLoading, setIsLoading] = useState(false)\n\tconst [searchFocused, setSearchFocused] = useState(false)\n\tconst { resolvedSharedPayloads, isResolving, clearSharedPayloads } =\n\t\tuseIncomingShare()\n\tconst clearBilibiliCookie = useAppStore((state) => state.clearBilibiliCookie)\n\tconst hasBilibiliCookie = useAppStore((state) => state.hasBilibiliCookie)\n\tconst searchBarRef = useAnimatedRef<View>()\n\tconst syncFailuresSheetRef = useRef<TrueSheet>(null)\n\n\tconst { data: personalInfo } = usePersonalInformation()\n\tconst { data: heatmapData } = usePlayHistoryHeatmap()\n\n\tconst { data: syncFailures } = useLiveQuery(\n\t\tdb\n\t\t\t.select({ id: schema.playlistSyncQueue.id })\n\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t.where(eq(schema.playlistSyncQueue.status, 'failed'))\n\t\t\t.limit(1),\n\t)\n\tconst hasSyncFailures = (syncFailures?.length ?? 0) > 0\n\n\tconst { data: recentPlaylists } = useRecentPlaylists()\n\n\tconst greeting = getGreetingMsg()\n\n\tconst saveSearchHistory = useCallback(\n\t\t(history: SearchHistoryItem[]) => {\n\t\t\ttry {\n\t\t\t\tsetSearchHistory(history)\n\t\t\t} catch (error) {\n\t\t\t\ttoastAndLogError('保存搜索历史失败', error, 'UI.Home')\n\t\t\t}\n\t\t},\n\t\t[setSearchHistory],\n\t)\n\n\tconst addSearchHistory = useCallback(\n\t\t(query: string) => {\n\t\t\tif (!query.trim()) return\n\n\t\t\tconst newItem: SearchHistoryItem = {\n\t\t\t\tid: `history_${Date.now()}`,\n\t\t\t\ttext: query,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t}\n\n\t\t\tconst currentHistory = searchHistory ?? []\n\t\t\tconst existingIndex = currentHistory.findIndex(\n\t\t\t\t(item) => item.text.toLowerCase() === query.toLowerCase(),\n\t\t\t)\n\n\t\t\tlet newHistory: SearchHistoryItem[]\n\n\t\t\tif (existingIndex !== -1) {\n\t\t\t\tnewHistory = [\n\t\t\t\t\tnewItem,\n\t\t\t\t\t...currentHistory.filter(\n\t\t\t\t\t\t(item) => item.text.toLowerCase() !== query.toLowerCase(),\n\t\t\t\t\t),\n\t\t\t\t]\n\t\t\t} else {\n\t\t\t\tnewHistory = [newItem, ...currentHistory]\n\t\t\t}\n\n\t\t\tif (newHistory.length > MAX_SEARCH_HISTORY) {\n\t\t\t\tnewHistory = newHistory.slice(0, MAX_SEARCH_HISTORY)\n\t\t\t}\n\n\t\t\tsaveSearchHistory(newHistory)\n\t\t},\n\t\t[searchHistory, saveSearchHistory],\n\t)\n\n\tconst handleEnter = useCallback(\n\t\tasync (query: string) => {\n\t\t\tif (!query.trim()) return\n\t\t\tKeyboard.dismiss()\n\t\t\tsetIsLoading(true)\n\t\t\tconst addToHistory = await matchSearchStrategies(query)\n\t\t\tconst needAddToHistory = navigateWithSearchStrategy(addToHistory, router)\n\t\t\tif (needAddToHistory === 1) {\n\t\t\t\taddSearchHistory(query)\n\t\t\t}\n\t\t\tsetIsLoading(false)\n\t\t\tsetSearchQuery('')\n\t\t\tsetSearchFocused(false)\n\t\t},\n\t\t[addSearchHistory, router],\n\t)\n\n\tconst handleSuggestionPress = useCallback(\n\t\t(query: string) => {\n\t\t\tvoid handleEnter(query)\n\t\t},\n\t\t[handleEnter],\n\t)\n\n\tconst handleClearHistory = useCallback(() => {\n\t\talert(\n\t\t\t'清空搜索历史？',\n\t\t\t'确定要清空吗？',\n\t\t\t[\n\t\t\t\t{ text: '取消' },\n\t\t\t\t{\n\t\t\t\t\ttext: '确定',\n\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\tsetSearchHistory([])\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t{ cancelable: true },\n\t\t)\n\t}, [setSearchHistory])\n\n\tconst handleRemoveHistoryItem = useCallback(\n\t\t(id: string) => {\n\t\t\tconst item = searchHistory?.find((h) => h.id === id)\n\t\t\tif (!item) return\n\t\t\talert(\n\t\t\t\t'删除搜索历史？',\n\t\t\t\t`确定要删除「${item.text}」吗？`,\n\t\t\t\t[\n\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t{\n\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\tconst newHistory = searchHistory?.filter((h) => h.id !== id)\n\t\t\t\t\t\t\tsetSearchHistory(newHistory)\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t{ cancelable: true },\n\t\t\t)\n\t\t},\n\t\t[searchHistory, setSearchHistory],\n\t)\n\n\tuseEffect(() => {\n\t\tif (resolvedSharedPayloads.length === 0) return\n\t\tif (resolvedSharedPayloads.length > 1) {\n\t\t\tif (Platform.OS === 'android') {\n\t\t\t\tToastAndroid.show('收到多个共享内容，已忽略', ToastAndroid.SHORT)\n\t\t\t} else {\n\t\t\t\talert(\n\t\t\t\t\t'收到多个共享内容，已忽略',\n\t\t\t\t\t'当前版本仅支持处理单个共享内容，已忽略其他内容',\n\t\t\t\t\t[{ text: '确定' }],\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tconst data = resolvedSharedPayloads[0]\n\t\tlet query: string | undefined\n\t\tif (data.shareType === 'text') {\n\t\t\tquery = data.value\n\t\t}\n\t\tif (!query) {\n\t\t\tclearSharedPayloads()\n\t\t\treturn\n\t\t}\n\n\t\tclearSharedPayloads()\n\t\tvoid handleEnter(query)\n\t}, [resolvedSharedPayloads, clearSharedPayloads, handleEnter])\n\n\tif (isResolving) {\n\t\treturn (\n\t\t\t<View\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.container,\n\t\t\t\t\t{\n\t\t\t\t\t\tbackgroundColor: colors.background,\n\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t},\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<ActivityIndicator\n\t\t\t\t\tsize='large'\n\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t{/*顶部欢迎区域*/}\n\t\t\t<View\n\t\t\t\tstyle={{\n\t\t\t\t\tpaddingTop: insets.top + 8,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<View style={[styles.greetingContainer, { paddingHorizontal: 16 }]}>\n\t\t\t\t\t<View>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='headlineSmall'\n\t\t\t\t\t\t\tstyle={styles.headline}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tBBPlayer\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{greeting}，{personalInfo?.name || '陌生人'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t\t<View style={styles.headerRight}>\n\t\t\t\t\t\t{hasSyncFailures && (\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon='alert-circle'\n\t\t\t\t\t\t\t\tsize={22}\n\t\t\t\t\t\t\t\ticonColor={colors.error}\n\t\t\t\t\t\t\t\tonPress={() => void syncFailuresSheetRef.current?.present()}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\tenabled={hasBilibiliCookie()}\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\talert(\n\t\t\t\t\t\t\t\t\t'退出登录？',\n\t\t\t\t\t\t\t\t\t'是否退出登录？',\n\t\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\t\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\t\t\t\t\t\t\tclearBilibiliCookie()\n\t\t\t\t\t\t\t\t\t\t\t\tawait queryClient.cancelQueries()\n\t\t\t\t\t\t\t\t\t\t\t\tqueryClient.clear()\n\t\t\t\t\t\t\t\t\t\t\t\ttoast.success('Cookie\\u2009已清除')\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tstyle={styles.avatarButton}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\tstyle={styles.avatarImage}\n\t\t\t\t\t\t\t\tsource={\n\t\t\t\t\t\t\t\t\tpersonalInfo?.face\n\t\t\t\t\t\t\t\t\t\t? { uri: personalInfo.face }\n\t\t\t\t\t\t\t\t\t\t: // oxlint-disable-next-line @typescript-eslint/no-require-imports\n\t\t\t\t\t\t\t\t\t\t\trequire('../../../assets/images/bilibili-default-avatar.jpg')\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tcachePolicy={'disk'}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.searchSection}>\n\t\t\t\t\t{/* 搜索栏 */}\n\t\t\t\t\t<View style={styles.searchbarContainer}>\n\t\t\t\t\t\t<View ref={searchBarRef}>\n\t\t\t\t\t\t\t<Searchbar\n\t\t\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\t\t\t'关键词\\u2009/\\u2009b23.tv\\u2009/\\u2009完整网址\\u2009/\\u2009av\\u2009/\\u2009bv'\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tonChangeText={setSearchQuery}\n\t\t\t\t\t\t\t\tvalue={searchQuery}\n\t\t\t\t\t\t\t\ticon={isLoading ? 'loading' : 'magnify'}\n\t\t\t\t\t\t\t\tonClearIconPress={() => setSearchQuery('')}\n\t\t\t\t\t\t\t\tonSubmitEditing={() => handleEnter(searchQuery)}\n\t\t\t\t\t\t\t\tonFocus={() => setSearchFocused(true)}\n\t\t\t\t\t\t\t\tonBlur={() => setSearchFocused(false)}\n\t\t\t\t\t\t\t\televation={0}\n\t\t\t\t\t\t\t\tmode='bar'\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.searchbar,\n\t\t\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\ttestID='search-bar'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<SearchSuggestions\n\t\t\t\t\t\t\tquery={deferredSearchQuery}\n\t\t\t\t\t\t\tvisible={searchFocused || searchQuery.length > 0}\n\t\t\t\t\t\t\tonSuggestionPress={handleSuggestionPress}\n\t\t\t\t\t\t\tsearchBarRef={searchBarRef}\n\t\t\t\t\t\t\tsearchHistory={searchHistory}\n\t\t\t\t\t\t\tonClearHistory={handleClearHistory}\n\t\t\t\t\t\t\tonRemoveHistoryItem={handleRemoveHistoryItem}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\n\t\t\t\t{/* 快捷操作与内容区，加上 ScrollView 让它可滚动 */}\n\t\t\t\t<Animated.ScrollView\n\t\t\t\t\tcontentContainerStyle={styles.scrollContent}\n\t\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\t>\n\t\t\t\t\t<WeeklyHeatMap\n\t\t\t\t\t\tdata={heatmapData || {}}\n\t\t\t\t\t\tcellSize={18}\n\t\t\t\t\t\tcellGap={4}\n\t\t\t\t\t\tcellRadius={4}\n\t\t\t\t\t\tinitialScrollEnd={true}\n\t\t\t\t\t\tlocale='zh-cn'\n\t\t\t\t\t\tonCellPress={({ date }) => {\n\t\t\t\t\t\t\tconst dateStr = dayjs(date).format('YYYY-MM-DD')\n\t\t\t\t\t\t\trouter.push(`/history/${dateStr}`)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tscheme={theme.dark ? 'dark' : 'light'}\n\t\t\t\t\t\tcellColor={{\n\t\t\t\t\t\t\t1: Color(colors.primary).alpha(0.2).rgb().string(),\n\t\t\t\t\t\t\t2: Color(colors.primary).alpha(0.4).rgb().string(),\n\t\t\t\t\t\t\t3: Color(colors.primary).alpha(0.6).rgb().string(),\n\t\t\t\t\t\t\t4: colors.primary,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tcellDefaultColor={colors.surfaceVariant}\n\t\t\t\t\t\theaderTextColor={colors.onSurfaceVariant}\n\t\t\t\t\t\tsidebarTextColor={colors.onSurfaceVariant}\n\t\t\t\t\t\tscrollStyle={{ marginHorizontal: 16, marginBottom: 16 }}\n\t\t\t\t\t/>\n\t\t\t\t\t{/* 快捷入口 */}\n\t\t\t\t\t<View style={styles.quickAccessSection}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.sectionTitle}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t快捷入口\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<ScrollView\n\t\t\t\t\t\t\thorizontal\n\t\t\t\t\t\t\tshowsHorizontalScrollIndicator={false}\n\t\t\t\t\t\t\tsnapToInterval={156}\n\t\t\t\t\t\t\tsnapToAlignment='start'\n\t\t\t\t\t\t\tdecelerationRate='fast'\n\t\t\t\t\t\t\tcontentContainerStyle={styles.quickAccessScrollContent}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{/* 那月今日 */}\n\t\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t\tkey='on-this-day'\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.quickAccessCard,\n\t\t\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\tconst lastMonth = dayjs()\n\t\t\t\t\t\t\t\t\t\t.subtract(1, 'month')\n\t\t\t\t\t\t\t\t\t\t.format('YYYY-MM-DD')\n\t\t\t\t\t\t\t\t\trouter.push(`/history/${lastMonth}`)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\twidth: 48,\n\t\t\t\t\t\t\t\t\t\theight: 48,\n\t\t\t\t\t\t\t\t\t\tborderRadius: 24,\n\t\t\t\t\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\tsource='calendar-month'\n\t\t\t\t\t\t\t\t\t\tsize={32}\n\t\t\t\t\t\t\t\t\t\tcolor={colors.onSurfaceVariant}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\t\t\t\tstyle={styles.quickAccessText}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t那月今日\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</RectButton>\n\n\t\t\t\t\t\t\t{/* 最近常听 */}\n\t\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t\tkey='recently-played'\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.quickAccessCard,\n\t\t\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\tonPress={() => router.push('/playlist/recently')}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\twidth: 48,\n\t\t\t\t\t\t\t\t\t\theight: 48,\n\t\t\t\t\t\t\t\t\t\tborderRadius: 24,\n\t\t\t\t\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\tsource='history'\n\t\t\t\t\t\t\t\t\t\tsize={32}\n\t\t\t\t\t\t\t\t\t\tcolor={colors.onSurfaceVariant}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\t\t\t\tstyle={styles.quickAccessText}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t最近常听\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</RectButton>\n\n\t\t\t\t\t\t\t{/* 稍后再看 - conditional on Bilibili cookie */}\n\t\t\t\t\t\t\t{hasBilibiliCookie() && (\n\t\t\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t\t\tkey='watch-later'\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.quickAccessCard,\n\t\t\t\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\tonPress={() => router.push('/playlist/remote/toview')}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\twidth: 48,\n\t\t\t\t\t\t\t\t\t\t\theight: 48,\n\t\t\t\t\t\t\t\t\t\t\tborderRadius: 24,\n\t\t\t\t\t\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource='clock-outline'\n\t\t\t\t\t\t\t\t\t\t\tsize={32}\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.onSurfaceVariant}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\t\t\t\t\tstyle={styles.quickAccessText}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t稍后再看\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</ScrollView>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{/* 近期歌单 */}\n\t\t\t\t\t{recentPlaylists && recentPlaylists.length > 0 && (\n\t\t\t\t\t\t<View style={styles.recentPlaylistsSection}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\tstyle={styles.sectionTitle}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t近期歌单\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Animated.ScrollView\n\t\t\t\t\t\t\t\thorizontal\n\t\t\t\t\t\t\t\tshowsHorizontalScrollIndicator={false}\n\t\t\t\t\t\t\t\tsnapToInterval={156}\n\t\t\t\t\t\t\t\tsnapToAlignment='start'\n\t\t\t\t\t\t\t\tdecelerationRate='fast'\n\t\t\t\t\t\t\t\tcontentContainerStyle={styles.horizontalScrollContent}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{recentPlaylists.map((item) => (\n\t\t\t\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t\t\t\tkey={item.id}\n\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\tstyles.playlistCard,\n\t\t\t\t\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\t\t\trouter.push(`/playlist/local/${item.id}`)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\t\t\t\tsource={\n\t\t\t\t\t\t\t\t\t\t\t\titem.coverUrl\n\t\t\t\t\t\t\t\t\t\t\t\t\t? { uri: item.coverUrl }\n\t\t\t\t\t\t\t\t\t\t\t\t\t: require('../../../assets/images/bilibili-default-avatar.jpg')\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\tstyle={styles.playlistCover}\n\t\t\t\t\t\t\t\t\t\t\tcontentFit='cover'\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<View style={styles.playlistInfo}>\n\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\t\t\t\t\t\t\tnumberOfLines={2}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={styles.playlistTitle}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{item.title}\n\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{item.itemCount} 首\n\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</Animated.ScrollView>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t)}\n\t\t\t\t\t{/* 底部留白给播放条 */}\n\t\t\t\t\t<View style={{ height: 200 }} />\n\t\t\t\t</Animated.ScrollView>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t\t<SyncFailuresSheet ref={syncFailuresSheetRef} />\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tgreetingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\theadline: {\n\t\tfontWeight: 'bold',\n\t},\n\tavatarButton: {\n\t\tborderRadius: 20,\n\t\toverflow: 'hidden',\n\t},\n\theaderRight: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tavatarImage: {\n\t\twidth: 40,\n\t\theight: 40,\n\t\tborderRadius: 20,\n\t},\n\tsearchSection: {\n\t\tmarginTop: 16,\n\t},\n\tsearchbarContainer: {\n\t\tpaddingTop: 10,\n\t\tpaddingHorizontal: 16,\n\t\tpaddingBottom: 8,\n\t},\n\tsearchbar: {\n\t\tborderRadius: 9999,\n\t},\n\tscrollContent: {\n\t\tpaddingTop: 16,\n\t},\n\tsectionTitle: {\n\t\tpaddingHorizontal: 16,\n\t\tfontWeight: 'bold',\n\t\tmarginBottom: 16,\n\t},\n\tquickAccessSection: {\n\t\tmarginBottom: 32,\n\t},\n\tquickAccessCard: {\n\t\twidth: 140,\n\t\tborderRadius: 12,\n\t\toverflow: 'hidden',\n\t\tpaddingVertical: 16,\n\t\tpaddingHorizontal: 12,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tgap: 8,\n\t},\n\tquickAccessText: {\n\t\tfontWeight: '600',\n\t},\n\tquickAccessScrollContent: {\n\t\tpaddingHorizontal: 16,\n\t\tgap: 16,\n\t},\n\trecentPlaylistsSection: {\n\t\tmarginBottom: 32,\n\t},\n\thorizontalScrollContent: {\n\t\tpaddingHorizontal: 16,\n\t\tgap: 16,\n\t},\n\tplaylistCard: {\n\t\twidth: 140,\n\t\tborderRadius: 12,\n\t\toverflow: 'hidden',\n\t\tpaddingBottom: 12,\n\t},\n\tplaylistCover: {\n\t\twidth: '100%',\n\t\taspectRatio: 1,\n\t\tborderRadius: 12,\n\t},\n\tplaylistInfo: {\n\t\tpaddingHorizontal: 12,\n\t\tpaddingTop: 10,\n\t},\n\tplaylistTitle: {\n\t\tfontWeight: '600',\n\t\tmarginBottom: 4,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n\nexport default HomePage\n"
  },
  {
    "path": "apps/mobile/src/app/(tabs)/library/[tab].tsx",
    "content": "import Icon from '@react-native-vector-icons/material-design-icons'\nimport { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'\nimport { useState, useTransition } from 'react'\nimport { Dimensions, StyleSheet, View } from 'react-native'\nimport { Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\nimport { SceneMap, TabBar, TabView } from 'react-native-tab-view'\n\nimport IconButton from '@/components/common/IconButton'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport CollectionListComponent from '@/features/library/collection/CollectionList'\nimport FavoriteFolderListComponent from '@/features/library/favorite/FavoriteFolderList'\nimport LocalPlaylistListComponent from '@/features/library/local/LocalPlaylistList'\nimport MultiPageVideosListComponent from '@/features/library/multipage/MultiPageVideosList'\n\nconst renderScene = SceneMap({\n\tlocal: LocalPlaylistListComponent,\n\tfavorite: FavoriteFolderListComponent,\n\tcollection: CollectionListComponent,\n\tmultiPage: MultiPageVideosListComponent,\n})\n\nconst routes = [\n\t{ key: 'local', title: '播放列表' },\n\t{ key: 'favorite', title: '收藏夹' },\n\t{ key: 'collection', title: '合集' },\n\t{ key: 'multiPage', title: '分 p' },\n]\n\nexport enum Tabs {\n\tLocal = 0,\n\tFavorite = 1,\n\tCollection = 2,\n\tMultiPage = 3,\n}\n\nexport default function Library() {\n\tconst [index, setIndex] = useState(Tabs.Local)\n\tconst [_, startTransition] = useTransition()\n\tconst insets = useSafeAreaInsets()\n\tconst colors = useTheme().colors\n\tconst router = useRouter()\n\tconst { tab } = useLocalSearchParams<{ tab: string }>()\n\n\tuseFocusEffect(() => {\n\t\tif (tab === undefined) return\n\t\tconst numTab = Number(tab)\n\t\tif (isNaN(numTab)) return\n\t\tstartTransition(() => {\n\t\t\tsetIndex(numTab)\n\t\t})\n\t})\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<View\n\t\t\t\tstyle={{\n\t\t\t\t\tpaddingBottom: 0,\n\t\t\t\t\tflex: 1,\n\t\t\t\t\tpaddingTop: insets.top + 8,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<View style={styles.header}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='headlineSmall'\n\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t>\n\t\t\t\t\t\t音乐库\n\t\t\t\t\t</Text>\n\t\t\t\t\t<View style={styles.headerIcons}>\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon='download-box'\n\t\t\t\t\t\t\tonPress={() => router.push('/downloaded')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon='trophy'\n\t\t\t\t\t\t\tonPress={() => router.push('/history/overall')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t\t<TabView\n\t\t\t\t\tstyle={[styles.tabView, { backgroundColor: colors.background }]}\n\t\t\t\t\tnavigationState={{ index, routes }}\n\t\t\t\t\trenderScene={renderScene}\n\t\t\t\t\toverScrollMode={'never'}\n\t\t\t\t\trenderTabBar={(props) => (\n\t\t\t\t\t\t<TabBar\n\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\tstyle={[styles.tabBar, { backgroundColor: colors.background }]}\n\t\t\t\t\t\t\tindicatorStyle={{ backgroundColor: colors.onSecondaryContainer }}\n\t\t\t\t\t\t\tactiveColor={colors.onSecondaryContainer}\n\t\t\t\t\t\t\tinactiveColor={colors.onSurface}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\tonIndexChange={(i) => {\n\t\t\t\t\t\tstartTransition(() => {\n\t\t\t\t\t\t\tsetIndex(i)\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t\tinitialLayout={{ width: Dimensions.get('window').width, height: 0 }}\n\t\t\t\t\toptions={{\n\t\t\t\t\t\tfavorite: {\n\t\t\t\t\t\t\ticon: ({ focused }) => (\n\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\tname={\n\t\t\t\t\t\t\t\t\t\tfocused ? 'star-box-multiple' : 'star-box-multiple-outline'\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tcolor={\n\t\t\t\t\t\t\t\t\t\tfocused ? colors.onSecondaryContainer : colors.onSurface\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tcollection: {\n\t\t\t\t\t\t\ticon: ({ focused }) => (\n\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\tname={focused ? 'folder' : 'folder-outline'}\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tcolor={\n\t\t\t\t\t\t\t\t\t\tfocused ? colors.onSecondaryContainer : colors.onSurface\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tmultiPage: {\n\t\t\t\t\t\t\ticon: ({ focused }) => (\n\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\tname={focused ? 'folder-play' : 'folder-play-outline'}\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tcolor={\n\t\t\t\t\t\t\t\t\t\tfocused ? colors.onSecondaryContainer : colors.onSurface\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tlocal: {\n\t\t\t\t\t\t\ticon: ({ focused }) => (\n\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\tname={focused ? 'list-box' : 'list-box-outline'}\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tcolor={\n\t\t\t\t\t\t\t\t\t\tfocused ? colors.onSecondaryContainer : colors.onSurface\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\theader: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginHorizontal: 16,\n\t\tjustifyContent: 'space-between',\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t},\n\theaderIcons: {\n\t\tflexDirection: 'row',\n\t},\n\ttabView: {\n\t\tflex: 1,\n\t},\n\ttabBar: {\n\t\toverflow: 'hidden',\n\t\tjustifyContent: 'center',\n\t\tmaxHeight: 70,\n\t\tmarginBottom: 20,\n\t\tmarginTop: 20,\n\t\televation: 0,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/(tabs)/settings/index.tsx",
    "content": "import * as Application from 'expo-application'\nimport * as Clipboard from 'expo-clipboard'\nimport { useRouter } from 'expo-router'\nimport * as Updates from 'expo-updates'\nimport * as WebBrowser from 'expo-web-browser'\nimport { memo } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { Divider, List, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport toast from '@/utils/toast'\n\nconst updateTime = Updates.createdAt\n\t? `${Updates.createdAt.getFullYear()}-${Updates.createdAt.getMonth() + 1}-${Updates.createdAt.getDate()}`\n\t: ''\n\nexport default function SettingsPage() {\n\tconst insets = useSafeAreaInsets()\n\tconst haveTrack = useCurrentTrack()\n\tconst colors = useTheme().colors\n\tconst router = useRouter()\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<View\n\t\t\t\tstyle={{\n\t\t\t\t\tflex: 1,\n\t\t\t\t\tpaddingTop: insets.top + 8,\n\t\t\t\t\tpaddingBottom: haveTrack ? 70 : 0,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<View style={styles.header}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='headlineSmall'\n\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t>\n\t\t\t\t\t\t设置\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t\t<ScrollView\n\t\t\t\t\tstyle={styles.scrollView}\n\t\t\t\t\tcontentContainerStyle={styles.scrollContent}\n\t\t\t\t\tcontentInsetAdjustmentBehavior='automatic'\n\t\t\t\t>\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='外观'\n\t\t\t\t\t\tdescription='主题、播放器样式、歌词样式'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='palette'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() => router.push('/settings/appearance')}\n\t\t\t\t\t/>\n\t\t\t\t\t<Divider style={styles.divider} />\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='播放'\n\t\t\t\t\t\tdescription='播放行为、音效设置、弹幕'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='play-circle'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() => router.push('/settings/playback')}\n\t\t\t\t\t/>\n\t\t\t\t\t<Divider style={styles.divider} />\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='歌词'\n\t\t\t\t\t\tdescription='歌词源、桌面歌词、样式'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='text-box-outline'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() => router.push('/settings/lyrics')}\n\t\t\t\t\t/>\n\t\t\t\t\t<Divider style={styles.divider} />\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='下载'\n\t\t\t\t\t\tdescription='相关设置'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='download'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() => router.push('/settings/download')}\n\t\t\t\t\t/>\n\t\t\t\t\t<Divider style={styles.divider} />\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='通用'\n\t\t\t\t\t\tdescription='账号、更新、日志、调试'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='cog'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() => router.push('/settings/general')}\n\t\t\t\t\t\ttestID='setting-general'\n\t\t\t\t\t/>\n\t\t\t\t\t<Divider style={styles.divider} />\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='捐赠支持'\n\t\t\t\t\t\tdescription='请开发者喝杯咖啡'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='coffee'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() => router.push('/settings/donate')}\n\t\t\t\t\t/>\n\t\t\t\t</ScrollView>\n\t\t\t\t<Divider style={styles.sectionDivider} />\n\t\t\t\t<AboutSection />\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst AboutSection = memo(function AboutSection() {\n\treturn (\n\t\t<View style={styles.aboutSectionContainer}>\n\t\t\t<Text\n\t\t\t\tvariant='titleLarge'\n\t\t\t\tstyle={styles.aboutTitle}\n\t\t\t>\n\t\t\t\tBBPlayer\n\t\t\t</Text>\n\t\t\t<Text\n\t\t\t\tvariant='bodySmall'\n\t\t\t\tstyle={styles.aboutVersion}\n\t\t\t>\n\t\t\t\tv{Application.nativeApplicationVersion}:{Application.nativeBuildVersion}{' '}\n\t\t\t\t{Updates.updateId\n\t\t\t\t\t? `(hotfix-${Updates.updateId.slice(0, 7)}-${updateTime})`\n\t\t\t\t\t: ''}\n\t\t\t</Text>\n\n\t\t\t<Text\n\t\t\t\tvariant='bodyMedium'\n\t\t\t\tstyle={styles.aboutSubtitle}\n\t\t\t>\n\t\t\t\t又一个{'\\u2009Bilibili\\u2009'}音乐播放器\n\t\t\t</Text>\n\t\t\t<Text\n\t\t\t\tvariant='bodyMedium'\n\t\t\t\tstyle={styles.aboutWebsite}\n\t\t\t>\n\t\t\t\t官网：\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\tWebBrowser.openBrowserAsync('https://bbplayer.roitium.com').catch(\n\t\t\t\t\t\t\t(e) => {\n\t\t\t\t\t\t\t\tvoid Clipboard.setStringAsync('https://bbplayer.roitium.com')\n\t\t\t\t\t\t\t\ttoast.error('无法调用浏览器打开网页，已将链接复制到剪贴板', {\n\t\t\t\t\t\t\t\t\tdescription: String(e),\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tstyle={styles.aboutWebsiteLink}\n\t\t\t\t>\n\t\t\t\t\thttps://bbplayer.roitium.com\n\t\t\t\t</Text>\n\t\t\t</Text>\n\t\t</View>\n\t)\n})\n\nAboutSection.displayName = 'AboutSection'\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\theader: {\n\t\tpaddingHorizontal: 25,\n\t\tpaddingBottom: 20,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 16,\n\t},\n\tdivider: {\n\t\tmarginVertical: 4,\n\t\tbackgroundColor: 'transparent', // Spacer\n\t},\n\tsectionDivider: {\n\t\tmarginTop: 24,\n\t\tmarginBottom: 24,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n\taboutSectionContainer: {\n\t\tpaddingBottom: 15,\n\t},\n\taboutTitle: {\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 5,\n\t},\n\taboutVersion: {\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 5,\n\t},\n\taboutSubtitle: {\n\t\ttextAlign: 'center',\n\t},\n\taboutWebsite: {\n\t\ttextAlign: 'center',\n\t\tmarginTop: 8,\n\t},\n\taboutWebsiteLink: {\n\t\ttextDecorationLine: 'underline',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/+native-intent.ts",
    "content": "import log from '@/utils/log'\n\nexport function redirectSystemPath({\n\tpath,\n\tinitial,\n}: {\n\tpath: string\n\tinitial: boolean\n}) {\n\ttry {\n\t\t// 这里的 path 可能是一个完整的 URL，也可能是一个 path\n\t\tlet url: URL | null = null\n\t\ttry {\n\t\t\turl = new URL(path)\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\tif (url) {\n\t\t\tif (url.hostname === 'expo-sharing') {\n\t\t\t\treturn '/(tabs)'\n\t\t\t}\n\t\t\tif (url.hostname === 'notification.click') {\n\t\t\t\treturn '/player'\n\t\t\t}\n\t\t\tif (url.hostname === 'bbplayer.roitium.com') {\n\t\t\t\tconst result = url.href.split('/link-to/')[1]\n\t\t\t\tif (result) {\n\t\t\t\t\treturn result\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (url.hostname === 'app.bbplayer.roitium.com') {\n\t\t\t\tconst result = url.href.split('/link-to/')[1]\n\t\t\t\tif (result) {\n\t\t\t\t\treturn result\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (url.protocol === 'bbplayer:') {\n\t\t\t\treturn `/${url.hostname}${url.pathname}${url.search}`\n\t\t\t}\n\t\t}\n\t\treturn path\n\t} catch {\n\t\tlog.error('redirectSystemPath 失败', { path, initial })\n\t\treturn '/'\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/app/+not-found.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { Button, StyleSheet, Text, View } from 'react-native'\n\nconst NotFoundScreen: React.FC = () => {\n\tconst router = useRouter()\n\tconst handleGoHome = () => {\n\t\trouter.replace('/(tabs)')\n\t}\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<Text style={styles.title}>404</Text>\n\t\t\t<Text style={styles.message}>你正在找的页面不见了！</Text>\n\t\t\t<Button\n\t\t\t\ttitle='回到主页'\n\t\t\t\tonPress={handleGoHome}\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 20,\n\t\tbackgroundColor: '#f5f5f5', // A light grey background\n\t},\n\ttitle: {\n\t\tfontSize: 24,\n\t\tfontWeight: 'bold',\n\t\tcolor: '#333', // Darker text for title\n\t\tmarginBottom: 8,\n\t},\n\tmessage: {\n\t\tfontSize: 16,\n\t\tcolor: '#666', // Slightly lighter text for message\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 20,\n\t},\n})\n\nexport default NotFoundScreen\n"
  },
  {
    "path": "apps/mobile/src/app/_layout.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport {\n\tfetch as fetchNetInfo,\n\taddEventListener as addNetInfoEventListener,\n} from '@react-native-community/netinfo'\nimport { useLogger } from '@react-navigation/devtools'\nimport * as Sentry from '@sentry/react-native'\nimport { focusManager, onlineManager } from '@tanstack/react-query'\nimport * as Application from 'expo-application'\nimport { Stack, useNavigationContainerRef, SplashScreen } from 'expo-router'\nimport * as Updates from 'expo-updates'\nimport { useEffect, useState } from 'react'\nimport type { AppStateStatus } from 'react-native'\nimport { AppState, Platform, StyleSheet, View } from 'react-native'\nimport { Text } from 'react-native-paper'\nimport { Toaster } from 'sonner-native'\n\nimport { alert } from '@/components/modals/AlertModal'\nimport AppProviders from '@/components/providers'\nimport { useFeatureTracking } from '@/hooks/analytics/useFeatureTracking'\nimport useCheckUpdate from '@/hooks/app/useCheckUpdate'\nimport { useFastMigrations } from '@/hooks/app/useFastMigrations'\nimport useAppStore, { serializeCookieObject } from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { usePlayerStore } from '@/hooks/stores/usePlayerStore'\nimport { initializeSentry } from '@/lib/config/sentry'\nimport drizzleDb from '@/lib/db/db'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport lyricService from '@/lib/services/lyricService'\nimport { playlistSyncWorker } from '@/lib/workers/PlaylistSyncWorker'\nimport { ProjectScope } from '@/types/core/scope'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport log, { cleanOldLogFiles, reportErrorToSentry } from '@/utils/log'\nimport { storage } from '@/utils/mmkv'\nimport { isActuallyOffline } from '@/utils/network'\nimport toast from '@/utils/toast'\n\nimport migrations from '../../drizzle/migrations'\n\nconst logger = log.extend('UI.RootLayout')\n\n// 在获取资源时保持启动画面可见\nvoid SplashScreen.preventAutoHideAsync()\n\n// 初始化 Sentry\ninitializeSentry()\n\nconst developement = process.env.NODE_ENV === 'development'\n\nfunction onAppStateChange(status: AppStateStatus) {\n\tif (Platform.OS !== 'web') {\n\t\tfocusManager.setFocused(status === 'active')\n\t}\n}\n\nexport default Sentry.wrap(function RootLayout() {\n\tconst [isReady, setIsReady] = useState(false)\n\tconst { success: migrationsSuccess, error: migrationsError } =\n\t\tuseFastMigrations(drizzleDb, migrations)\n\tconst open = useModalStore((state) => state.open)\n\tconst ref = useNavigationContainerRef()\n\tuseCheckUpdate()\n\tuseFeatureTracking()\n\n\tuseLogger(ref)\n\n\tonlineManager.setEventListener((setOnline) => {\n\t\tvoid fetchNetInfo().then((state) => {\n\t\t\tsetOnline(!isActuallyOffline(state))\n\t\t})\n\n\t\tconst unsubscribe = addNetInfoEventListener((state) => {\n\t\t\tsetOnline(!isActuallyOffline(state))\n\t\t})\n\t\treturn unsubscribe\n\t})\n\n\tuseEffect(() => {\n\t\tconst logAppInfo = async () => {\n\t\t\tif (\n\t\t\t\tApplication.nativeApplicationVersion &&\n\t\t\t\tApplication.nativeBuildVersion\n\t\t\t) {\n\t\t\t\tawait analyticsService.logAppInfo(\n\t\t\t\t\tApplication.nativeApplicationVersion,\n\t\t\t\t\tApplication.nativeBuildVersion,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tvoid logAppInfo()\n\n\t\tconst subscription = AppState.addEventListener('change', onAppStateChange)\n\t\treturn () => subscription.remove()\n\t}, [])\n\n\tuseEffect(() => {\n\t\ttry {\n\t\t\tuseAppStore.getState()\n\n\t\t\t// 清理旧日志\n\t\t\tcleanOldLogFiles(7)\n\t\t\t\t.andTee((deleted) => {\n\t\t\t\t\tif (deleted > 0) {\n\t\t\t\t\t\tlogger.info(`已清理 ${deleted} 个旧日志文件`)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.orTee((e) => {\n\t\t\t\t\tlogger.warning('清理旧日志失败', { error: e.message })\n\t\t\t\t})\n\n\t\t\t// 迁移旧格式歌词\n\t\t\tvoid lyricService.migrateFromOldFormat()\n\n\t\t\t// 初始化播放器状态\n\t\t\tusePlayerStore.getState().initialize()\n\n\t\t\t// 桌面歌词权限启动检查\n\t\t\tconst checkOverlayPermissionOnStart = async () => {\n\t\t\t\tif (Orpheus.isDesktopLyricsShown) {\n\t\t\t\t\tconst hasPermission = await Orpheus.checkOverlayPermission()\n\t\t\t\t\tif (!hasPermission) {\n\t\t\t\t\t\t// 延迟显示，确保 UI 已经加载\n\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\talert(\n\t\t\t\t\t\t\t\t'桌面歌词',\n\t\t\t\t\t\t\t\t'检测到桌面歌词已开启，但缺少悬浮窗权限，请授权以恢复显示。',\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\ttext: '去授权',\n\t\t\t\t\t\t\t\t\t\tonPress: () => Orpheus.requestOverlayPermission(),\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}, 1000)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tvoid checkOverlayPermissionOnStart()\n\n\t\t\t// 初始化播放器 Cookie\n\t\t\ttry {\n\t\t\t\tconst settings = useAppStore.getState().settings\n\t\t\t\tvoid Orpheus.setDownloadMaxParallelTasks(\n\t\t\t\t\tsettings.downloadMaxParallelTasks,\n\t\t\t\t)\n\n\t\t\t\tconst cookie = useAppStore.getState().bilibiliCookie\n\t\t\t\tif (cookie) {\n\t\t\t\t\tlogger.debug('初始化 orpheus bilibili cookie')\n\t\t\t\t\tOrpheus.setBilibiliCookie(serializeCookieObject(cookie))\n\t\t\t\t} else {\n\t\t\t\t\tlogger.info('没有 bilibili cookie，跳过播放器初始化')\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('播放器初始化失败: ', error)\n\t\t\t\treportErrorToSentry(error, '播放器初始化失败', ProjectScope.Player)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.error('初始化失败:', error)\n\t\t\treportErrorToSentry(error, '初始化失败', ProjectScope.UI)\n\t\t} finally {\n\t\t\t// oxlint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state\n\t\t\tsetIsReady(true)\n\t\t}\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (isReady && migrationsSuccess) {\n\t\t\tSplashScreen.hide()\n\n\t\t\t// 恢复上次被中断的同步任务（syncing → pending），并触发同步\n\t\t\tplaylistSyncWorker.recoverStuckRows().catch((error) => {\n\t\t\t\tlogger.error('恢复同步任务失败:', error)\n\t\t\t})\n\n\t\t\tconst firstOpen = storage.getBoolean('first_open') ?? true\n\t\t\tif (firstOpen) {\n\t\t\t\topen('Welcome', undefined, { dismissible: false })\n\t\t\t}\n\t\t}\n\t}, [isReady, migrationsSuccess, open])\n\n\tuseEffect(() => {\n\t\tif (migrationsError) {\n\t\t\tSplashScreen.hide()\n\t\t\tlogger.error('数据库迁移失败：', migrationsError)\n\t\t}\n\t}, [migrationsError])\n\n\tuseEffect(() => {\n\t\tif (developement) {\n\t\t\treturn\n\t\t}\n\t\tUpdates.checkForUpdateAsync()\n\t\t\t.then((result) => {\n\t\t\t\tif (result.isAvailable) {\n\t\t\t\t\ttoast.show('有新的热更新，将在下次启动时应用', {\n\t\t\t\t\t\tid: 'update',\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch((error: Error) => {\n\t\t\t\ttoastAndLogError('检测更新失败', error, 'UI.RootLayout')\n\t\t\t})\n\t}, [])\n\n\tif (migrationsError) {\n\t\treturn (\n\t\t\t<View style={styles.errorContainer}>\n\t\t\t\t<Text>数据库迁移失败: {migrationsError?.message}</Text>\n\t\t\t\t<Text>建议截图报错信息，发到项目 issues 反馈</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\tif (!migrationsSuccess || !isReady) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<AppProviders>\n\t\t\t<Stack screenOptions={{ headerShown: false }}>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='(tabs)'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='player'\n\t\t\t\t\toptions={{\n\t\t\t\t\t\tanimation: 'slide_from_bottom',\n\t\t\t\t\t\theaderShown: false,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='test'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/search-result/global/[query]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/collection/[id]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/favorite/[id]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/multipage/[bvid]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/uploader/[mid]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/search-result/fav/[query]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/local/[id]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='share/playlist'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='history/overall'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='history/[date]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='download'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='+not-found'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='modal'\n\t\t\t\t\toptions={{\n\t\t\t\t\t\tpresentation: 'transparentModal',\n\t\t\t\t\t\tgestureEnabled: false,\n\t\t\t\t\t\tanimation: 'fade',\n\t\t\t\t\t\theaderShown: false,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/remote/toview'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='comments/[bvid]'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='comments/reply'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='playlist/external-sync'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='settings/appearance'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='settings/playback'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='settings/lyrics'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='settings/download'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='settings/general'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t\t<Stack.Screen\n\t\t\t\t\tname='settings/donate'\n\t\t\t\t\toptions={{ headerShown: false }}\n\t\t\t\t/>\n\t\t\t</Stack>\n\t\t\t<Toaster />\n\t\t</AppProviders>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\terrorContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/comments/[bvid].tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useCallback, useMemo } from 'react'\nimport { ActivityIndicator, StyleSheet, View } from 'react-native'\nimport { Appbar, Divider, Text, useTheme } from 'react-native-paper'\n\nimport { CommentItem } from '@/features/comments/components/CommentItem'\nimport { useComments } from '@/hooks/queries/bilibili/comments'\nimport type { BilibiliCommentItem } from '@/types/apis/bilibili'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\n\nconst renderItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tBilibiliCommentItem,\n\t{ bvid: string; onReplyPress: (item: BilibiliCommentItem) => void }\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst { bvid, onReplyPress } = extraData\n\treturn (\n\t\t<CommentItem\n\t\t\titem={item}\n\t\t\tbvid={bvid}\n\t\t\tonReplyPress={onReplyPress}\n\t\t/>\n\t)\n}\n\nexport default function CommentsPage() {\n\tconst { bvid } = useLocalSearchParams<{ bvid: string }>()\n\tconst theme = useTheme()\n\tconst router = useRouter()\n\tconst {\n\t\tdata,\n\t\tfetchNextPage,\n\t\thasNextPage,\n\t\tisFetchingNextPage,\n\t\tisLoading,\n\t\trefetch,\n\t\tisRefetching,\n\t} = useComments(bvid)\n\n\tconst comments = data?.pages.flatMap((page) => page.replies ?? []) ?? []\n\n\tconst onReplyPress = useCallback(\n\t\t(item: BilibiliCommentItem) => {\n\t\t\trouter.push({\n\t\t\t\tpathname: '/comments/reply',\n\t\t\t\tparams: { bvid: bvid, rpid: item.rpid },\n\t\t\t})\n\t\t},\n\t\t[bvid, router],\n\t)\n\n\tconst extraData = useMemo(\n\t\t() => ({ bvid, onReplyPress }),\n\t\t[bvid, onReplyPress],\n\t)\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliCommentItem) => item.rpid.toString(),\n\t\t[],\n\t)\n\n\tconst ItemSeparatorComponent = useCallback(() => <Divider />, [])\n\n\tif (!bvid) {\n\t\treturn (\n\t\t\t<View style={styles.center}>\n\t\t\t\t<Text>参数错误</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[styles.container, { backgroundColor: theme.colors.background }]}\n\t\t>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.Content title={'评论区'} />\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t</Appbar.Header>\n\t\t\t{isLoading ? (\n\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\tsize='large'\n\t\t\t\t\t\tcolor={theme.colors.primary}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t) : (\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={comments}\n\t\t\t\t\textraData={extraData}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\tonEndReached={() => {\n\t\t\t\t\t\tif (hasNextPage) void fetchNextPage()\n\t\t\t\t\t}}\n\t\t\t\t\tonEndReachedThreshold={0.5}\n\t\t\t\t\tListFooterComponent={() =>\n\t\t\t\t\t\tisFetchingNextPage ? (\n\t\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\t\tstyle={styles.footer}\n\t\t\t\t\t\t\t\tcolor={theme.colors.primary}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : null\n\t\t\t\t\t}\n\t\t\t\t\trefreshing={isRefetching}\n\t\t\t\t\tonRefresh={refetch}\n\t\t\t\t\tcontentContainerStyle={{ paddingBottom: 20 }}\n\t\t\t\t\tItemSeparatorComponent={ItemSeparatorComponent}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tcenter: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tfooter: {\n\t\tpadding: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/comments/reply.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useCallback, useMemo } from 'react'\nimport { ActivityIndicator, StyleSheet, View } from 'react-native'\nimport { Appbar, Divider, Text, useTheme } from 'react-native-paper'\n\nimport { CommentItem } from '@/features/comments/components/CommentItem'\nimport { useReplyComments } from '@/hooks/queries/bilibili/comments'\nimport type { BilibiliCommentItem } from '@/types/apis/bilibili'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\n\nconst renderItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<BilibiliCommentItem, { bvid: string }>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst { bvid } = extraData\n\treturn (\n\t\t<CommentItem\n\t\t\titem={item}\n\t\t\tbvid={bvid}\n\t\t/>\n\t)\n}\n\nexport default function ReplyCommentsPage() {\n\tconst { bvid, rpid } = useLocalSearchParams<{ bvid: string; rpid: string }>()\n\tconst theme = useTheme()\n\tconst {\n\t\tdata,\n\t\tfetchNextPage,\n\t\thasNextPage,\n\t\tisFetchingNextPage,\n\t\tisLoading,\n\t\trefetch,\n\t\tisRefetching,\n\t} = useReplyComments(bvid, Number(rpid))\n\tconst router = useRouter()\n\n\tconst replies = data?.pages.flatMap((page) => page.replies ?? []) ?? []\n\tconst rootComment = data?.pages[0]?.root\n\n\tconst extraData = useMemo(() => ({ bvid }), [bvid])\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliCommentItem) => item.rpid.toString(),\n\t\t[],\n\t)\n\n\tconst divider = useCallback(() => <Divider />, [])\n\n\tif (!bvid || !rpid) {\n\t\treturn (\n\t\t\t<View style={styles.center}>\n\t\t\t\t<Text>参数错误</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\tconst rpidNumber = Number(rpid)\n\tif (isNaN(rpidNumber)) {\n\t\treturn (\n\t\t\t<View style={styles.center}>\n\t\t\t\t<Text>无效的评论ID</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[styles.container, { backgroundColor: theme.colors.background }]}\n\t\t>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.Content title={'评论区'} />\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t</Appbar.Header>\n\t\t\t{isLoading ? (\n\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\tsize='large'\n\t\t\t\t\t\tcolor={theme.colors.primary}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t) : (\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={replies}\n\t\t\t\t\textraData={extraData}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\tListHeaderComponent={() =>\n\t\t\t\t\t\trootComment ? (\n\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\t\t\t\t\tborderBottomColor: theme.colors.outlineVariant,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<CommentItem\n\t\t\t\t\t\t\t\t\titem={rootComment}\n\t\t\t\t\t\t\t\t\tbvid={bvid}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : null\n\t\t\t\t\t}\n\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\tonEndReached={() => {\n\t\t\t\t\t\tif (hasNextPage) void fetchNextPage()\n\t\t\t\t\t}}\n\t\t\t\t\tonEndReachedThreshold={0.5}\n\t\t\t\t\tListFooterComponent={() =>\n\t\t\t\t\t\tisFetchingNextPage ? (\n\t\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\t\tstyle={styles.footer}\n\t\t\t\t\t\t\t\tcolor={theme.colors.primary}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : null\n\t\t\t\t\t}\n\t\t\t\t\tItemSeparatorComponent={divider}\n\t\t\t\t\trefreshing={isRefetching}\n\t\t\t\t\tonRefresh={refetch}\n\t\t\t\t\tcontentContainerStyle={{ paddingBottom: 20 }}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tcenter: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tfooter: {\n\t\tpadding: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/download.tsx",
    "content": "import { DownloadState, Orpheus, type DownloadTask } from '@bbplayer/orpheus'\nimport { FlashList } from '@shopify/flash-list'\nimport { useRouter } from 'expo-router'\nimport { useCallback } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { ActivityIndicator, Appbar, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport DownloadHeader from '@/features/downloads/DownloadHeader'\nimport DownloadTaskItem from '@/features/downloads/DownloadTaskItem'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { orpheusQueryKeys, useDownloadTasks } from '@/hooks/queries/orpheus'\nimport { queryClient } from '@/lib/config/queryClient'\n\nconst canRetryDownloadTask = (task: DownloadTask) =>\n\t!!task.track &&\n\t(task.state === DownloadState.FAILED || task.state === DownloadState.STOPPED)\n\nconst renderItem = ({ item }: { item: DownloadTask }) => {\n\treturn <DownloadTaskItem initTask={item} />\n}\n\nexport default function DownloadPage() {\n\tconst { colors } = useTheme()\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\n\tconst { data: tasks, isPending, isError, error } = useDownloadTasks()\n\n\tconst haveTrack = useCurrentTrack()\n\n\tconst header = (\n\t\t<Appbar.Header elevated>\n\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t<Appbar.Content title='下载任务' />\n\t\t</Appbar.Header>\n\t)\n\n\tconst keyExtractor = useCallback((item: DownloadTask) => item.id, [])\n\n\tif (isPending) {\n\t\treturn (\n\t\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t\t{header}\n\t\t\t\t<ActivityIndicator\n\t\t\t\t\tsize='large'\n\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t)\n\t}\n\n\tif (isError) {\n\t\treturn (\n\t\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t\t{header}\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\tstyle={{ color: colors.error, padding: 16 }}\n\t\t\t\t>\n\t\t\t\t\t加载下载任务失败: {error.message}\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t{header}\n\n\t\t\t<DownloadHeader\n\t\t\t\ttaskCount={tasks.length}\n\t\t\t\tretryableCount={tasks.filter(canRetryDownloadTask).length}\n\t\t\t\tonRetryAll={async () => {\n\t\t\t\t\tawait Promise.all(\n\t\t\t\t\t\ttasks.filter(canRetryDownloadTask).map((task) => {\n\t\t\t\t\t\t\tif (task.state === DownloadState.STOPPED) {\n\t\t\t\t\t\t\t\treturn Orpheus.resumeDownload(task.id)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn task.track ? Orpheus.retryDownload(task.track) : undefined\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: orpheusQueryKeys.downloadTasks(),\n\t\t\t\t\t})\n\t\t\t\t}}\n\t\t\t\tonClearAll={async () => {\n\t\t\t\t\tawait Orpheus.clearUncompletedDownloadTasks()\n\t\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: orpheusQueryKeys.downloadTasks(),\n\t\t\t\t\t})\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={tasks}\n\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\t\tpaddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/downloaded.tsx",
    "content": "import { DownloadState, Orpheus, type DownloadTask } from '@bbplayer/orpheus'\nimport type { TrueSheet as TrueSheetType } from '@lodev09/react-native-true-sheet'\nimport { FlashList } from '@shopify/flash-list'\nimport { useRouter } from 'expo-router'\nimport {\n\ttype ComponentRef,\n\tuseCallback,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from 'react'\nimport {\n\tStyleSheet,\n\tToastAndroid,\n\tView,\n\tPlatform,\n\tAlert,\n\tPermissionsAndroid,\n} from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport {\n\tActivityIndicator,\n\tAppbar,\n\tCheckbox,\n\tDivider,\n\tMenu,\n\tSearchbar,\n\tSurface,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport IconButton from '@/components/common/IconButton'\nimport { alert } from '@/components/modals/AlertModal'\nimport ExportDownloadsProgressModal from '@/components/modals/settings/ExportDownloadsProgressModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { useTrackSelection } from '@/features/playlist/local/hooks/useTrackSelection'\nimport { useRemoveDownloadsMutation } from '@/hooks/mutations/orpheus'\nimport { useAllDownloads, orpheusQueryKeys } from '@/hooks/queries/orpheus'\nimport { queryClient } from '@/lib/config/queryClient'\nimport {\n\tLIST_ITEM_COVER_SIZE,\n\tLIST_ITEM_BORDER_RADIUS,\n} from '@/theme/dimensions'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport * as Haptics from '@/utils/haptics'\nimport { formatDurationToHHMMSS } from '@/utils/time'\nimport toast from '@/utils/toast'\n\nconst PUBLIC_MUSIC_EXPORT_URI = 'orpheus://public-music'\n\ninterface DownloadedItemExtraData {\n\tselectMode: boolean\n\tselected: Set<string>\n\ttoggleSelected: (id: string) => void\n\tenterSelectMode: (id: string) => void\n\tonItemPress: (item: DownloadTask) => void\n\tonMenuPress: (id: string, anchor: { x: number; y: number }) => void\n}\n\nfunction renderDownloadedItem({\n\titem,\n\tindex,\n\textraData,\n}: {\n\titem: DownloadTask\n\tindex: number\n\textraData?: DownloadedItemExtraData\n}) {\n\treturn (\n\t\t<DownloadedItem\n\t\t\titem={item}\n\t\t\tindex={index}\n\t\t\tselectMode={extraData?.selectMode ?? false}\n\t\t\tisSelected={extraData?.selected.has(item.id) ?? false}\n\t\t\ttoggleSelected={extraData?.toggleSelected ?? (() => {})}\n\t\t\tenterSelectMode={extraData?.enterSelectMode ?? (() => {})}\n\t\t\tonItemPress={extraData?.onItemPress ?? (() => {})}\n\t\t\tonMenuPress={extraData?.onMenuPress ?? (() => {})}\n\t\t/>\n\t)\n}\n\nfunction DownloadedItem({\n\titem,\n\tindex,\n\tselectMode,\n\tisSelected,\n\ttoggleSelected,\n\tenterSelectMode,\n\tonItemPress,\n\tonMenuPress,\n}: {\n\titem: DownloadTask\n\tindex: number\n\tselectMode: boolean\n\tisSelected: boolean\n\ttoggleSelected: (id: string) => void\n\tenterSelectMode: (id: string) => void\n\tonItemPress: (item: DownloadTask) => void\n\tonMenuPress: (id: string, anchor: { x: number; y: number }) => void\n}) {\n\tconst theme = useTheme()\n\tconst track = item.track\n\tconst menuButtonRef = useRef<ComponentRef<typeof IconButton>>(null)\n\n\treturn (\n\t\t<RectButton\n\t\t\tstyle={[\n\t\t\t\tstyles.rectButton,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: isSelected\n\t\t\t\t\t\t? theme.dark\n\t\t\t\t\t\t\t? 'rgba(255, 255, 255, 0.12)'\n\t\t\t\t\t\t\t: 'rgba(0, 0, 0, 0.12)'\n\t\t\t\t\t\t: 'transparent',\n\t\t\t\t},\n\t\t\t]}\n\t\t\tonPress={() => {\n\t\t\t\tif (selectMode) {\n\t\t\t\t\ttoggleSelected(item.id)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tonItemPress(item)\n\t\t\t}}\n\t\t\tonLongPress={() => {\n\t\t\t\tif (!selectMode) {\n\t\t\t\t\tenterSelectMode(item.id)\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<Surface\n\t\t\t\tstyle={styles.surface}\n\t\t\t\televation={0}\n\t\t\t>\n\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t<View style={styles.indexContainer}>\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.checkboxContainer,\n\t\t\t\t\t\t\t\t{ opacity: selectMode ? 1 : 0 },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Checkbox status={isSelected ? 'checked' : 'unchecked'} />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={{ opacity: selectMode ? 0 : 1 }}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\tstyle={{ color: theme.colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\tid={item.id}\n\t\t\t\t\t\tcover={track?.artwork}\n\t\t\t\t\t\ttitle={track?.title ?? '未知曲目'}\n\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t/>\n\n\t\t\t\t\t<View style={styles.titleContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{track?.title ?? '未知曲目'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.detailsContainer}>\n\t\t\t\t\t\t\t{track?.artist && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{track.artist}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tstyle={styles.dotSeparator}\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t{track?.duration ? formatDurationToHHMMSS(track.duration) : ''}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{!selectMode && (\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\tref={menuButtonRef}\n\t\t\t\t\t\t\ticon='dots-vertical'\n\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\ticonColor={theme.colors.onSurfaceVariant}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t;(menuButtonRef.current as unknown as View)?.measure(\n\t\t\t\t\t\t\t\t\t(\n\t\t\t\t\t\t\t\t\t\t_x: number,\n\t\t\t\t\t\t\t\t\t\t_y: number,\n\t\t\t\t\t\t\t\t\t\t_w: number,\n\t\t\t\t\t\t\t\t\t\t_h: number,\n\t\t\t\t\t\t\t\t\t\tpageX: number,\n\t\t\t\t\t\t\t\t\t\tpageY: number,\n\t\t\t\t\t\t\t\t\t) => {\n\t\t\t\t\t\t\t\t\t\tonMenuPress(item.id, { x: pageX, y: pageY })\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</Surface>\n\t\t</RectButton>\n\t)\n}\n\nexport default function DownloadedPage() {\n\tconst { colors } = useTheme()\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\n\tconst exportSheetRef = useRef<TrueSheetType>(null)\n\tconst [exportConfig, setExportConfig] = useState<{\n\t\tids: string[]\n\t\tdestinationUri: string\n\t} | null>(null)\n\n\tconst { data: allTasks, isPending } = useAllDownloads()\n\tconst completedTasks = (allTasks ?? []).filter(\n\t\t(t) => t.state === DownloadState.COMPLETED,\n\t)\n\n\tconst [searchQuery, setSearchQuery] = useState('')\n\tconst [isSearching, setIsSearching] = useState(false)\n\n\tconst filteredTasks = (() => {\n\t\tif (!searchQuery.trim()) return completedTasks\n\t\tconst query = searchQuery.toLowerCase()\n\t\treturn completedTasks.filter((t) => {\n\t\t\tconst track = t.track\n\t\t\treturn (\n\t\t\t\ttrack?.title?.toLowerCase().includes(query) ||\n\t\t\t\ttrack?.artist?.toLowerCase().includes(query)\n\t\t\t)\n\t\t})\n\t})()\n\n\tconst {\n\t\tselected,\n\t\tselectMode,\n\t\ttoggle,\n\t\tenterSelectMode,\n\t\texitSelectMode,\n\t\tsetSelected,\n\t} = useTrackSelection<string>()\n\n\tconst removeDownloadsMutation = useRemoveDownloadsMutation()\n\n\tconst [menuState, setMenuState] = useState<{\n\t\tvisible: boolean\n\t\tid: string | null\n\t\tanchor: { x: number; y: number }\n\t}>({ visible: false, id: null, anchor: { x: 0, y: 0 } })\n\tconst currentMenuTask = completedTasks.find(\n\t\t(task) => task.id === menuState.id,\n\t)\n\n\tconst handleItemMenuPress = useCallback(\n\t\t(id: string, anchor: { x: number; y: number }) => {\n\t\t\tsetMenuState({ visible: true, id, anchor })\n\t\t},\n\t\t[],\n\t)\n\n\tconst dismissItemMenu = useCallback(() => {\n\t\tsetMenuState((prev) => ({ ...prev, visible: false }))\n\t}, [])\n\n\tconst handlePlayItem = useCallback((item: DownloadTask) => {\n\t\tif (!item.track) {\n\t\t\ttoast.error('当前下载项缺少歌曲信息，无法播放')\n\t\t\treturn\n\t\t}\n\n\t\tvoid Orpheus.addToEnd([item.track], item.track.id, false)\n\t}, [])\n\n\tconst resolveExportDestination = useCallback(async () => {\n\t\tconst hasDirectoryPicker = await Orpheus.isDirectoryPickerAvailable()\n\t\tif (!hasDirectoryPicker) {\n\t\t\t// For API < 29, we need WRITE_EXTERNAL_STORAGE permission for public music export\n\t\t\tif (Platform.OS === 'android' && Platform.Version < 29) {\n\t\t\t\tconst permissionResult = await PermissionsAndroid.request(\n\t\t\t\t\tPermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,\n\t\t\t\t\t{\n\t\t\t\t\t\ttitle: '存储权限',\n\t\t\t\t\t\tmessage: '导出歌曲到公共音乐目录需要存储权限',\n\t\t\t\t\t\tbuttonPositive: '确定',\n\t\t\t\t\t\tbuttonNegative: '取消',\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\tif (permissionResult !== PermissionsAndroid.RESULTS.GRANTED) {\n\t\t\t\t\ttoast.error('需要存储权限才能导出到公共音乐目录')\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t}\n\t\t\ttoast.info('系统不支持目录选择，回退到 Music/BBPlayer')\n\t\t\treturn PUBLIC_MUSIC_EXPORT_URI\n\t\t}\n\n\t\tToastAndroid.showWithGravity(\n\t\t\t'请选择需要导出到的目录',\n\t\t\tToastAndroid.SHORT,\n\t\t\tToastAndroid.BOTTOM,\n\t\t)\n\t\treturn await Orpheus.selectDirectory()\n\t}, [])\n\n\tconst handleSingleExport = useCallback(async () => {\n\t\tdismissItemMenu()\n\t\tconst id = menuState.id\n\t\tif (!id) return\n\n\t\tif (Platform.OS !== 'android') {\n\t\t\tAlert.alert('提示', '音频导出功能目前仅支持 Android 系统')\n\t\t\treturn\n\t\t}\n\n\t\tconst directoryUri = await resolveExportDestination()\n\t\tif (directoryUri) {\n\t\t\tsetExportConfig({ ids: [id], destinationUri: directoryUri })\n\t\t\tvoid exportSheetRef.current?.present()\n\t\t}\n\t}, [dismissItemMenu, menuState.id, resolveExportDestination])\n\n\tconst handleDelete = useCallback(() => {\n\t\tdismissItemMenu()\n\t\tconst id = menuState.id\n\t\tif (!id) return\n\t\tconst task = completedTasks.find((t) => t.id === id)\n\t\tconst title = task?.track?.title ?? id\n\t\talert(\n\t\t\t'删除下载',\n\t\t\t`确定要删除「${title}」的下载记录及缓存文件吗？`,\n\t\t\t[\n\t\t\t\t{ text: '取消' },\n\t\t\t\t{\n\t\t\t\t\ttext: '删除',\n\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait Orpheus.removeDownload(id)\n\t\t\t\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\t\t\t\tqueryKey: [...orpheusQueryKeys.all, 'allDownloads'],\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\ttoastAndLogError('删除下载失败', e, 'Downloaded.Page')\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t{ cancelable: true },\n\t\t)\n\t}, [dismissItemMenu, menuState.id, completedTasks])\n\n\tconst handleExport = async () => {\n\t\tconst idsToExport =\n\t\t\tselected.size > 0 ? Array.from(selected) : completedTasks.map((t) => t.id)\n\n\t\tif (idsToExport.length === 0) {\n\t\t\tif (Platform.OS === 'android') {\n\t\t\t\tToastAndroid.showWithGravity(\n\t\t\t\t\t'没有可导出的歌曲',\n\t\t\t\t\tToastAndroid.SHORT,\n\t\t\t\t\tToastAndroid.BOTTOM,\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tAlert.alert('提示', '没有可导出的歌曲')\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tif (Platform.OS !== 'android') {\n\t\t\tAlert.alert('提示', '音频导出功能目前仅支持 Android 系统')\n\t\t\treturn\n\t\t}\n\n\t\tconst directoryUri = await resolveExportDestination()\n\t\tif (directoryUri) {\n\t\t\tsetExportConfig({ ids: idsToExport, destinationUri: directoryUri })\n\t\t\tvoid exportSheetRef.current?.present()\n\t\t\tif (selectMode) {\n\t\t\t\texitSelectMode()\n\t\t\t}\n\t\t}\n\t}\n\n\tconst handleBatchDelete = useCallback(() => {\n\t\tconst idsToDelete = Array.from(selected)\n\t\tif (idsToDelete.length === 0) return\n\t\talert(\n\t\t\t'批量删除',\n\t\t\t`确定要删除选中的 ${idsToDelete.length} 首歌曲的下载记录及缓存文件吗？`,\n\t\t\t[\n\t\t\t\t{ text: '取消' },\n\t\t\t\t{\n\t\t\t\t\ttext: '删除',\n\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\tremoveDownloadsMutation.mutate(idsToDelete, {\n\t\t\t\t\t\t\tonSuccess: () => exitSelectMode(),\n\t\t\t\t\t\t\tonError: (e) =>\n\t\t\t\t\t\t\t\ttoastAndLogError('批量删除失败', e, 'Downloaded.Page'),\n\t\t\t\t\t\t})\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t{ cancelable: true },\n\t\t)\n\t}, [selected, exitSelectMode, removeDownloadsMutation])\n\n\tconst invertSelection = useCallback(() => {\n\t\tconst allIds = filteredTasks.map((t) => t.id)\n\t\tconst inverted = new Set(allIds.filter((id) => !selected.has(id)))\n\t\tsetSelected(inverted)\n\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Clock_Tick)\n\t}, [filteredTasks, selected, setSelected])\n\n\tconst extraData = useMemo<DownloadedItemExtraData>(\n\t\t() => ({\n\t\t\tselectMode,\n\t\t\tselected,\n\t\t\ttoggleSelected: (id: string) => {\n\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Clock_Tick)\n\t\t\t\ttoggle(id)\n\t\t\t},\n\t\t\tenterSelectMode: (id: string) => {\n\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Long_Press)\n\t\t\t\tenterSelectMode(id)\n\t\t\t},\n\t\t\tonItemPress: handlePlayItem,\n\t\t\tonMenuPress: handleItemMenuPress,\n\t\t}),\n\t\t[\n\t\t\tselectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenterSelectMode,\n\t\t\thandleItemMenuPress,\n\t\t\thandlePlayItem,\n\t\t],\n\t)\n\n\tif (isPending) {\n\t\treturn (\n\t\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t\t<ActivityIndicator\n\t\t\t\t\tsize='large'\n\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.BackAction\n\t\t\t\t\tonPress={() => (selectMode ? exitSelectMode() : router.back())}\n\t\t\t\t/>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={selectMode ? `已选择 ${selected.size} 项` : '下载管理'}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(new Set(filteredTasks.map((t) => t.id)))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={invertSelection}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='trash-can-outline'\n\t\t\t\t\t\t\tonPress={handleBatchDelete}\n\t\t\t\t\t\t\tdisabled={selected.size === 0}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='export-variant'\n\t\t\t\t\t\t\tonPress={handleExport}\n\t\t\t\t\t\t\tdisabled={selected.size === 0}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='magnify'\n\t\t\t\t\t\t\tonPress={() => setIsSearching(!isSearching)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='progress-download'\n\t\t\t\t\t\t\tonPress={() => router.push('/download')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='export-variant'\n\t\t\t\t\t\t\tonPress={handleExport}\n\t\t\t\t\t\t\tdisabled={completedTasks.length === 0}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t{isSearching && !selectMode && (\n\t\t\t\t<Searchbar\n\t\t\t\t\tplaceholder='搜索已下载歌曲'\n\t\t\t\t\tonChangeText={setSearchQuery}\n\t\t\t\t\tvalue={searchQuery}\n\t\t\t\t\tstyle={styles.searchbar}\n\t\t\t\t\tonIconPress={() => setIsSearching(false)}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={filteredTasks}\n\t\t\t\t\trenderItem={renderDownloadedItem}\n\t\t\t\t\textraData={extraData}\n\t\t\t\t\tkeyExtractor={(item) => item.id}\n\t\t\t\t\tItemSeparatorComponent={() => <Divider />}\n\t\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\t\tpaddingBottom: insets.bottom + 70,\n\t\t\t\t\t}}\n\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t<View style={styles.emptyContainer}>\n\t\t\t\t\t\t\t<Text variant='bodyLarge'>没有已下载的歌曲</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\n\t\t\t<FunctionalMenu\n\t\t\t\tvisible={menuState.visible}\n\t\t\t\tonDismiss={dismissItemMenu}\n\t\t\t\tanchor={menuState.anchor}\n\t\t\t\tanchorPosition='bottom'\n\t\t\t>\n\t\t\t\t<Menu.Item\n\t\t\t\t\tleadingIcon='export-variant'\n\t\t\t\t\ttitle='导出'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid handleSingleExport()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Menu.Item\n\t\t\t\t\tleadingIcon='trash-can-outline'\n\t\t\t\t\ttitle='删除'\n\t\t\t\t\tonPress={handleDelete}\n\t\t\t\t/>\n\t\t\t\t<Menu.Item\n\t\t\t\t\tleadingIcon='skip-next-circle-outline'\n\t\t\t\t\ttitle='下一首播放'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tif (currentMenuTask && currentMenuTask.track) {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tawait Orpheus.playNext(currentMenuTask.track)\n\t\t\t\t\t\t\t\ttoast.success('添加到下一首播放成功')\n\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\ttoastAndLogError(error, '添加到下一首播放失败')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdismissItemMenu()\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={!currentMenuTask?.track}\n\t\t\t\t/>\n\t\t\t</FunctionalMenu>\n\n\t\t\t<ExportDownloadsProgressModal\n\t\t\t\tsheetRef={exportSheetRef}\n\t\t\t\tids={exportConfig?.ids ?? []}\n\t\t\t\tdestinationUri={exportConfig?.destinationUri ?? ''}\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: { flex: 1 },\n\tlistContainer: { flex: 1 },\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n\tsearchbar: {\n\t\tmargin: 8,\n\t\televation: 0,\n\t\tbackgroundColor: 'transparent',\n\t\tborderBottomWidth: 1,\n\t\tborderBottomColor: 'rgba(0,0,0,0.1)',\n\t},\n\trectButton: { paddingVertical: 4 },\n\tsurface: {\n\t\toverflow: 'hidden',\n\t\tborderRadius: LIST_ITEM_BORDER_RADIUS,\n\t\tbackgroundColor: 'transparent',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 6,\n\t},\n\tindexContainer: {\n\t\twidth: 35,\n\t\tmarginRight: 8,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tcheckboxContainer: { position: 'absolute' },\n\ttitleContainer: { marginLeft: 12, flex: 1, marginRight: 4 },\n\tdetailsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginTop: 2,\n\t},\n\tdotSeparator: { marginHorizontal: 4 },\n\temptyContainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpaddingTop: 100,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/history/[date].tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport dayjs from 'dayjs'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useCallback, useMemo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tAppbar,\n\tSurface,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { HistoryListItem } from '@/features/history/HistoryListItem'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { usePlayHistoryByDayOfMonth } from '@/hooks/queries/playHistory'\nimport type { Track } from '@/types/core/media'\n\ninterface HistoryItemData {\n\ttrack: Track\n\tplayCount: number\n}\n\nconst formatDurationToWords = (seconds: number) => {\n\tif (isNaN(seconds) || seconds < 0) {\n\t\treturn '0\\u2009秒'\n\t}\n\tconst h = Math.floor(seconds / 3600)\n\tconst m = Math.floor((seconds % 3600) / 60)\n\tconst s = Math.floor(seconds % 60)\n\n\tconst parts = []\n\tif (h > 0) parts.push(`${h}\\u2009时`)\n\tif (m > 0) parts.push(`${m}\\u2009分`)\n\tif (s > 0 || parts.length === 0) parts.push(`${s}\\u2009秒`)\n\n\treturn parts.join('\\u2009')\n}\n\nconst renderItem = ({\n\titem,\n\tindex,\n}: {\n\titem: HistoryItemData\n\tindex: number\n}) => (\n\t<HistoryListItem\n\t\titem={item}\n\t\tindex={index}\n\t/>\n)\n\nexport default function DateHistoryPage() {\n\tconst { colors } = useTheme()\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\tconst haveTrack = useCurrentTrack()\n\tconst { date } = useLocalSearchParams<{ date: string }>()\n\tconst dayOfMonth = date ? dayjs(date).date() : null\n\n\tconst {\n\t\tdata: historyRecords,\n\t\tisLoading: isHistoryLoading,\n\t\tisError: isHistoryError,\n\t} = usePlayHistoryByDayOfMonth(dayOfMonth ?? 0)\n\n\tconst { aggregatedTracks, totalDuration } = useMemo(() => {\n\t\tif (!historyRecords) return { aggregatedTracks: [], totalDuration: 0 }\n\n\t\tconst trackMap = new Map<string, { track: Track; playCount: number }>()\n\t\tlet duration = 0\n\n\t\tfor (const record of historyRecords) {\n\t\t\tconst key = record.uniqueKey\n\t\t\tif (!trackMap.has(key)) {\n\t\t\t\ttrackMap.set(key, { track: record as Track, playCount: 0 })\n\t\t\t}\n\t\t\ttrackMap.get(key)!.playCount += 1\n\t\t\tduration += record.duration ?? 0\n\t\t}\n\n\t\tconst sortedTracks = Array.from(trackMap.values()).sort(\n\t\t\t(a, b) => b.playCount - a.playCount,\n\t\t)\n\n\t\treturn { aggregatedTracks: sortedTracks, totalDuration: duration }\n\t}, [historyRecords])\n\n\tconst totalDurationStr = useMemo(() => {\n\t\tif (isHistoryError || !historyRecords) return '0\\u2009秒'\n\t\treturn formatDurationToWords(totalDuration)\n\t}, [totalDuration, isHistoryError, historyRecords])\n\n\tconst keyExtractor = useCallback(\n\t\t(item: HistoryItemData) => item.track.uniqueKey,\n\t\t[],\n\t)\n\n\tconst renderContent = () => {\n\t\tif (isHistoryLoading) {\n\t\t\treturn (\n\t\t\t\t<ActivityIndicator\n\t\t\t\t\tanimating={true}\n\t\t\t\t\tstyle={styles.loadingIndicator}\n\t\t\t\t/>\n\t\t\t)\n\t\t}\n\n\t\tif (isHistoryError) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centeredContainer}>\n\t\t\t\t\t<Text>加载失败</Text>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\tif (aggregatedTracks.length === 0) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centeredContainer}>\n\t\t\t\t\t<Text>暂无数据</Text>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<FlashList\n\t\t\t\tdata={aggregatedTracks}\n\t\t\t\trenderItem={renderItem}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\tpaddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,\n\t\t\t\t}}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='那月今日' />\n\t\t\t</Appbar.Header>\n\t\t\t{aggregatedTracks.length > 0 && !isHistoryError && (\n\t\t\t\t<>\n\t\t\t\t\t<Surface\n\t\t\t\t\t\tstyle={styles.totalDurationSurface}\n\t\t\t\t\t\televation={2}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Text variant='titleMedium'>当日听歌时长</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='headlineMedium'\n\t\t\t\t\t\t\tstyle={[styles.totalDurationText, { color: colors.primary }]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{totalDurationStr}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Surface>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t<View style={styles.contentContainer}>{renderContent()}</View>\n\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tloadingIndicator: {\n\t\tmarginTop: 20,\n\t},\n\tcenteredContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\ttotalDurationSurface: {\n\t\tmarginHorizontal: 16,\n\t\tmarginTop: 16,\n\t\tmarginBottom: 8,\n\t\tpaddingVertical: 16,\n\t\tborderRadius: 12,\n\t\talignItems: 'center',\n\t},\n\ttotalDurationText: {\n\t\tmarginTop: 8,\n\t},\n\n\tcontentContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/history/overall.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { useRouter } from 'expo-router'\nimport { useCallback, useMemo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tAppbar,\n\tSurface,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { HistoryListItem } from '@/features/history/HistoryListItem'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport {\n\tusePlayCountHistoryPaginated,\n\tuseTotalPlaybackDuration,\n} from '@/hooks/queries/db/track'\nimport type { Track } from '@/types/core/media'\n\ninterface HistoryItemData {\n\ttrack: Track\n\tplayCount: number\n}\n\nconst formatDurationToWords = (seconds: number) => {\n\tif (isNaN(seconds) || seconds < 0) {\n\t\treturn '0\\u2009秒'\n\t}\n\tconst h = Math.floor(seconds / 3600)\n\tconst m = Math.floor((seconds % 3600) / 60)\n\tconst s = Math.floor(seconds % 60)\n\n\tconst parts = []\n\tif (h > 0) parts.push(`${h}\\u2009时`)\n\tif (m > 0) parts.push(`${m}\\u2009分`)\n\tif (s > 0 || parts.length === 0) parts.push(`${s}\\u2009秒`)\n\n\treturn parts.join('\\u2009')\n}\n\nconst renderItem = ({\n\titem,\n\tindex,\n}: {\n\titem: HistoryItemData\n\tindex: number\n}) => (\n\t<HistoryListItem\n\t\titem={item}\n\t\tindex={index}\n\t/>\n)\n\nexport default function OverallHistoryPage() {\n\tconst { colors } = useTheme()\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\tconst haveTrack = useCurrentTrack()\n\n\tconst {\n\t\tdata: historyData,\n\t\tisLoading: isHistoryLoading,\n\t\tisError: isHistoryError,\n\t\tfetchNextPage,\n\t\thasNextPage,\n\t\tisFetchingNextPage,\n\t} = usePlayCountHistoryPaginated(30, true, 15)\n\n\tconst { data: totalDurationData, isError: isTotalDurationError } =\n\t\tuseTotalPlaybackDuration(true)\n\n\tconst allTracks = useMemo(() => {\n\t\treturn historyData?.pages.flatMap((page) => page.items) ?? []\n\t}, [historyData])\n\n\tconst totalDuration = useMemo(() => {\n\t\tif (isTotalDurationError || !totalDurationData) return '0\\u2009秒'\n\t\treturn formatDurationToWords(totalDurationData)\n\t}, [totalDurationData, isTotalDurationError])\n\n\tconst keyExtractor = useCallback(\n\t\t(item: HistoryItemData) => item.track.uniqueKey,\n\t\t[],\n\t)\n\n\tconst onEndReached = () => {\n\t\tif (hasNextPage && !isFetchingNextPage) {\n\t\t\tvoid fetchNextPage()\n\t\t}\n\t}\n\n\tconst renderContent = () => {\n\t\tif (isHistoryLoading) {\n\t\t\treturn (\n\t\t\t\t<ActivityIndicator\n\t\t\t\t\tanimating={true}\n\t\t\t\t\tstyle={styles.loadingIndicator}\n\t\t\t\t/>\n\t\t\t)\n\t\t}\n\n\t\tif (isHistoryError) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centeredContainer}>\n\t\t\t\t\t<Text>加载失败</Text>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\tif (allTracks.length === 0) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centeredContainer}>\n\t\t\t\t\t<Text>暂无数据</Text>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<FlashList\n\t\t\t\tdata={allTracks}\n\t\t\t\trenderItem={renderItem}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\tpaddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,\n\t\t\t\t}}\n\t\t\t\tonEndReached={onEndReached}\n\t\t\t\tonEndReachedThreshold={0.8}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tListFooterComponent={\n\t\t\t\t\tisFetchingNextPage ? (\n\t\t\t\t\t\t<View style={styles.footerLoadingContainer}>\n\t\t\t\t\t\t\t<ActivityIndicator size='small' />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : !hasNextPage ? (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={[styles.footerText, { color: colors.onSurfaceVariant }]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t已经到底啦\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t) : null\n\t\t\t\t}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='全部统计' />\n\t\t\t</Appbar.Header>\n\t\t\t{allTracks.length > 0 && !isTotalDurationError && (\n\t\t\t\t<Surface\n\t\t\t\t\tstyle={styles.totalDurationSurface}\n\t\t\t\t\televation={2}\n\t\t\t\t>\n\t\t\t\t\t<Text variant='titleMedium'>总计听歌时长</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='headlineMedium'\n\t\t\t\t\t\tstyle={[styles.totalDurationText, { color: colors.primary }]}\n\t\t\t\t\t>\n\t\t\t\t\t\t{totalDuration}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.totalDurationSubText,\n\t\t\t\t\t\t\t{ color: colors.onSurfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t（仅统计完整播放的歌曲）\n\t\t\t\t\t</Text>\n\t\t\t\t</Surface>\n\t\t\t)}\n\n\t\t\t<View style={styles.contentContainer}>{renderContent()}</View>\n\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tloadingIndicator: {\n\t\tmarginTop: 20,\n\t},\n\tcenteredContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tfooterLoadingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tfooterText: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n\ttotalDurationSurface: {\n\t\tmarginHorizontal: 16,\n\t\tmarginTop: 16,\n\t\tmarginBottom: 8,\n\t\tpaddingVertical: 16,\n\t\tborderRadius: 12,\n\t\talignItems: 'center',\n\t},\n\ttotalDurationText: {\n\t\tmarginTop: 8,\n\t},\n\ttotalDurationSubText: {\n\t\tmarginTop: 4,\n\t},\n\tcontentContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/modal.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { Suspense, useEffect, useState } from 'react'\nimport { ActivityIndicator, Keyboard, StyleSheet, View } from 'react-native'\n\nimport AnimatedModalOverlay from '@/components/common/AnimatedModalOverlay'\nimport { modalRegistry } from '@/components/ModalRegistry'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nexport default function ModalHost() {\n\tconst modals = useModalStore((state) => state.modals)\n\tconst closeTop = useModalStore((s) => s.closeTop)\n\tconst eventEmitter = useModalStore((s) => s.eventEmitter)\n\tconst [canUnmountHost, setCanUnmountHost] = useState(modals.length === 0)\n\tconst router = useRouter()\n\n\tusePreventRemove(modals.length > 0, () => {\n\t\tif (modals[modals.length - 1].options?.dismissible === false) {\n\t\t\treturn\n\t\t}\n\t\tcloseTop()\n\t})\n\n\tuseEffect(() => {\n\t\tif (modals.length > 0) {\n\t\t\tsetCanUnmountHost(false)\n\t\t\treturn\n\t\t}\n\t\tKeyboard.dismiss()\n\t\tif (router.canGoBack()) {\n\t\t\tsetCanUnmountHost(true)\n\t\t\trouter.back()\n\t\t\tsetImmediate(() => {\n\t\t\t\teventEmitter.emit('modalHostDidClose')\n\t\t\t})\n\t\t}\n\t}, [eventEmitter, modals, router])\n\n\tif (canUnmountHost) return null\n\n\treturn (\n\t\t<View\n\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\tpointerEvents='box-none'\n\t\t>\n\t\t\t{modals.map((m, idx) => {\n\t\t\t\tconst Component = modalRegistry[m.key]\n\t\t\t\tif (!Component) return null\n\t\t\t\tconst zIndex = 1000 + idx * 100\n\t\t\t\treturn (\n\t\t\t\t\t<AnimatedModalOverlay\n\t\t\t\t\t\tkey={m.key}\n\t\t\t\t\t\tvisible\n\t\t\t\t\t\tonDismiss={() => {\n\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\tm.options?.dismissible === undefined ||\n\t\t\t\t\t\t\t\tm.options?.dismissible\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\tuseModalStore.getState().close(m.key)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tcontentStyle={{ zIndex }}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Suspense\n\t\t\t\t\t\t\tfallback={\n\t\t\t\t\t\t\t\t<View style={styles.loadingContainer}>\n\t\t\t\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{/*\n            // @ts-expect-error -- 懒得管了*/}\n\t\t\t\t\t\t\t<Component {...m.props} />\n\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t</AnimatedModalOverlay>\n\t\t\t\t)\n\t\t\t})}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tloadingContainer: {\n\t\twidth: 200,\n\t\theight: 150,\n\t\talignSelf: 'center',\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/player.tsx",
    "content": "import ImageThemeColors from '@bbplayer/image-theme-colors'\nimport type { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport {\n\tCanvas,\n\tGroup,\n\tLinearGradient,\n\tRect,\n\tvec,\n} from '@shopify/react-native-skia'\nimport { useImage } from 'expo-image'\nimport { router } from 'expo-router'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport {\n\tAppState,\n\tStyleSheet,\n\tuseColorScheme,\n\tuseWindowDimensions,\n\tView,\n} from 'react-native'\nimport PagerView from 'react-native-pager-view'\nimport { useTheme } from 'react-native-paper'\nimport {\n\tcreateAnimatedComponent,\n\tEasing,\n\tuseDerivedValue,\n\tuseEvent,\n\tuseHandler,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport PlayerQueueModal from '@/components/modals/PlayerQueueModal'\nimport { PlayerFunctionalMenu } from '@/features/player/components/PlayerFunctionalMenu'\nimport { PlayerHeader } from '@/features/player/components/PlayerHeader'\nimport Lyrics from '@/features/player/components/PlayerLyrics'\nimport PlayerMainTab from '@/features/player/components/PlayerMainTab'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useScreenDimensions } from '@/hooks/ui/useScreenDimensions'\nimport log, { reportErrorToSentry } from '@/utils/log'\nimport toast from '@/utils/toast'\n\nconst AnimatedPagerView = createAnimatedComponent(PagerView)\n\ninterface PageScrollEvent {\n\toffset: number\n\tposition: number\n}\n\nfunction usePageScrollHandler(\n\thandlers: {\n\t\tonPageScroll: (e: PageScrollEvent, context: Record<string, unknown>) => void\n\t},\n\tdependencies?: unknown[],\n) {\n\tconst { context, doDependenciesDiffer } = useHandler(handlers, dependencies)\n\tconst subscribeForEvents = ['onPageScroll']\n\n\treturn useEvent(\n\t\t(event) => {\n\t\t\t'worklet'\n\t\t\tconst { onPageScroll } = handlers\n\t\t\tif (onPageScroll && event.eventName.endsWith('onPageScroll')) {\n\t\t\t\tonPageScroll(event as unknown as PageScrollEvent, context)\n\t\t\t}\n\t\t},\n\t\tsubscribeForEvents,\n\t\tdoDependenciesDiffer,\n\t)\n}\n\nconst logger = log.extend('App.Player')\n\nexport default function PlayerPage() {\n\tconst theme = useTheme()\n\tconst colors = theme.colors\n\tconst insets = useSafeAreaInsets()\n\tconst sheetRef = useRef<TrueSheet>(null)\n\tconst pagerRef = useRef<PagerView>(null)\n\tconst currentTrack = useCurrentTrack()\n\tconst coverRef = useImage(\n\t\tresolveTrackCover(currentTrack?.uniqueKey, currentTrack?.coverUrl) ?? '',\n\t\t{\n\t\t\tonError: () => void 0,\n\t\t},\n\t)\n\tconst { width } = useWindowDimensions()\n\tconst colorScheme = useColorScheme()\n\tconst playerBackgroundStyle = useAppStore(\n\t\t(state) => state.settings.playerBackgroundStyle,\n\t)\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\tconst [isForeground, setIsForeground] = useState(\n\t\tAppState.currentState === 'active',\n\t)\n\tconst [isPreventingBack, setIsPreventingBack] = useState(true)\n\n\tconst [index, setIndex] = useState(0)\n\n\tconst dismissPlayer = () => {\n\t\tsetIsPreventingBack(false)\n\t\tif (router.canGoBack()) {\n\t\t\trouter.back()\n\t\t}\n\t}\n\n\tconst handleDismiss = () => {\n\t\tif (index === 1) {\n\t\t\tpagerRef.current?.setPage(0)\n\t\t\treturn\n\t\t}\n\t\tdismissPlayer()\n\t}\n\n\tuseEffect(() => {\n\t\tconst subscription = AppState.addEventListener('change', (nextAppState) => {\n\t\t\tsetIsForeground(nextAppState === 'active')\n\t\t})\n\n\t\treturn () => {\n\t\t\tsubscription.remove()\n\t\t}\n\t}, [])\n\n\tconst { height: realHeight } = useScreenDimensions()\n\n\tconst gradientMainColor = useSharedValue(colors.background)\n\tconst scrollX = useSharedValue(0)\n\n\tconst [menuVisible, setMenuVisible] = useState(false)\n\n\tconst jumpTo = (key: string) => {\n\t\tconst targetIndex = key === 'lyrics' ? 1 : 0\n\t\tpagerRef.current?.setPage(targetIndex)\n\t}\n\n\tconst gradientColors = useDerivedValue(() => {\n\t\tif (playerBackgroundStyle !== 'gradient') {\n\t\t\treturn [colors.background, colors.background]\n\t\t}\n\t\treturn [gradientMainColor.value, colors.background]\n\t})\n\n\tuseEffect(() => {\n\t\tif (!coverRef || playerBackgroundStyle === 'md3' || !isForeground) {\n\t\t\tif (playerBackgroundStyle !== 'gradient' && !isForeground) {\n\t\t\t\tgradientMainColor.set(colors.background)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tImageThemeColors.extractThemeColorAsync(coverRef)\n\t\t\t.then((palette) => {\n\t\t\t\tif (!palette) return\n\n\t\t\t\tconst animationConfig = {\n\t\t\t\t\tduration: 400,\n\t\t\t\t\teasing: Easing.out(Easing.quad),\n\t\t\t\t}\n\n\t\t\t\tif (playerBackgroundStyle === 'gradient') {\n\t\t\t\t\tlet topColor: string\n\t\t\t\t\tif (colorScheme === 'dark') {\n\t\t\t\t\t\ttopColor =\n\t\t\t\t\t\t\tpalette.darkMuted?.hex ?? palette.muted?.hex ?? colors.background\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttopColor =\n\t\t\t\t\t\t\tpalette.lightMuted?.hex ?? palette.muted?.hex ?? colors.background\n\t\t\t\t\t}\n\n\t\t\t\t\tgradientMainColor.set(withTiming(topColor, animationConfig))\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch((e) => {\n\t\t\t\tlogger.error('提取封面图片主题色失败', e)\n\t\t\t\treportErrorToSentry(e, '提取封面图片主题色失败', 'App.Player')\n\t\t\t})\n\t}, [\n\t\tcolorScheme,\n\t\tcolors.background,\n\t\tcoverRef,\n\t\tgradientMainColor,\n\t\tisForeground,\n\t\tplayerBackgroundStyle,\n\t])\n\n\tconst scrimColors = useMemo(() => {\n\t\tif (playerBackgroundStyle !== 'gradient')\n\t\t\treturn ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']\n\t\tif (colorScheme === 'dark') {\n\t\t\treturn ['rgba(0, 0, 0, 0.4)', 'rgba(0, 0, 0, 0)']\n\t\t} else {\n\t\t\treturn ['rgba(255, 255, 255, 0.4)', 'rgba(255, 255, 255, 0)']\n\t\t}\n\t}, [colorScheme, playerBackgroundStyle])\n\n\tconst [queueVisible, setQueueVisible] = useState(false)\n\n\tusePreventRemove(isPreventingBack, () => {\n\t\tif (menuVisible) {\n\t\t\tsetMenuVisible(false)\n\t\t\treturn\n\t\t}\n\t\tif (queueVisible) {\n\t\t\tconst sheet = sheetRef.current\n\t\t\tif (!sheet) {\n\t\t\t\tsetQueueVisible(false)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsheet\n\t\t\t\t.dismiss()\n\t\t\t\t.catch(() => {\n\t\t\t\t\t// Ignore error if view not found or already dismissed\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tsetQueueVisible(false)\n\t\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tif (index === 1) {\n\t\t\tpagerRef.current?.setPage(0)\n\t\t\treturn\n\t\t}\n\t\thandleDismiss()\n\t})\n\n\tconst scrimEndVec = vec(0, realHeight * 0.5)\n\n\tuseEffect(() => {\n\t\t// @ts-expect-error -- 虽然我们项目内已经移除了 streamer 选项，但部分存量用户可能还在这个选项，需要帮他回退\n\t\tif (playerBackgroundStyle === 'streamer') {\n\t\t\ttoast.show(\n\t\t\t\t'因为会对性能造成较大影响，并且也不好看，所以我们移除了流光效果，已为您回退到渐变模式',\n\t\t\t)\n\t\t\tsetSettings({ playerBackgroundStyle: 'gradient' })\n\t\t}\n\t}, [playerBackgroundStyle, setSettings])\n\n\tconst pageScrollHandler = usePageScrollHandler({\n\t\tonPageScroll: (e) => {\n\t\t\t'worklet'\n\t\t\tscrollX.set(e.offset + e.position)\n\t\t},\n\t})\n\n\treturn (\n\t\t<View style={styles.fullScreen}>\n\t\t\t<View style={styles.fullScreen}>\n\t\t\t\t<Canvas style={StyleSheet.absoluteFill}>\n\t\t\t\t\t<Rect\n\t\t\t\t\t\tx={0}\n\t\t\t\t\t\ty={0}\n\t\t\t\t\t\twidth={width}\n\t\t\t\t\t\theight={realHeight}\n\t\t\t\t\t\tcolor={colors.background}\n\t\t\t\t\t/>\n\t\t\t\t\t{playerBackgroundStyle === 'gradient' && (\n\t\t\t\t\t\t<Group>\n\t\t\t\t\t\t\t<Rect\n\t\t\t\t\t\t\t\tx={0}\n\t\t\t\t\t\t\t\ty={0}\n\t\t\t\t\t\t\t\twidth={width}\n\t\t\t\t\t\t\t\theight={realHeight}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\t\t\t\tstart={vec(0, 0)}\n\t\t\t\t\t\t\t\t\tend={vec(0, realHeight)}\n\t\t\t\t\t\t\t\t\tcolors={gradientColors}\n\t\t\t\t\t\t\t\t\tpositions={[0, 1]}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</Rect>\n\t\t\t\t\t\t\t<Rect\n\t\t\t\t\t\t\t\tx={0}\n\t\t\t\t\t\t\t\ty={0}\n\t\t\t\t\t\t\t\twidth={width}\n\t\t\t\t\t\t\t\theight={realHeight}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\t\t\t\tstart={vec(0, 0)}\n\t\t\t\t\t\t\t\t\tend={scrimEndVec}\n\t\t\t\t\t\t\t\t\tcolors={scrimColors}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</Rect>\n\t\t\t\t\t\t</Group>\n\t\t\t\t\t)}\n\t\t\t\t</Canvas>\n\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.container,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tpaddingTop: insets.top,\n\t\t\t\t\t\t},\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.innerContainer,\n\t\t\t\t\t\t\t{ pointerEvents: menuVisible ? 'none' : 'auto' },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<PlayerHeader\n\t\t\t\t\t\t\tonMorePress={() => setMenuVisible(true)}\n\t\t\t\t\t\t\tonBack={handleDismiss}\n\t\t\t\t\t\t\tindex={index}\n\t\t\t\t\t\t\tscrollX={scrollX}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<AnimatedPagerView\n\t\t\t\t\t\t\tref={pagerRef}\n\t\t\t\t\t\t\tstyle={styles.tabView}\n\t\t\t\t\t\t\tinitialPage={0}\n\t\t\t\t\t\t\tonPageScroll={pageScrollHandler}\n\t\t\t\t\t\t\tonPageSelected={(e) => setIndex(e.nativeEvent.position)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\tkey='main'\n\t\t\t\t\t\t\t\tstyle={styles.tabView}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<PlayerMainTab\n\t\t\t\t\t\t\t\t\tsheetRef={sheetRef}\n\t\t\t\t\t\t\t\t\tjumpTo={jumpTo}\n\t\t\t\t\t\t\t\t\timageRef={coverRef}\n\t\t\t\t\t\t\t\t\tonPresent={() => setQueueVisible(true)}\n\t\t\t\t\t\t\t\t\tdanmakuEnabled={index === 0}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\tkey='lyrics'\n\t\t\t\t\t\t\t\tstyle={styles.tabView}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Lyrics\n\t\t\t\t\t\t\t\t\tcurrentIndex={index}\n\t\t\t\t\t\t\t\t\tonPressBackground={() => jumpTo('main')}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</AnimatedPagerView>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t<PlayerFunctionalMenu\n\t\t\t\t\t\tmenuVisible={menuVisible}\n\t\t\t\t\t\tsetMenuVisible={setMenuVisible}\n\t\t\t\t\t/>\n\n\t\t\t\t\t<PlayerQueueModal\n\t\t\t\t\t\tsheetRef={sheetRef}\n\t\t\t\t\t\tisVisible={queueVisible}\n\t\t\t\t\t\tonDidDismiss={() => setQueueVisible(false)}\n\t\t\t\t\t\tonDidPresent={() => setQueueVisible(true)}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tfullScreen: {\n\t\tflex: 1,\n\t},\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tinnerContainer: {\n\t\tflex: 1,\n\t},\n\ttabView: {\n\t\tflex: 1,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/external-sync.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { memo, useCallback, useEffect, useRef, useState } from 'react'\nimport { ActivityIndicator, StyleSheet, View } from 'react-native'\nimport {\n\tAppbar,\n\tBanner,\n\tDivider,\n\tText,\n\tTouchableRipple,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport Button from '@/components/common/Button'\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport IconButton from '@/components/common/IconButton'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { playlistKeys } from '@/hooks/queries/db/playlist'\nimport { useExternalPlaylist } from '@/hooks/queries/external-playlist/useExternalPlaylist'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport {\n\tExternalPlaylistSyncStoreProvider,\n\tuseExternalPlaylistSyncStore,\n} from '@/hooks/stores/useExternalPlaylistSyncStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { syncExternalPlaylistFacade } from '@/lib/facades/syncExternalPlaylist'\nimport { externalPlaylistService } from '@/lib/services/externalPlaylistService'\nimport {\n\tLIST_ITEM_BORDER_RADIUS,\n\tLIST_ITEM_COVER_SIZE,\n} from '@/theme/dimensions'\nimport type { GenericTrack } from '@/types/external_playlist'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport toast from '@/utils/toast'\n\nconst ItemSeparator = () => <Divider />\n\nconst SyncTrackItem = memo(\n\t({\n\t\tindex,\n\t\ttrack,\n\t\tonPress,\n\t}: {\n\t\tindex: number\n\t\ttrack: GenericTrack\n\t\tonPress: () => void\n\t}) => {\n\t\tconst theme = useTheme()\n\t\tconst result = useExternalPlaylistSyncStore((state) => state.results[index])\n\n\t\treturn (\n\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t<View style={styles.itemInner}>\n\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\tid={`${index}`}\n\t\t\t\t\t\ttitle={track.title}\n\t\t\t\t\t\tcover={track.coverUrl}\n\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\tborderRadius={LIST_ITEM_BORDER_RADIUS}\n\t\t\t\t\t/>\n\t\t\t\t\t<View style={styles.itemContent}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={{ fontWeight: '600', marginBottom: 2 }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{track.title}\n\t\t\t\t\t\t\t{track.translatedTitle && ` (${track.translatedTitle})`}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tstyle={{ color: theme.colors.onSurfaceVariant }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{track.artists.join(', ')} - {track.album}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t{result?.matchedVideo && (\n\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tmarginTop: 8,\n\t\t\t\t\t\t\t\t\tbackgroundColor: theme.colors.surfaceVariant,\n\t\t\t\t\t\t\t\t\tpadding: 8,\n\t\t\t\t\t\t\t\t\tborderRadius: 8,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tcolor: theme.colors.primary,\n\t\t\t\t\t\t\t\t\t\tfontWeight: 'bold',\n\t\t\t\t\t\t\t\t\t\tmarginBottom: 2,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t已匹配:{' '}\n\t\t\t\t\t\t\t\t\t{result.matchedVideo.title\n\t\t\t\t\t\t\t\t\t\t.replace(/<em class=\"keyword\">/g, '')\n\t\t\t\t\t\t\t\t\t\t.replace(/<\\/em>/g, '')}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tstyle={{ color: theme.colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tUP主: {result.matchedVideo.author}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</View>\n\t\t\t\t\t<View style={styles.statusContainer}>\n\t\t\t\t\t\t{!result ? (\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon='clock-outline'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\ticonColor={theme.colors.onSurfaceVariant}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : !result.matchedVideo ? (\n\t\t\t\t\t\t\t<View style={{ alignItems: 'flex-end' }}>\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='alert-circle-outline'\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\ticonColor={theme.colors.error}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='pencil'\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tonPress={onPress}\n\t\t\t\t\t\t\t\t\tmode='contained-tonal'\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<View style={{ alignItems: 'flex-end' }}>\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='check-circle-outline'\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\ticonColor={theme.colors.primary}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='pencil'\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tonPress={onPress}\n\t\t\t\t\t\t\t\t\tmode='contained-tonal'\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t)\n\t},\n)\nSyncTrackItem.displayName = 'SyncTrackItem'\n\nconst renderItem = ({\n\titem,\n\tindex,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tGenericTrack,\n\t{\n\t\topenManualMatch: (track: GenericTrack, index: number) => void\n\t\tsyncing: boolean\n\t}\n>) => {\n\tif (!extraData) return null\n\treturn (\n\t\t<SyncTrackItem\n\t\t\tindex={index}\n\t\t\ttrack={item}\n\t\t\tonPress={() => extraData.openManualMatch(item, index)}\n\t\t/>\n\t)\n}\n\nconst ExternalPlaylistSyncPageInner = () => {\n\tconst { id, source } = useLocalSearchParams<{\n\t\tid: string\n\t\tsource: 'netease' | 'qq'\n\t}>()\n\tconst theme = useTheme()\n\tconst insets = useSafeAreaInsets()\n\tconst router = useRouter()\n\tconst openModal = useModalStore((state) => state.open)\n\tconst queryClient = useQueryClient()\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<GenericTrack>()\n\n\tconst { data, isLoading, error } = useExternalPlaylist(\n\t\tid ?? '',\n\t\tsource ?? 'netease',\n\t)\n\n\tconst {\n\t\tsetSyncing,\n\t\tsetProgress,\n\t\tsetResult,\n\t\treset,\n\t\tsyncing,\n\t\tprogress,\n\t\tresults,\n\t} = useExternalPlaylistSyncStore((state) => state)\n\tconst tracks = data?.tracks ?? []\n\n\tuseEffect(() => {\n\t\treset()\n\t\treturn () => reset()\n\t}, [reset])\n\n\tconst abortControllerRef = useRef<AbortController | null>(null)\n\tconst sessionStartTimeRef = useRef<number>(0)\n\tconst [etaSeconds, setEtaSeconds] = useState<number | null>(null)\n\n\tconst [isExiting, setIsExiting] = useState(false)\n\n\tconst hasResults = Object.keys(results).length > 0 && !isExiting\n\tusePreventRemove(hasResults, () => {\n\t\topenModal('Alert', {\n\t\t\ttitle: '确定要退出吗？',\n\t\t\tmessage:\n\t\t\t\t'退出后，当前的匹配结果将会丢失，未保存的进度将无法恢复。（注意，匹配完毕必须手动保存！）',\n\t\t\tbuttons: [\n\t\t\t\t{\n\t\t\t\t\ttext: '取消',\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: '退出',\n\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\tsetIsExiting(true)\n\t\t\t\t\t\tuseModalStore.getState().doAfterModalHostClosed(() => router.back())\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t})\n\t})\n\n\tconst handleSave = async () => {\n\t\tif (!data?.playlist || !data?.tracks || !results) return\n\t\tconst matchResults = Object.values(results)\n\t\tif (matchResults.length === 0) {\n\t\t\ttoast.error('没有可保存的内容')\n\t\t\treturn\n\t\t}\n\n\t\tconst unmatchedCount = matchResults.filter(\n\t\t\t(r) => r.matchedVideo === null,\n\t\t).length\n\n\t\tconst unprocessedCount = data.tracks.length - matchResults.length\n\n\t\tconst proceedSave = async () => {\n\t\t\tconst loadingToast = toast.loading('正在保存到本地...')\n\t\t\tconst coverUrl = data.playlist.coverUrl ?? ''\n\t\t\tconst description = data.playlist.description ?? ''\n\t\t\ttry {\n\t\t\t\tconst saveResult = await syncExternalPlaylistFacade.saveMatchedPlaylist(\n\t\t\t\t\t{\n\t\t\t\t\t\ttitle: data.playlist.title,\n\t\t\t\t\t\tcoverUrl,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t},\n\t\t\t\t\tmatchResults,\n\t\t\t\t)\n\n\t\t\t\tif (saveResult.isErr()) {\n\t\t\t\t\ttoast.error(`保存失败: ${saveResult.error.message}`)\n\t\t\t\t} else {\n\t\t\t\t\ttoast.success('歌单已保存到本地')\n\t\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t\t})\n\t\t\t\t\treset()\n\t\t\t\t\tconst playlistId = saveResult.value\n\t\t\t\t\tuseModalStore\n\t\t\t\t\t\t.getState()\n\t\t\t\t\t\t.doAfterModalHostClosed(() =>\n\t\t\t\t\t\t\trouter.replace(`/playlist/local/${playlistId}`),\n\t\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\ttoast.error('保存失败')\n\t\t\t}\n\t\t\ttoast.dismiss(loadingToast)\n\t\t}\n\n\t\tif (unmatchedCount > 0 || unprocessedCount > 0) {\n\t\t\tconst messages = []\n\t\t\tif (unprocessedCount > 0) {\n\t\t\t\tmessages.push(`还有 ${unprocessedCount} 首歌曲未进行匹配`)\n\t\t\t}\n\t\t\tif (unmatchedCount > 0) {\n\t\t\t\tmessages.push(`还有 ${unmatchedCount} 首歌曲未匹配到视频`)\n\t\t\t}\n\n\t\t\topenModal('Alert', {\n\t\t\t\ttitle: '存在未完成的项目',\n\t\t\t\tmessage: `${messages.join('，')}。如果继续，这些已匹配的歌曲将被保存，未匹配的将被忽略。建议您完成匹配或手动匹配剩余歌曲。`,\n\t\t\t\tbuttons: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttext: '去手动匹配',\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ttext: '仍要保存',\n\t\t\t\t\t\tonPress: proceedSave,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t})\n\t\t} else {\n\t\t\tawait proceedSave()\n\t\t}\n\t}\n\n\tconst processedIndexes = Object.keys(results).map(Number)\n\tconst hasProcessedAny = processedIndexes.length > 0\n\tconst failedIndexes = processedIndexes.filter(\n\t\t(index) => results[index]?.matchedVideo === null,\n\t)\n\tconst unprocessedIndexes = tracks\n\t\t.map((_, index) => index)\n\t\t.filter((index) => !Object.hasOwn(results, index))\n\tconst syncButtonText = syncing\n\t\t? '暂停'\n\t\t: !hasProcessedAny\n\t\t\t? '开始匹配'\n\t\t\t: unprocessedIndexes.length > 0\n\t\t\t\t? '继续匹配'\n\t\t\t\t: failedIndexes.length > 0\n\t\t\t\t\t? '继续匹配失败项'\n\t\t\t\t\t: '重新匹配全部'\n\n\tconst handleSync = async () => {\n\t\tif (!data?.tracks) return\n\n\t\tif (syncing) {\n\t\t\tabortControllerRef.current?.abort()\n\t\t\tsetSyncing(false)\n\t\t\tsetEtaSeconds(null)\n\t\t\ttoast.info('已暂停匹配')\n\t\t\treturn\n\t\t}\n\n\t\tlet indexesToProcess = unprocessedIndexes\n\n\t\tif (indexesToProcess.length === 0 && failedIndexes.length > 0) {\n\t\t\tindexesToProcess = failedIndexes\n\t\t}\n\n\t\tif (indexesToProcess.length === 0) {\n\t\t\treset()\n\t\t\tindexesToProcess = data.tracks.map((_, index) => index)\n\t\t}\n\n\t\tsetSyncing(true)\n\t\tsetProgress(0, indexesToProcess.length)\n\n\t\tabortControllerRef.current = new AbortController()\n\t\tsessionStartTimeRef.current = Date.now()\n\n\t\t// Initial rough estimate\n\t\tsetEtaSeconds(indexesToProcess.length * 1.2)\n\n\t\tconst result = await externalPlaylistService.matchExternalPlaylist(\n\t\t\tdata.tracks,\n\t\t\t(current, total, matchResult, trackIndex) => {\n\t\t\t\tsetResult(trackIndex, matchResult)\n\t\t\t\tsetProgress(current, total)\n\n\t\t\t\t// ETA Calculation\n\t\t\t\tconst now = Date.now()\n\t\t\t\tconst elapsed = now - sessionStartTimeRef.current\n\t\t\t\tconst processedInSession = current\n\n\t\t\t\tif (processedInSession > 0) {\n\t\t\t\t\tconst avgTimePerItem = elapsed / processedInSession\n\t\t\t\t\tconst remainingItems = total - current\n\t\t\t\t\tconst eta = (avgTimePerItem * remainingItems) / 1000\n\t\t\t\t\tsetEtaSeconds(eta)\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\ttrackIndexes: indexesToProcess,\n\t\t\t\tsignal: abortControllerRef.current.signal,\n\t\t\t},\n\t\t)\n\n\t\tsetSyncing(false)\n\t\tsetEtaSeconds(null)\n\t\tif (result.isErr()) {\n\t\t\tif (result.error.message !== 'Aborted') {\n\t\t\t\ttoast.error(`匹配出错: ${result.error.message}`)\n\t\t\t}\n\t\t} else {\n\t\t\ttoast.success('匹配完成')\n\t\t}\n\t}\n\n\tconst handleOpenManualMatch = useCallback(\n\t\t(track: GenericTrack, index: number) => {\n\t\t\topenModal('ManualMatchExternalSync', {\n\t\t\t\ttrack,\n\t\t\t\tinitialQuery: `${track.title} - ${track.artists.join(' ')}`,\n\t\t\t\tonMatch: (result) => setResult(index, result),\n\t\t\t})\n\t\t},\n\t\t[openModal, setResult],\n\t)\n\n\tconst keyExtractor = useCallback(\n\t\t(item: GenericTrack, index: number) => `${index}-${item.title}`,\n\t\t[],\n\t)\n\n\tif (isLoading) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (error || !data) {\n\t\treturn (\n\t\t\t<View style={styles.center}>\n\t\t\t\t<Text style={{ color: theme.colors.error }}>\n\t\t\t\t\t加载失败: {error?.message ?? '未知错误'}\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={{ flex: 1, backgroundColor: theme.colors.background }}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={router.back} />\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle='外部歌单匹配'\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t<Appbar.Action\n\t\t\t\t\ticon='check'\n\t\t\t\t\tonPress={handleSave}\n\t\t\t\t\tdisabled={!hasResults}\n\t\t\t\t/>\n\t\t\t</Appbar.Header>\n\t\t\t<Banner\n\t\t\t\tvisible={hasResults}\n\t\t\t\tactions={[\n\t\t\t\t\t{\n\t\t\t\t\t\tlabel: '立即保存',\n\t\t\t\t\t\tonPress: handleSave,\n\t\t\t\t\t},\n\t\t\t\t]}\n\t\t\t\ticon='information'\n\t\t\t>\n\t\t\t\t匹配完成后，请务必点击右上角或下方的保存按钮，否则进度将丢失。\n\t\t\t</Banner>\n\t\t\t<FlashList\n\t\t\t\tref={listRef}\n\t\t\t\tdata={tracks}\n\t\t\t\trenderItem={renderItem}\n\t\t\t\textraData={{\n\t\t\t\t\topenManualMatch: handleOpenManualMatch,\n\t\t\t\t\tsyncing,\n\t\t\t\t}}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tItemSeparatorComponent={ItemSeparator}\n\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\tpaddingBottom: insets.bottom,\n\t\t\t\t}}\n\t\t\t\tListHeaderComponent={\n\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\tid={data.playlist.id}\n\t\t\t\t\t\ttitle={data.playlist.title}\n\t\t\t\t\t\tdescription={data.playlist.description ?? ''}\n\t\t\t\t\t\tcover={data.playlist.coverUrl ?? ''}\n\t\t\t\t\t\tsubtitles={[\n\t\t\t\t\t\t\tdata.playlist.author.name,\n\t\t\t\t\t\t\t`${data.playlist.trackCount} 首歌曲`,\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tmainButtonIcon='check'\n\t\t\t\t\t\tmainButtonText='保存'\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t\trole='list'\n\t\t\t/>\n\n\t\t\t<ExternalPlaylistSyncFooter\n\t\t\t\tonSync={handleSync}\n\t\t\t\tsyncing={syncing}\n\t\t\t\tprogress={progress}\n\t\t\t\tetaSeconds={etaSeconds}\n\t\t\t\tbuttonText={syncButtonText}\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nconst ExternalPlaylistSyncFooter = ({\n\tonSync,\n\tsyncing,\n\tprogress,\n\tetaSeconds,\n\tbuttonText,\n}: {\n\tonSync: () => void\n\tsyncing: boolean\n\tprogress: number\n\tetaSeconds: number | null\n\tbuttonText: string\n}) => {\n\tconst theme = useTheme()\n\tconst insets = useSafeAreaInsets()\n\n\tconst etaText =\n\t\tetaSeconds !== null\n\t\t\t? etaSeconds > 60\n\t\t\t\t? `${(etaSeconds / 60).toFixed(1)} 分 (ETA)`\n\t\t\t\t: `${etaSeconds.toFixed(0)} 秒 (ETA)`\n\t\t\t: '计算中...'\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[\n\t\t\t\tstyles.footer,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: theme.colors.elevation.level2,\n\t\t\t\t\tpaddingBottom: insets.bottom + 16,\n\t\t\t\t},\n\t\t\t]}\n\t\t>\n\t\t\t<View style={styles.progressContainer}>\n\t\t\t\t{syncing ? (\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.syncingContainer,\n\t\t\t\t\t\t\t{ justifyContent: 'space-between', width: '100%' },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<View style={{ flexDirection: 'row', alignItems: 'center' }}>\n\t\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\t\tanimating={true}\n\t\t\t\t\t\t\t\tcolor={theme.colors.primary}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<View style={{ marginLeft: 12 }}>\n\t\t\t\t\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t\t\t\t\t正在匹配... {(progress * 100).toFixed(0)}%\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tstyle={{ color: theme.colors.outline }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t剩余 {etaText}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ticon='pause'\n\t\t\t\t\t\t\tmode='contained-tonal'\n\t\t\t\t\t\t\tonPress={onSync}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t暂停\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<TouchableRipple\n\t\t\t\t\t\tonPress={onSync}\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.button,\n\t\t\t\t\t\t\t{ backgroundColor: theme.colors.primaryContainer },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Text style={{ color: theme.colors.onPrimaryContainer }}>\n\t\t\t\t\t\t\t{buttonText}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</TouchableRipple>\n\t\t\t\t)}\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nexport default function ExternalPlaylistSyncPage() {\n\treturn (\n\t\t<ExternalPlaylistSyncStoreProvider>\n\t\t\t<ExternalPlaylistSyncPageInner />\n\t\t</ExternalPlaylistSyncStoreProvider>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tcenter: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\titemContainer: {\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 12,\n\t},\n\titemInner: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'flex-start',\n\t},\n\titemContent: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\tmarginLeft: 12,\n\t},\n\tstatusContainer: {\n\t\tmarginLeft: 8,\n\t\tminWidth: 60,\n\t\talignItems: 'flex-end',\n\t},\n\tfooter: {\n\t\tpadding: 16,\n\t\tborderTopLeftRadius: 16,\n\t\tborderTopRightRadius: 16,\n\t\televation: 4,\n\t\tshadowColor: '#000',\n\t\tshadowOffset: { width: 0, height: -2 },\n\t\tshadowOpacity: 0.1,\n\t\tshadowRadius: 4,\n\t},\n\tprogressContainer: {\n\t\talignItems: 'center',\n\t},\n\tsyncingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\theight: 48,\n\t},\n\tbutton: {\n\t\theight: 48,\n\t\tpaddingHorizontal: 32,\n\t\tborderRadius: 24,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/local/[id].tsx",
    "content": "import { DownloadState, Orpheus } from '@bbplayer/orpheus'\nimport type { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport { and, eq } from 'drizzle-orm'\nimport { useLiveQuery } from 'drizzle-orm/expo-sqlite'\nimport { useImage } from 'expo-image'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'\nimport { StyleSheet, View, useWindowDimensions } from 'react-native'\nimport {\n\tActivityIndicator,\n\tAppbar,\n\tMD3Theme,\n\tMenu,\n\tPortal,\n\tSearchbar,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedStyle,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport { alert } from '@/components/modals/AlertModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistHeader } from '@/features/playlist/local/components/LocalPlaylistHeader'\nimport { TrackListItem } from '@/features/playlist/local/components/LocalPlaylistItem'\nimport { LocalTrackList } from '@/features/playlist/local/components/LocalTrackList'\nimport { PlaylistError } from '@/features/playlist/local/components/PlaylistError'\nimport { SharedPlaylistMembersSheet } from '@/features/playlist/local/components/SharedPlaylistMembersSheet'\nimport { SyncFailuresSheet } from '@/features/playlist/local/components/SyncFailuresSheet'\nimport { useLocalPlaylistMenu } from '@/features/playlist/local/hooks/useLocalPlaylistMenu'\nimport { useLocalPlaylistPlayer } from '@/features/playlist/local/hooks/useLocalPlaylistPlayer'\nimport { useTrackSelection } from '@/features/playlist/local/hooks/useTrackSelection'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport {\n\tuseBatchDeleteTracksFromLocalPlaylist,\n\tuseDeletePlaylist,\n\tusePullSharedPlaylist,\n\tusePlaylistSync,\n\tuseReorderLocalPlaylistTrack,\n} from '@/hooks/mutations/db/playlist'\nimport {\n\tusePlaylistContentsInfinite,\n\tusePlaylistMetadata,\n\tuseSearchTracksInPlaylist,\n} from '@/hooks/queries/db/playlist'\nimport { useBatchDownloadStatus } from '@/hooks/queries/orpheus'\nimport { useSharedPlaylistMembers } from '@/hooks/queries/sharedPlaylistMembers'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { useIsActuallyOffline } from '@/hooks/utils/useIsActuallyOffline'\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { CustomError } from '@/lib/errors'\nimport type { Track } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport * as Haptics from '@/utils/haptics'\nimport { getInternalPlayUri } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nconst SEARCHBAR_HEIGHT = 72\nconst SCOPE = 'UI.Playlist.Local'\n\nconst SELECT_MODE_ITEM_HEIGHT = 69\n\n/** px from top/bottom edge of list container that triggers auto-scroll */\nconst EDGE_ZONE = 80\n/** px scrolled per auto-scroll tick (~16 ms) */\nconst SCROLL_SPEED = 8\n\nconst deletePlaylistDialogPrompt = (\n\tplaylistMetadata: ReturnType<typeof usePlaylistMetadata>['data'],\n\tcolors: MD3Theme['colors'],\n) => {\n\tif (!playlistMetadata || playlistMetadata.shareId === null)\n\t\treturn '确定要删除此播放列表吗？'\n\tswitch (playlistMetadata?.shareRole) {\n\t\tcase 'owner':\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Text>确定要删除此播放列表吗？</Text>\n\t\t\t\t\t<Text style={{ color: colors.error }}>\n\t\t\t\t\t\t同时所有订阅过该播放列表的人也会失去访问权限。\n\t\t\t\t\t</Text>\n\t\t\t\t</>\n\t\t\t)\n\t\tcase 'editor':\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Text>确定要删除此播放列表吗？</Text>\n\t\t\t\t\t<Text style={{ color: colors.error }}>\n\t\t\t\t\t\t同时你也会失去访问权限，下次需要由共享歌单的人再次邀请。\n\t\t\t\t\t</Text>\n\t\t\t\t</>\n\t\t\t)\n\t\tcase 'subscriber':\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Text>确定要删除此播放列表吗？</Text>\n\t\t\t\t\t<Text style={{ color: colors.error }}>\n\t\t\t\t\t\t同时你也会失去访问权限，下次需要由共享歌单的人再次邀请。\n\t\t\t\t\t</Text>\n\t\t\t\t</>\n\t\t\t)\n\t}\n\treturn <Text>确定要删除此播放列表吗？</Text>\n}\n\nexport default function LocalPlaylistPage() {\n\tconst { id } = useLocalSearchParams<{ id: string }>()\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\tconst dimensions = useWindowDimensions()\n\tconst [searchQuery, setSearchQuery] = useState('')\n\tconst [startSearch, setStartSearch] = useState(false)\n\tconst searchbarHeight = useSharedValue(0)\n\tconst deferredQuery = useDeferredValue(searchQuery)\n\tconst {\n\t\tselected,\n\t\tselectMode,\n\t\ttoggle,\n\t\tenterSelectMode,\n\t\texitSelectMode,\n\t\tsetSelected,\n\t} = useTrackSelection()\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<Track>()\n\tconst membersSheetRef = useRef<TrueSheet>(null)\n\tconst syncFailuresSheetRef = useRef<TrueSheet>(null)\n\n\tconst selection = {\n\t\tactive: selectMode,\n\t\tselected,\n\t\ttoggle,\n\t\tenter: enterSelectMode,\n\t}\n\tconst openModal = useModalStore((state) => state.open)\n\tconst [functionalMenuVisible, setFunctionalMenuVisible] = useState(false)\n\n\tconst {\n\t\tdata: playlistData,\n\t\tisPending: isPlaylistDataPending,\n\t\tisError: isPlaylistDataError,\n\t\tfetchNextPage: fetchNextPagePlaylistData,\n\t\thasNextPage: hasNextPagePlaylistData,\n\t\tisFetchingNextPage: isFetchingNextPagePlaylistData,\n\t} = usePlaylistContentsInfinite(Number(id), 30, 15)\n\tconst allLoadedTracks =\n\t\t(\n\t\t\tplaylistData?.pages as Array<{\n\t\t\t\ttracks: Track[]\n\t\t\t\tsortKeys: string[]\n\t\t\t\tnextPageFirstSortKey?: string\n\t\t\t}>\n\t\t)?.flatMap((page) => page.tracks) ?? []\n\t/** DB `sort_key` values parallel to allLoadedTracks (needed for reorder mutation) */\n\tconst allLoadedSortKeys =\n\t\t(\n\t\t\tplaylistData?.pages as Array<{\n\t\t\t\ttracks: Track[]\n\t\t\t\tsortKeys: string[]\n\t\t\t\tnextPageFirstSortKey?: string\n\t\t\t}>\n\t\t)?.flatMap((page) => page.sortKeys) ?? []\n\n\tconst isOffline = useIsActuallyOffline()\n\n\tconst loadedTrackKeys = allLoadedTracks.map((t) => t.uniqueKey)\n\tconst { data: downloadStatus } = useBatchDownloadStatus(loadedTrackKeys)\n\n\tconst playableOfflineKeys = (() => {\n\t\tif (!allLoadedTracks.length) return new Set<string>()\n\n\t\tconst keys = new Set<string>()\n\t\tconst urisToCheck: { uniqueKey: string; uri: string }[] = []\n\n\t\tfor (const track of allLoadedTracks) {\n\t\t\tif (track.source === 'local') {\n\t\t\t\tkeys.add(track.uniqueKey)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst uri = getInternalPlayUri(track)\n\t\t\tif (uri) {\n\t\t\t\turisToCheck.push({ uniqueKey: track.uniqueKey, uri })\n\t\t\t}\n\t\t}\n\n\t\tconst validUris = new Set(\n\t\t\tOrpheus.getLruCachedUris(urisToCheck.map((u) => u.uri)),\n\t\t)\n\t\tfor (const item of urisToCheck) {\n\t\t\tif (\n\t\t\t\tvalidUris.has(item.uri) ||\n\t\t\t\tdownloadStatus?.[item.uniqueKey] === DownloadState.COMPLETED\n\t\t\t) {\n\t\t\t\tkeys.add(item.uniqueKey)\n\t\t\t}\n\t\t}\n\t\treturn keys\n\t})()\n\n\tconst batchAddTracksModalPayloads = (() => {\n\t\tconst trackMap = new Map<number, Track>(\n\t\t\tallLoadedTracks.map((t) => [t.id, t]),\n\t\t)\n\t\tconst payloads = []\n\t\tfor (const trackId of selected) {\n\t\t\tconst track = trackMap.get(trackId)\n\t\t\tif (!track) continue\n\t\t\tpayloads.push({\n\t\t\t\ttrack: {\n\t\t\t\t\t...track,\n\t\t\t\t\tartistId: track.artist?.id,\n\t\t\t\t},\n\t\t\t\tartist: track.artist!,\n\t\t\t})\n\t\t}\n\t\treturn payloads\n\t})()\n\n\tconst {\n\t\tdata: searchData,\n\t\tisError: isSearchError,\n\t\terror: searchError,\n\t} = useSearchTracksInPlaylist(Number(id), deferredQuery, startSearch)\n\n\tconst finalPlaylistData = (() => {\n\t\tif (!startSearch || !deferredQuery.trim()) {\n\t\t\treturn allLoadedTracks\n\t\t}\n\n\t\tif (isSearchError) {\n\t\t\ttoastAndLogError('搜索失败', searchError, SCOPE)\n\t\t\treturn []\n\t\t}\n\n\t\treturn searchData ?? []\n\t})()\n\n\tconst {\n\t\tdata: playlistMetadata,\n\t\tisPending: isPlaylistMetadataPending,\n\t\tisError: isPlaylistMetadataError,\n\t} = usePlaylistMetadata(Number(id))\n\n\tconst shareMembers = useSharedPlaylistMembers(playlistMetadata?.shareId)\n\tconst isSharedSubscriber = playlistMetadata?.shareRole === 'subscriber'\n\n\tconst coverRef = useImage(playlistMetadata?.coverUrl ?? '', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { mutate: syncPlaylist } = usePlaylistSync()\n\tconst { mutate: deletePlaylist } = useDeletePlaylist()\n\tconst { mutate: deleteTrackFromLocalPlaylist } =\n\t\tuseBatchDeleteTracksFromLocalPlaylist()\n\tconst { mutate: reorderTrack } = useReorderLocalPlaylistTrack()\n\tconst { mutate: pullSharedPlaylist, isPending: isPullingShared } =\n\t\tusePullSharedPlaylist()\n\n\tconst handlePressShareMember = () => {\n\t\tif (playlistMetadata?.shareId) {\n\t\t\tvoid membersSheetRef.current?.present()\n\t\t}\n\t}\n\n\tconst onClickDeletePlaylist = () => {\n\t\tdeletePlaylist(\n\t\t\t{ playlistId: Number(id) },\n\t\t\t{ onSuccess: () => router.back() },\n\t\t)\n\t}\n\n\tconst handleSync = () => {\n\t\tif (!playlistMetadata || !playlistMetadata.remoteSyncId) {\n\t\t\ttoast.error(\n\t\t\t\t'无法同步，因为未找到播放列表元数据或\\u2009remoteSyncId\\u2009为空',\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tif (playlistMetadata.type === 'favorite') {\n\t\t\topenModal(\n\t\t\t\t'FavoriteSyncProgress',\n\t\t\t\t{\n\t\t\t\t\tfavoriteId: Number(playlistMetadata.remoteSyncId),\n\t\t\t\t\tshouldRedirectToLocalPlaylist: false,\n\t\t\t\t},\n\t\t\t\t{ dismissible: false },\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tconst toastId = 'sync-playlist'\n\t\ttoast.show('同步中...', { id: toastId, duration: Infinity })\n\t\tsyncPlaylist({\n\t\t\tremoteSyncId: playlistMetadata.remoteSyncId,\n\t\t\ttype: playlistMetadata.type,\n\t\t\ttoastId,\n\t\t})\n\t}\n\n\tconst { playAll, handleTrackPress } = useLocalPlaylistPlayer(\n\t\tNumber(id),\n\t\tisOffline,\n\t\tplayableOfflineKeys,\n\t)\n\tconst pullingIcon = useMemo(\n\t\t() => () => (\n\t\t\t<ActivityIndicator\n\t\t\t\tsize={18}\n\t\t\t\tanimating\n\t\t\t\tcolor={colors.primary}\n\t\t\t/>\n\t\t),\n\t\t[colors.primary],\n\t)\n\n\tconst deleteTrack = (trackId: number) => {\n\t\tdeleteTrackFromLocalPlaylist({\n\t\t\ttrackIds: [trackId],\n\t\t\tplaylistId: Number(id),\n\t\t})\n\t}\n\n\tconst trackMenuItems = useLocalPlaylistMenu({\n\t\tdeleteTrack,\n\t\topenAddToPlaylistModal: (track) =>\n\t\t\topenModal('UpdateTrackLocalPlaylists', { track }),\n\t\topenEditTrackModal: (track) => openModal('EditTrackMetadata', { track }),\n\t\tplaylist: playlistMetadata!,\n\t\tisReadOnly: isSharedSubscriber,\n\t})\n\n\tconst deleteSelectedTracks = () => {\n\t\tif (selected.size === 0) return\n\t\tdeleteTrackFromLocalPlaylist({\n\t\t\ttrackIds: Array.from(selected),\n\t\t\tplaylistId: Number(id),\n\t\t})\n\t\texitSelectMode()\n\t}\n\n\t/** 防止重复处理共享歌单被删除的场景 */\n\tconst handledRemoteDeletionRef = useRef(false)\n\n\tuseEffect(() => {\n\t\thandledRemoteDeletionRef.current = false\n\t}, [id])\n\n\tuseEffect(() => {\n\t\tif (typeof id !== 'string') {\n\t\t\trouter.replace('/+not-found')\n\t\t}\n\t}, [id, router])\n\n\tusePreventRemove(startSearch || selectMode, () => {\n\t\tif (startSearch) setStartSearch(false)\n\t\tif (selectMode) exitSelectMode()\n\t})\n\n\tuseEffect(() => {\n\t\tsearchbarHeight.set(\n\t\t\twithTiming(startSearch ? SEARCHBAR_HEIGHT : 0, { duration: 180 }),\n\t\t)\n\t}, [searchbarHeight, startSearch])\n\n\tuseEffect(() => {\n\t\tif (typeof id !== 'string') return\n\t\tif (!playlistMetadata?.shareId || !playlistMetadata.shareRole) return\n\t\tif (isOffline) return\n\t\tpullSharedPlaylist(\n\t\t\t{ playlistId: Number(id) },\n\t\t\t{\n\t\t\t\tonError: (error) => {\n\t\t\t\t\tif (\n\t\t\t\t\t\thandledRemoteDeletionRef.current ||\n\t\t\t\t\t\t!(error instanceof CustomError) ||\n\t\t\t\t\t\terror.type !== 'SharedPlaylistDeleted'\n\t\t\t\t\t) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\thandledRemoteDeletionRef.current = true\n\t\t\t\t\ttoast.error('共享者已删除该歌单，已为你移除本地副本')\n\t\t\t\t\tdeletePlaylist(\n\t\t\t\t\t\t{ playlistId: Number(id) },\n\t\t\t\t\t\t{ onSuccess: () => router.back() },\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}, [\n\t\tid,\n\t\tisOffline,\n\t\tplaylistMetadata?.shareId,\n\t\tplaylistMetadata?.shareRole,\n\t\thandledRemoteDeletionRef,\n\t\tdeletePlaylist,\n\t\trouter,\n\t\tpullSharedPlaylist,\n\t])\n\n\tconst { data: syncFailures } = useLiveQuery(\n\t\tdb\n\t\t\t.select({ id: schema.playlistSyncQueue.id })\n\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(schema.playlistSyncQueue.playlistId, playlistMetadata?.id ?? -1),\n\t\t\t\t\teq(schema.playlistSyncQueue.status, 'failed'),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.limit(1),\n\t)\n\tconst hasSyncFailures =\n\t\t!!playlistMetadata?.shareId && (syncFailures?.length ?? 0) > 0\n\n\tconst searchbarAnimatedStyle = useAnimatedStyle(() => ({\n\t\theight: searchbarHeight.value,\n\t}))\n\n\tconst [dragging, setDragging] = useState<{\n\t\ttrackIndex: number\n\t\ttrackId: number\n\t} | null>(null)\n\n\t/** Index AFTER which to show the insertion line (-1 = before item 0) */\n\tconst [insertAfterIndex, setInsertAfterIndex] = useState<number | null>(null)\n\n\t/** Ghost Y relative to the list container */\n\tconst ghostY = useSharedValue(0)\n\n\tconst dragOriginRef = useRef(0)\n\t/** Absolute screen Y of the top of the list container (from measureInWindow) */\n\tconst containerTopRef = useRef(0)\n\tconst containerHeightRef = useRef(0)\n\tconst listContainerRef = useRef<View>(null)\n\n\t/** Current FlashList scroll offset */\n\tconst scrollOffsetRef = useRef(0)\n\n\t/** Auto-scroll interval handle */\n\tconst autoScrollRef = useRef<ReturnType<typeof setInterval> | null>(null)\n\n\tconst stopAutoScroll = () => {\n\t\tif (autoScrollRef.current !== null) {\n\t\t\tclearInterval(autoScrollRef.current)\n\t\t\tautoScrollRef.current = null\n\t\t}\n\t}\n\n\t// 组件卸载时清理自动滚动定时器\n\tuseEffect(() => {\n\t\treturn () => stopAutoScroll()\n\t}, [])\n\n\tconst startAutoScroll = (direction: 'up' | 'down') => {\n\t\tstopAutoScroll()\n\t\tautoScrollRef.current = setInterval(() => {\n\t\t\tconst delta = direction === 'down' ? SCROLL_SPEED : -SCROLL_SPEED\n\t\t\tconst next = Math.max(0, scrollOffsetRef.current + delta)\n\t\t\tlistRef.current?.scrollToOffset({ offset: next, animated: false })\n\t\t\tscrollOffsetRef.current = next\n\t\t}, 16)\n\t}\n\n\tconst updateDragPosition = (absoluteY: number) => {\n\t\t// Ghost: center it on the finger relative to the container\n\t\tghostY.set(\n\t\t\tabsoluteY - containerTopRef.current - SELECT_MODE_ITEM_HEIGHT / 2,\n\t\t)\n\n\t\t// Insert index: use calibration so that item-0 touches the origin correctly\n\t\tconst hoverRel = absoluteY + scrollOffsetRef.current - dragOriginRef.current\n\t\tconst k = Math.floor(hoverRel / SELECT_MODE_ITEM_HEIGHT)\n\t\t// Upper/lower half of item k determines whether to insert before or after\n\t\tconst inItemFrac =\n\t\t\t(hoverRel - k * SELECT_MODE_ITEM_HEIGHT) / SELECT_MODE_ITEM_HEIGHT\n\t\tconst insertIdx = inItemFrac >= 0.5 ? k : k - 1\n\t\tsetInsertAfterIndex(\n\t\t\tMath.max(-1, Math.min(insertIdx, finalPlaylistData.length - 1)),\n\t\t)\n\n\t\t// Edge auto-scroll\n\t\tconst containerRelY = absoluteY - containerTopRef.current\n\t\tif (containerRelY < EDGE_ZONE) {\n\t\t\tstartAutoScroll('up')\n\t\t} else if (containerRelY > containerHeightRef.current - EDGE_ZONE) {\n\t\t\tstartAutoScroll('down')\n\t\t} else {\n\t\t\tstopAutoScroll()\n\t\t}\n\t}\n\n\tconst draggingRef = useRef(dragging)\n\tconst insertAfterIndexRef = useRef(insertAfterIndex)\n\tuseEffect(() => {\n\t\tdraggingRef.current = dragging\n\t}, [dragging])\n\tuseEffect(() => {\n\t\tinsertAfterIndexRef.current = insertAfterIndex\n\t}, [insertAfterIndex])\n\n\tconst handleDragStart = (\n\t\ttrackIndex: number,\n\t\ttrackId: number,\n\t\tabsoluteY: number,\n\t) => {\n\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Long_Press)\n\t\t// Calibrate: store the virtual Y-origin so item i is at origin + i * H\n\t\tdragOriginRef.current =\n\t\t\tabsoluteY + scrollOffsetRef.current - trackIndex * SELECT_MODE_ITEM_HEIGHT\n\t\tsetDragging({ trackIndex, trackId })\n\t\t// Ghost starts centered on the finger\n\t\tghostY.set(\n\t\t\tabsoluteY - containerTopRef.current - SELECT_MODE_ITEM_HEIGHT / 2,\n\t\t)\n\t\tsetInsertAfterIndex(trackIndex - 1)\n\t}\n\n\tconst handleDragUpdate = updateDragPosition\n\n\tconst handleDragEnd = () => {\n\t\tstopAutoScroll()\n\t\tconst currentDragging = draggingRef.current\n\t\tconst currentInsertAfterIndex = insertAfterIndexRef.current\n\n\t\tif (!currentDragging || currentInsertAfterIndex === null) {\n\t\t\tsetDragging(null)\n\t\t\tsetInsertAfterIndex(null)\n\t\t\treturn\n\t\t}\n\n\t\tconst { trackIndex, trackId } = currentDragging\n\n\t\t// Adjust target visual index based on drag direction\n\t\tconst targetVisualIndex =\n\t\t\tcurrentInsertAfterIndex >= trackIndex\n\t\t\t\t? currentInsertAfterIndex\n\t\t\t\t: currentInsertAfterIndex + 1\n\n\t\tif (targetVisualIndex !== trackIndex) {\n\t\t\tconst clamped = Math.max(\n\t\t\t\t0,\n\t\t\t\tMath.min(targetVisualIndex, finalPlaylistData.length - 1),\n\t\t\t)\n\t\t\t// 显示为 DESC 排序：index 0 = 最高 sort_key，index N-1 = 最低 sort_key\n\t\t\t// 向下拖（clamped > trackIndex）：新位置夹在 [clamped] 和 [clamped+1] 之间\n\t\t\t// 向上拖（clamped < trackIndex）：新位置夹在 [clamped-1] 和 [clamped] 之间\n\t\t\tlet prevSortKey: string | null\n\t\t\tlet nextSortKey: string | null\n\t\t\tif (targetVisualIndex > trackIndex) {\n\t\t\t\t// 向列表底部方向移动（sort_key 降低）\n\t\t\t\t// 如果已经到了加载的末尾，且还有下一页，那么 prevSortKey 应该是下一页的第一条的 key\n\t\t\t\tconst isAtEnd = clamped === allLoadedSortKeys.length - 1\n\t\t\t\tconst nextPageFirstSortKey =\n\t\t\t\t\tplaylistData?.pages[playlistData.pages.length - 1]\n\t\t\t\t\t\t?.nextPageFirstSortKey\n\t\t\t\tprevSortKey =\n\t\t\t\t\tallLoadedSortKeys[clamped + 1] ??\n\t\t\t\t\t(isAtEnd && hasNextPagePlaylistData ? nextPageFirstSortKey : null) ??\n\t\t\t\t\tnull\n\t\t\t\tnextSortKey = allLoadedSortKeys[clamped] ?? null\n\t\t\t} else {\n\t\t\t\t// 向列表顶部方向移动（sort_key 升高）\n\t\t\t\tprevSortKey = allLoadedSortKeys[clamped] ?? null\n\t\t\t\tnextSortKey = allLoadedSortKeys[clamped - 1] ?? null\n\t\t\t}\n\t\t\treorderTrack({\n\t\t\t\tplaylistId: Number(id),\n\t\t\t\ttrackId,\n\t\t\t\tprevSortKey,\n\t\t\t\tnextSortKey,\n\t\t\t})\n\t\t}\n\n\t\tsetDragging(null)\n\t\tsetInsertAfterIndex(null)\n\t}\n\n\tconst ghostAnimatedStyle = useAnimatedStyle(() => ({\n\t\ttransform: [{ translateY: ghostY.value }],\n\t}))\n\n\tconst draggedTrack =\n\t\tdragging !== null ? finalPlaylistData[dragging.trackIndex] : null\n\n\tif (typeof id !== 'string') return null\n\tif (isPlaylistDataPending || isPlaylistMetadataPending)\n\t\treturn <PlaylistPageSkeleton />\n\tif (isPlaylistDataError || isPlaylistMetadataError)\n\t\treturn <PlaylistError text='加载播放列表内容失败' />\n\tif (!playlistMetadata) return <PlaylistError text='未找到播放列表元数据' />\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: playlistMetadata.title\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(new Set(finalPlaylistData.map((t) => t.id)))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\tfinalPlaylistData\n\t\t\t\t\t\t\t\t\t\t\t.filter((t) => !selected.has(t.id))\n\t\t\t\t\t\t\t\t\t\t\t.map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{playlistMetadata.type === 'local' && (\n\t\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\t\ticon='trash-can'\n\t\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\t\talert(\n\t\t\t\t\t\t\t\t\t\t'移除歌曲',\n\t\t\t\t\t\t\t\t\t\t'确定从播放列表移除这些歌曲？',\n\t\t\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t\t\t\t\t\t{ text: '确定', onPress: deleteSelectedTracks },\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads: batchAddTracksModalPayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{playlistMetadata.shareId && hasSyncFailures && (\n\t\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\t\ticon='alert-circle'\n\t\t\t\t\t\t\t\tcolor={colors.error}\n\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\tvoid syncFailuresSheetRef.current?.present()\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\taccessibilityLabel='同步失败'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{isPullingShared && (\n\t\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\t\ticon={pullingIcon}\n\t\t\t\t\t\t\t\tdisabled\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon={startSearch ? 'close' : 'magnify'}\n\t\t\t\t\t\t\tonPress={() => setStartSearch((prev) => !prev)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='dots-vertical'\n\t\t\t\t\t\t\tonPress={() => setFunctionalMenuVisible(true)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t{/* 搜索框 */}\n\t\t\t<Animated.View\n\t\t\t\tstyle={[styles.searchbarContainer, searchbarAnimatedStyle]}\n\t\t\t>\n\t\t\t\t<Searchbar\n\t\t\t\t\tmode='view'\n\t\t\t\t\tplaceholder='搜索歌曲'\n\t\t\t\t\tonChangeText={setSearchQuery}\n\t\t\t\t\tvalue={searchQuery}\n\t\t\t\t/>\n\t\t\t</Animated.View>\n\n\t\t\t<View\n\t\t\t\tref={listContainerRef}\n\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t\tonLayout={() => {\n\t\t\t\t\tlistContainerRef.current?.measureInWindow((_x, y, _w, h) => {\n\t\t\t\t\t\tcontainerTopRef.current = y\n\t\t\t\t\t\tcontainerHeightRef.current = h\n\t\t\t\t\t})\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<LocalTrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\tisStale={searchQuery !== deferredQuery}\n\t\t\t\t\ttracks={finalPlaylistData ?? []}\n\t\t\t\t\tplaylist={playlistMetadata}\n\t\t\t\t\thandleTrackPress={handleTrackPress}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tisOffline={isOffline}\n\t\t\t\t\tisSearching={startSearch}\n\t\t\t\t\tplayableOfflineKeys={playableOfflineKeys}\n\t\t\t\t\tonEndReached={\n\t\t\t\t\t\thasNextPagePlaylistData &&\n\t\t\t\t\t\t!startSearch &&\n\t\t\t\t\t\t!isFetchingNextPagePlaylistData\n\t\t\t\t\t\t\t? () => fetchNextPagePlaylistData()\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tonDragStart={\n\t\t\t\t\t\tselectMode &&\n\t\t\t\t\t\tplaylistMetadata.type === 'local' &&\n\t\t\t\t\t\t!startSearch &&\n\t\t\t\t\t\t!isSharedSubscriber\n\t\t\t\t\t\t\t? handleDragStart\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tonDragUpdate={\n\t\t\t\t\t\tselectMode &&\n\t\t\t\t\t\tplaylistMetadata.type === 'local' &&\n\t\t\t\t\t\t!startSearch &&\n\t\t\t\t\t\t!isSharedSubscriber\n\t\t\t\t\t\t\t? handleDragUpdate\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tonDragEnd={\n\t\t\t\t\t\tselectMode &&\n\t\t\t\t\t\tplaylistMetadata.type === 'local' &&\n\t\t\t\t\t\t!startSearch &&\n\t\t\t\t\t\t!isSharedSubscriber\n\t\t\t\t\t\t\t? handleDragEnd\n\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tinsertAfterIndex={dragging !== null ? insertAfterIndex : null}\n\t\t\t\t\tonScroll={(e) => {\n\t\t\t\t\t\tscrollOffsetRef.current = e.nativeEvent.contentOffset.y\n\t\t\t\t\t}}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\tcoverRef={coverRef}\n\t\t\t\t\t\t\tplaylist={playlistMetadata}\n\t\t\t\t\t\t\ttotalDuration={playlistMetadata.totalDuration}\n\t\t\t\t\t\t\tonClickPlayAll={playAll}\n\t\t\t\t\t\t\tonClickSync={handleSync}\n\t\t\t\t\t\t\tonClickCopyToLocalPlaylist={() =>\n\t\t\t\t\t\t\t\topenModal('DuplicateLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tsourcePlaylistId: Number(id),\n\t\t\t\t\t\t\t\t\trawName: playlistMetadata.title,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tonPressAuthor={(author) =>\n\t\t\t\t\t\t\t\tauthor.remoteId &&\n\t\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\t\t\t\t\t\tparams: { mid: author.remoteId },\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tshareMembers={shareMembers}\n\t\t\t\t\t\t\tonPressShareMember={handlePressShareMember}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t/>\n\n\t\t\t\t{dragging !== null && draggedTrack && (\n\t\t\t\t\t<Animated.View\n\t\t\t\t\t\tpointerEvents='none'\n\t\t\t\t\t\tstyle={[styles.ghostContainer, ghostAnimatedStyle]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<View style={styles.ghostInner}>\n\t\t\t\t\t\t\t<TrackListItem\n\t\t\t\t\t\t\t\tindex={dragging.trackIndex}\n\t\t\t\t\t\t\t\tdata={draggedTrack}\n\t\t\t\t\t\t\t\tplaylist={playlistMetadata}\n\t\t\t\t\t\t\t\tselectMode={true}\n\t\t\t\t\t\t\t\tisSelected={false}\n\t\t\t\t\t\t\t\ttoggleSelected={() => void 0}\n\t\t\t\t\t\t\t\tenterSelectMode={() => void 0}\n\t\t\t\t\t\t\t\tonTrackPress={() => void 0}\n\t\t\t\t\t\t\t\tonMenuPress={() => void 0}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</Animated.View>\n\t\t\t\t)}\n\t\t\t</View>\n\n\t\t\t<Portal>\n\t\t\t\t<FunctionalMenu\n\t\t\t\t\tvisible={functionalMenuVisible}\n\t\t\t\t\tonDismiss={() => setFunctionalMenuVisible(false)}\n\t\t\t\t\tanchor={{\n\t\t\t\t\t\tx: dimensions.width - 10,\n\t\t\t\t\t\ty: 60 + insets.top,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{playlistMetadata.type === 'local' && !isSharedSubscriber && (\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetFunctionalMenuVisible(false)\n\t\t\t\t\t\t\t\tenterSelectMode()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='排序'\n\t\t\t\t\t\t\tleadingIcon='sort'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{!isSharedSubscriber && (\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetFunctionalMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('EditPlaylistMetadata', {\n\t\t\t\t\t\t\t\t\tplaylist: playlistMetadata,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='编辑播放列表信息'\n\t\t\t\t\t\t\tleadingIcon='pencil'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{playlistMetadata.type === 'local' &&\n\t\t\t\t\t\tplaylistMetadata.remoteSyncId === null &&\n\t\t\t\t\t\t!isSharedSubscriber && (\n\t\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\tsetFunctionalMenuVisible(false)\n\t\t\t\t\t\t\t\t\topenModal(\n\t\t\t\t\t\t\t\t\t\t'SyncLocalToBilibili',\n\t\t\t\t\t\t\t\t\t\t{ playlistId: Number(id) },\n\t\t\t\t\t\t\t\t\t\t{ dismissible: false },\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\ttitle='同步到 B 站'\n\t\t\t\t\t\t\t\tleadingIcon='sync'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t{playlistMetadata.type === 'local' && !playlistMetadata.shareId && (\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetFunctionalMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('EnableSharing', { playlistId: Number(id) })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='设为共享歌单'\n\t\t\t\t\t\t\tleadingIcon='share-variant'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{playlistMetadata.shareId && (\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetFunctionalMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('EnableSharing', {\n\t\t\t\t\t\t\t\t\tplaylistId: Number(id),\n\t\t\t\t\t\t\t\t\tshareId: playlistMetadata.shareId,\n\t\t\t\t\t\t\t\t\tshareRole: playlistMetadata.shareRole,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='共享设置'\n\t\t\t\t\t\t\tleadingIcon='link-variant'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tsetFunctionalMenuVisible(false)\n\t\t\t\t\t\t\talert(\n\t\t\t\t\t\t\t\t'删除播放列表',\n\t\t\t\t\t\t\t\tdeletePlaylistDialogPrompt(playlistMetadata, colors),\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t\t\t\t{ text: '确定', onPress: onClickDeletePlaylist },\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\ttitle='删除播放列表'\n\t\t\t\t\t\tleadingIcon='delete'\n\t\t\t\t\t\ttitleStyle={{ color: colors.error }}\n\t\t\t\t\t/>\n\t\t\t\t</FunctionalMenu>\n\t\t\t</Portal>\n\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\n\t\t\t<SharedPlaylistMembersSheet\n\t\t\t\tref={membersSheetRef}\n\t\t\t\tshareId={playlistMetadata?.shareId}\n\t\t\t/>\n\t\t\t<SyncFailuresSheet\n\t\t\t\tref={syncFailuresSheetRef}\n\t\t\t\tplaylistId={playlistMetadata?.id}\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: { flex: 1 },\n\tsearchbarContainer: { overflow: 'hidden' },\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n\tghostContainer: {\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n\tghostInner: {\n\t\topacity: 0.85,\n\t\televation: 8,\n\t\tshadowColor: '#000',\n\t\tshadowOffset: { width: 0, height: 4 },\n\t\tshadowOpacity: 0.3,\n\t\tshadowRadius: 6,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/recently/index.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { useCallback, useMemo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Appbar, Text, useTheme } from 'react-native-paper'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/local/components/PlaylistError'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { usePlaylistMenu } from '@/features/playlist/remote/hooks/usePlaylistMenu'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { useMostPlayedTracks } from '@/hooks/queries/playHistory'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport { addToQueue } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nexport default function RecentlyPlayedPage() {\n\tconst router = useRouter()\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tnull,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { selected, selectMode, toggle, enterSelectMode } = useTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\n\tconst { data: tracksData, isPending, isError } = useMostPlayedTracks(14, 10)\n\n\tconst tracks = useMemo(() => {\n\t\tif (!tracksData) return []\n\t\treturn tracksData.map((item) => item.track)\n\t}, [tracksData])\n\n\tconst playTrack = useCallback(\n\t\tasync (track: BilibiliTrack, playNext: boolean) => {\n\t\t\tawait addToQueue({\n\t\t\t\ttracks: [track],\n\t\t\t\tplayNow: false,\n\t\t\t\tclearQueue: false,\n\t\t\t\tplayNext: playNext,\n\t\t\t})\n\t\t},\n\t\t[],\n\t)\n\n\tconst handlePlay = useCallback(async (track: Track) => {\n\t\tawait addToQueue({\n\t\t\ttracks: [track],\n\t\t\tplayNow: true,\n\t\t\tclearQueue: false,\n\t\t\tstartFromKey: track.uniqueKey,\n\t\t\tplayNext: false,\n\t\t})\n\t}, [])\n\n\tconst handlePlayAll = useCallback(async () => {\n\t\tif (!tracksData) {\n\t\t\ttoast.error('没有可播放的歌曲')\n\t\t\treturn\n\t\t}\n\t\tconst tracks = tracksData.map((item) => item.track)\n\t\tawait addToQueue({\n\t\t\ttracks,\n\t\t\tplayNow: true,\n\t\t\tclearQueue: true,\n\t\t\tplayNext: false,\n\t\t})\n\t}, [tracksData])\n\n\tconst trackMenuItems = usePlaylistMenu(playTrack)\n\n\tconst bilibiliTracks = useMemo(() => {\n\t\treturn tracks as BilibiliTrack[]\n\t}, [tracks])\n\n\tif (isPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isError) {\n\t\treturn <PlaylistError text='加载失败' />\n\t}\n\n\tconst isEmpty = !tracksData || tracksData.length === 0\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='最近常听' />\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t{isEmpty ? (\n\t\t\t\t\t<View style={styles.emptyContainer}>\n\t\t\t\t\t\t<Text variant='bodyLarge'>暂无播放记录</Text>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<TrackList\n\t\t\t\t\t\ttracks={bilibiliTracks}\n\t\t\t\t\t\tplayTrack={handlePlay}\n\t\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\t\tselection={selection}\n\t\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\t\ttitle='最近常听'\n\t\t\t\t\t\t\t\tsubtitles='最近14天最常播放的歌曲'\n\t\t\t\t\t\t\t\tdescription={undefined}\n\t\t\t\t\t\t\t\tmainButtonIcon='play'\n\t\t\t\t\t\t\t\tmainButtonText='播放全部'\n\t\t\t\t\t\t\t\tid='recently-played'\n\t\t\t\t\t\t\t\tonClickMainButton={handlePlayAll}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</View>\n\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\temptyContainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/collection/[id].tsx",
    "content": "import { useImage } from 'expo-image'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Appbar, useTheme } from 'react-native-paper'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport useCheckLinkedToPlaylist from '@/features/playlist/remote/hooks/useCheckLinkedToLocalPlaylist'\nimport { usePlaylistMenu } from '@/features/playlist/remote/hooks/usePlaylistMenu'\nimport { useRemotePlaylist } from '@/features/playlist/remote/hooks/useRemotePlaylist'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { usePlaylistSync } from '@/hooks/mutations/db/playlist'\nimport { useCollectionAllContents } from '@/hooks/queries/bilibili/favorite'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport type { BilibiliMediaItemInCollection } from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport toast from '@/utils/toast'\n\nconst mapApiItemToTrack = (\n\tapiItem: BilibiliMediaItemInCollection,\n): BilibiliTrack => {\n\treturn {\n\t\tid: bv2av(apiItem.bvid),\n\t\tuniqueKey: `bilibili::${apiItem.bvid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: apiItem.title,\n\t\tartist: {\n\t\t\tid: apiItem.upper.mid,\n\t\t\tname: apiItem.upper.name,\n\t\t\tremoteId: apiItem.upper.mid.toString(),\n\t\t\tsource: 'bilibili',\n\t\t\tcreatedAt: new Date(apiItem.pubtime),\n\t\t\tupdatedAt: new Date(apiItem.pubtime),\n\t\t},\n\t\tcoverUrl: apiItem.cover,\n\t\tduration: apiItem.duration,\n\t\tcreatedAt: new Date(apiItem.pubtime),\n\t\tupdatedAt: new Date(apiItem.pubtime),\n\t\tbilibiliMetadata: {\n\t\t\tbvid: apiItem.bvid,\n\t\t\tcid: null,\n\t\t\tisMultiPage: false,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t}\n}\n\nexport default function CollectionPage() {\n\tconst router = useRouter()\n\tconst { id } = useLocalSearchParams<{ id: string }>()\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst linkedPlaylistId = useCheckLinkedToPlaylist(Number(id), 'collection')\n\n\tconst { selected, selectMode, toggle, enterSelectMode, setSelected } =\n\t\tuseTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst {\n\t\tdata: collectionData,\n\t\tisPending: isCollectionDataPending,\n\t\tisError: isCollectionDataError,\n\t\trefetch,\n\t} = useCollectionAllContents(Number(id))\n\tconst tracks = useMemo(\n\t\t() => collectionData?.medias?.map(mapApiItemToTrack) ?? [],\n\t\t[collectionData],\n\t)\n\n\tconst coverRef = useImage(collectionData?.info?.cover ?? '', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { playTrack } = useRemotePlaylist()\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst trackMenuItems = usePlaylistMenu(playTrack)\n\n\tconst { mutate: syncCollection } = usePlaylistSync()\n\n\tconst handleSync = useCallback(() => {\n\t\tconst toastId = 'sync-playlist'\n\t\ttoast.show('同步中...', { id: toastId, duration: Infinity })\n\t\tsetRefreshing(true)\n\t\tsyncCollection(\n\t\t\t{\n\t\t\t\tremoteSyncId: Number(id),\n\t\t\t\ttype: 'collection',\n\t\t\t\ttoastId,\n\t\t\t},\n\t\t\t{\n\t\t\t\tonSuccess: (id) => {\n\t\t\t\t\tif (!id) return\n\t\t\t\t\trouter.replace({\n\t\t\t\t\t\tpathname: '/playlist/local/[id]',\n\t\t\t\t\t\tparams: { id: String(id) },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\tsetRefreshing(false)\n\t}, [id, router, syncCollection])\n\n\tuseEffect(() => {\n\t\tif (typeof id !== 'string') {\n\t\t\trouter.replace('/+not-found')\n\t\t}\n\t}, [id, router])\n\n\tif (typeof id !== 'string') {\n\t\treturn null\n\t}\n\n\tif (isCollectionDataPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isCollectionDataError) {\n\t\treturn (\n\t\t\t<PlaylistError\n\t\t\t\ttext='加载收藏夹内容失败'\n\t\t\t\tonRetry={refetch}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: collectionData.info.title\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() => setSelected(new Set(tracks.map((t) => t.id)))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\ttracks.filter((t) => !selected.has(t.id)).map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = tracks.find((t) => t.id === id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={tracks}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\tcover={coverRef ?? undefined}\n\t\t\t\t\t\t\ttitle={collectionData.info.title}\n\t\t\t\t\t\t\tsubtitles={`${collectionData.info.upper.name}\\u2009•\\u2009${collectionData.info.media_count}\\u2009首歌曲`}\n\t\t\t\t\t\t\tdescription={collectionData.info.intro}\n\t\t\t\t\t\t\tonClickMainButton={handleSync}\n\t\t\t\t\t\t\tmainButtonIcon={'sync'}\n\t\t\t\t\t\t\tlinkedPlaylistId={linkedPlaylistId}\n\t\t\t\t\t\t\tid={id}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/favorite/[id].tsx",
    "content": "import { useImage } from 'expo-image'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Appbar, useTheme } from 'react-native-paper'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport useCheckLinkedToPlaylist from '@/features/playlist/remote/hooks/useCheckLinkedToLocalPlaylist'\nimport { usePlaylistMenu } from '@/features/playlist/remote/hooks/usePlaylistMenu'\nimport { useRemotePlaylist } from '@/features/playlist/remote/hooks/useRemotePlaylist'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { useInfiniteFavoriteList } from '@/hooks/queries/bilibili/favorite'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport type { BilibiliFavoriteListContent } from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport toast from '@/utils/toast'\n\nconst mapApiItemToTrack = (\n\tapiItem: BilibiliFavoriteListContent,\n): BilibiliTrack => {\n\treturn {\n\t\tid: bv2av(apiItem.bvid),\n\t\tuniqueKey: `bilibili::${apiItem.bvid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: apiItem.title,\n\t\tartist: {\n\t\t\tid: apiItem.upper.mid,\n\t\t\tname: apiItem.upper.name,\n\t\t\tremoteId: apiItem.upper.mid.toString(),\n\t\t\tsource: 'bilibili',\n\t\t\tavatarUrl: apiItem.upper.face,\n\t\t\tcreatedAt: new Date(apiItem.pubdate),\n\t\t\tupdatedAt: new Date(apiItem.pubdate),\n\t\t},\n\t\tcoverUrl: apiItem.cover,\n\t\tduration: apiItem.duration,\n\t\tcreatedAt: new Date(apiItem.pubdate),\n\t\tupdatedAt: new Date(apiItem.pubdate),\n\t\tbilibiliMetadata: {\n\t\t\tbvid: apiItem.bvid,\n\t\t\tcid: null,\n\t\t\tisMultiPage: false,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t}\n}\n\nexport default function FavoritePage() {\n\tconst { id } = useLocalSearchParams<{ id: string }>()\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst router = useRouter()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst linkedPlaylistId = useCheckLinkedToPlaylist(Number(id), 'favorite')\n\n\tconst { selected, selectMode, toggle, enterSelectMode, setSelected } =\n\t\tuseTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst {\n\t\tdata: favoriteData,\n\t\tisPending: isFavoriteDataPending,\n\t\tisError: isFavoriteDataError,\n\t\tfetchNextPage,\n\t\trefetch,\n\t\thasNextPage,\n\t\tisFetchingNextPage,\n\t} = useInfiniteFavoriteList(Number(id))\n\tconst tracks = useMemo(() => {\n\t\treturn (\n\t\t\tfavoriteData?.pages\n\t\t\t\t.flatMap((page) => page.medias ?? [])\n\t\t\t\t.map(mapApiItemToTrack) ?? []\n\t\t)\n\t}, [favoriteData])\n\n\tconst coverRef = useImage(favoriteData?.pages[0]?.info?.cover ?? '', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { playTrack } = useRemotePlaylist()\n\n\tconst trackMenuItems = usePlaylistMenu(playTrack)\n\n\tconst handleSync = useCallback(() => {\n\t\tif (favoriteData?.pages.flatMap((page) => page.medias).length === 0) {\n\t\t\ttoast.info('收藏夹为空，无需同步')\n\t\t\treturn\n\t\t}\n\n\t\topenModal(\n\t\t\t'FavoriteSyncProgress',\n\t\t\t{ favoriteId: Number(id), shouldRedirectToLocalPlaylist: true },\n\t\t\t{ dismissible: false },\n\t\t)\n\t}, [favoriteData?.pages, id, openModal])\n\n\tuseEffect(() => {\n\t\tif (typeof id !== 'string') {\n\t\t\trouter.replace('/+not-found')\n\t\t}\n\t}, [id, router])\n\n\tif (typeof id !== 'string') {\n\t\treturn null\n\t}\n\n\tif (isFavoriteDataPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isFavoriteDataError) {\n\t\treturn (\n\t\t\t<PlaylistError\n\t\t\t\ttext='加载收藏夹内容失败'\n\t\t\t\tonRetry={refetch}\n\t\t\t/>\n\t\t)\n\t}\n\n\tif (!favoriteData.pages[0].info) {\n\t\treturn (\n\t\t\t<PlaylistError\n\t\t\t\ttext='收藏夹信息无效或不存在'\n\t\t\t\tonRetry={refetch}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: favoriteData.pages[0].info.title\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() => setSelected(new Set(tracks.map((t) => t.id)))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\ttracks.filter((t) => !selected.has(t.id)).map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = tracks.find((t) => t.id === id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={tracks}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\tcover={coverRef ?? undefined}\n\t\t\t\t\t\t\ttitle={favoriteData.pages[0].info.title}\n\t\t\t\t\t\t\tsubtitles={`${favoriteData.pages[0].info.upper.name}\\u2009•\\u2009${favoriteData.pages[0].info.media_count}\\u2009首歌曲`}\n\t\t\t\t\t\t\tdescription={favoriteData.pages[0].info.intro}\n\t\t\t\t\t\t\tonClickMainButton={handleSync}\n\t\t\t\t\t\t\tmainButtonIcon={'sync'}\n\t\t\t\t\t\t\tlinkedPlaylistId={linkedPlaylistId}\n\t\t\t\t\t\t\tid={id}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\tonEndReached={hasNextPage ? () => fetchNextPage() : undefined}\n\t\t\t\t\tisFetchingNextPage={isFetchingNextPage}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/multipage/[bvid].tsx",
    "content": "import type { FlashListRef } from '@shopify/flash-list'\nimport { useImage } from 'expo-image'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Appbar, useTheme } from 'react-native-paper'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { FlashingTrackListItem } from '@/features/playlist/remote/components/FlashingTrackListItem'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport type { ExtraData } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport useCheckLinkedToPlaylist from '@/features/playlist/remote/hooks/useCheckLinkedToLocalPlaylist'\nimport { usePlaylistMenu } from '@/features/playlist/remote/hooks/usePlaylistMenu'\nimport { useRemotePlaylist } from '@/features/playlist/remote/hooks/useRemotePlaylist'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { usePlaylistSync } from '@/hooks/mutations/db/playlist'\nimport {\n\tuseGetMultiPageList,\n\tuseGetVideoDetails,\n} from '@/hooks/queries/bilibili/video'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport type {\n\tBilibiliMultipageVideo,\n\tBilibiliVideoDetails,\n} from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport * as Haptics from '@/utils/haptics'\nimport toast from '@/utils/toast'\n\nconst mapApiItemToTrack = (\n\tmp: BilibiliMultipageVideo,\n\tvideo: BilibiliVideoDetails,\n): BilibiliTrack => {\n\treturn {\n\t\tid: mp.cid,\n\t\tuniqueKey: `bilibili::${video.bvid}::${mp.cid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: mp.part,\n\t\tartist: {\n\t\t\tid: video.owner.mid,\n\t\t\tname: video.owner.name,\n\t\t\tremoteId: video.owner.mid.toString(),\n\t\t\tsource: 'bilibili',\n\t\t\tcreatedAt: new Date(video.pubdate),\n\t\t\tupdatedAt: new Date(video.pubdate),\n\t\t},\n\t\tcoverUrl: video.pic,\n\t\tduration: mp.duration,\n\t\tcreatedAt: new Date(video.pubdate),\n\t\tupdatedAt: new Date(video.pubdate),\n\t\tbilibiliMetadata: {\n\t\t\tbvid: video.bvid,\n\t\t\tcid: mp.cid,\n\t\t\tisMultiPage: true,\n\t\t\tvideoIsValid: true,\n\t\t\tmainTrackTitle: video.title,\n\t\t},\n\t}\n}\n\nexport default function MultipagePage() {\n\tconst router = useRouter()\n\tconst { bvid, cid } = useLocalSearchParams<{ bvid: string; cid?: string }>()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst linkedPlaylistId = useCheckLinkedToPlaylist(bv2av(bvid), 'multi_page')\n\n\tconst { selected, selectMode, toggle, enterSelectMode, setSelected } =\n\t\tuseTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst {\n\t\tdata: rawMultipageData,\n\t\tisPending: isMultipageDataPending,\n\t\tisError: isMultipageDataError,\n\t\trefetch,\n\t} = useGetMultiPageList(bvid)\n\n\tconst {\n\t\tdata: videoData,\n\t\tisError: isVideoDataError,\n\t\tisPending: isVideoDataPending,\n\t} = useGetVideoDetails(bvid)\n\n\tconst tracksData = useMemo(() => {\n\t\tif (!rawMultipageData || !videoData) {\n\t\t\treturn []\n\t\t}\n\t\treturn rawMultipageData.map((item) => mapApiItemToTrack(item, videoData))\n\t}, [rawMultipageData, videoData])\n\n\tconst coverRef = useImage(videoData?.pic ?? '', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { mutate: syncMultipage } = usePlaylistSync()\n\n\tconst { playTrack } = useRemotePlaylist()\n\tconst listRef = useRef<FlashListRef<BilibiliTrack>>(null)\n\tconst { handleDoubleTap } = useDoubleTapScrollToTop(listRef)\n\n\tconst trackMenuItems = usePlaylistMenu(playTrack)\n\n\tconst handleSync = useCallback(() => {\n\t\tconst toastId = 'sync-playlist'\n\t\ttoast.show('同步中...', { id: toastId, duration: Infinity })\n\t\tsetRefreshing(true)\n\t\tsyncMultipage(\n\t\t\t{\n\t\t\t\tremoteSyncId: bv2av(bvid),\n\t\t\t\ttype: 'multi_page',\n\t\t\t\ttoastId,\n\t\t\t},\n\t\t\t{\n\t\t\t\tonSuccess: (id) => {\n\t\t\t\t\tif (!id) return\n\t\t\t\t\trouter.replace({\n\t\t\t\t\t\tpathname: '/playlist/local/[id]',\n\t\t\t\t\t\tparams: { id: String(id) },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\tsetRefreshing(false)\n\t}, [bvid, router, syncMultipage])\n\n\tuseEffect(() => {\n\t\tif (tracksData.length > 0 && cid) {\n\t\t\tconst index = tracksData.findIndex((track) => String(track.id) === cid)\n\t\t\tif (index !== -1) {\n\t\t\t\t// 给一点延时给列表渲染\n\t\t\t\tconst timer = setTimeout(() => {\n\t\t\t\t\tvoid listRef.current?.scrollToIndex({\n\t\t\t\t\t\tindex,\n\t\t\t\t\t\tanimated: true,\n\t\t\t\t\t\tviewPosition: 0.5,\n\t\t\t\t\t})\n\t\t\t\t}, 500)\n\t\t\t\treturn () => {\n\t\t\t\t\tclearTimeout(timer)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [cid, tracksData])\n\n\tconst renderCustomItem = useCallback(\n\t\t({\n\t\t\titem,\n\t\t\tindex,\n\t\t\textraData,\n\t\t}: ListRenderItemInfoWithExtraData<BilibiliTrack, ExtraData>) => {\n\t\t\tif (!extraData) throw new Error('Extradata 不存在')\n\t\t\tconst {\n\t\t\t\tplayTrack: play,\n\t\t\t\thandleMenuPress,\n\t\t\t\tselection,\n\t\t\t\tshowItemCover,\n\t\t\t} = extraData\n\n\t\t\tconst shouldFlash = String(item.id) === cid\n\n\t\t\treturn (\n\t\t\t\t<FlashingTrackListItem\n\t\t\t\t\tshouldFlash={shouldFlash}\n\t\t\t\t\tindex={index}\n\t\t\t\t\tonTrackPress={() => play(item)}\n\t\t\t\t\tonMenuPress={(anchor) => handleMenuPress(item, anchor)}\n\t\t\t\t\tshowCoverImage={showItemCover ?? true}\n\t\t\t\t\tdata={{\n\t\t\t\t\t\tcover: item.coverUrl ?? undefined,\n\t\t\t\t\t\ttitle: item.title,\n\t\t\t\t\t\tduration: item.duration,\n\t\t\t\t\t\tid: item.id,\n\t\t\t\t\t\tartistName: item.artist?.name,\n\t\t\t\t\t\tuniqueKey: item.uniqueKey,\n\t\t\t\t\t\ttitleHtml: item.titleHtml,\n\t\t\t\t\t}}\n\t\t\t\t\ttoggleSelected={() => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Clock_Tick)\n\t\t\t\t\t\tselection.toggle(item.id)\n\t\t\t\t\t}}\n\t\t\t\t\tisSelected={selection.selected.has(item.id)}\n\t\t\t\t\tselectMode={selection.active}\n\t\t\t\t\tenterSelectMode={() => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Long_Press)\n\t\t\t\t\t\tselection.enter(item.id)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)\n\t\t},\n\t\t[cid],\n\t)\n\n\tuseEffect(() => {\n\t\tif (typeof bvid !== 'string') {\n\t\t\trouter.replace('/+not-found')\n\t\t}\n\t}, [bvid, router])\n\n\tif (typeof bvid !== 'string') {\n\t\treturn null\n\t}\n\n\tif (isMultipageDataPending || isVideoDataPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isMultipageDataError || isVideoDataError) {\n\t\treturn <PlaylistError text='加载失败' />\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: videoData.title\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() => setSelected(new Set(tracksData.map((t) => t.id)))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\ttracksData\n\t\t\t\t\t\t\t\t\t\t\t.filter((t) => !selected.has(t.id))\n\t\t\t\t\t\t\t\t\t\t\t.map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst trackMap = new Map(tracksData.map((t) => [t.id, t]))\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = trackMap.get(id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\trenderCustomItem={renderCustomItem}\n\t\t\t\t\ttracks={tracksData}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tshowItemCover={false}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\tcover={coverRef ?? undefined}\n\t\t\t\t\t\t\ttitle={videoData.title}\n\t\t\t\t\t\t\tsubtitles={`${videoData.owner.name}\\u2009•\\u2009${tracksData.length}\\u2009首歌曲`}\n\t\t\t\t\t\t\tdescription={videoData.desc}\n\t\t\t\t\t\t\tonClickMainButton={handleSync}\n\t\t\t\t\t\t\tmainButtonIcon={'sync'}\n\t\t\t\t\t\t\tlinkedPlaylistId={linkedPlaylistId}\n\t\t\t\t\t\t\tid={bv2av(bvid)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/search-result/fav/[query].tsx",
    "content": "import { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useMemo, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { ActivityIndicator, Appbar, Text, useTheme } from 'react-native-paper'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { useSearchInteractions } from '@/features/playlist/remote/search-result/hooks/useSearchInteractions'\nimport { PlaylistTrackListSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport {\n\tuseGetFavoritePlaylists,\n\tuseInfiniteSearchFavoriteItems,\n} from '@/hooks/queries/bilibili/favorite'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport type { BilibiliFavoriteListContent } from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\n\nconst mapApiItemToTrack = (\n\tapiItem: BilibiliFavoriteListContent,\n): BilibiliTrack => {\n\treturn {\n\t\tid: bv2av(apiItem.bvid),\n\t\tuniqueKey: `bilibili::${apiItem.bvid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: apiItem.title,\n\t\tartist: {\n\t\t\tid: apiItem.upper.mid,\n\t\t\tname: apiItem.upper.name,\n\t\t\tremoteId: apiItem.upper.mid.toString(),\n\t\t\tsource: 'bilibili',\n\t\t\tavatarUrl: apiItem.upper.face,\n\t\t\tcreatedAt: new Date(apiItem.pubdate),\n\t\t\tupdatedAt: new Date(apiItem.pubdate),\n\t\t},\n\t\tcoverUrl: apiItem.cover,\n\t\tduration: apiItem.duration,\n\t\tcreatedAt: new Date(apiItem.pubdate),\n\t\tupdatedAt: new Date(apiItem.pubdate),\n\t\tbilibiliMetadata: {\n\t\t\tbvid: apiItem.bvid,\n\t\t\tcid: null,\n\t\t\tisMultiPage: false,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t}\n}\n\nexport default function SearchResultsPage() {\n\tconst { colors } = useTheme()\n\tconst { query } = useLocalSearchParams<{ query: string }>()\n\tconst router = useRouter()\n\n\tconst { selected, selectMode, toggle, enterSelectMode, setSelected } =\n\t\tuseTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst { data: userData } = usePersonalInformation()\n\tconst { data: favoriteFolderList } = useGetFavoritePlaylists(userData?.mid)\n\tconst {\n\t\tdata: searchData,\n\t\tisPending: isPendingSearchData,\n\t\tisError: isErrorSearchData,\n\t\thasNextPage,\n\t\tfetchNextPage,\n\t\trefetch,\n\t} = useInfiniteSearchFavoriteItems(\n\t\t'all',\n\t\tquery,\n\t\tfavoriteFolderList?.at(0)?.id,\n\t)\n\tconst tracks = useMemo(\n\t\t() =>\n\t\t\tsearchData?.pages\n\t\t\t\t.flatMap((page) => page.medias ?? [])\n\t\t\t\t.map(mapApiItemToTrack) ?? [],\n\t\t[searchData],\n\t)\n\n\tconst { trackMenuItems, playTrack } = useSearchInteractions()\n\n\tif (isPendingSearchData) {\n\t\treturn <PlaylistTrackListSkeleton />\n\t}\n\n\tif (isErrorSearchData) {\n\t\treturn <PlaylistError text='加载失败' />\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: `搜索结果\\u2009-\\u2009${query}`\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() => setSelected(new Set(tracks.map((t) => t.id)))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\ttracks.filter((t) => !selected.has(t.id)).map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = tracks.find((t) => t.id === id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={tracks}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tonEndReached={hasNextPage ? () => fetchNextPage() : undefined}\n\t\t\t\t\thasNextPage={hasNextPage}\n\t\t\t\t\tListHeaderComponent={null}\n\t\t\t\t\tListFooterComponent={\n\t\t\t\t\t\thasNextPage ? (\n\t\t\t\t\t\t\t<View style={styles.footerLoadingContainer}>\n\t\t\t\t\t\t\t\t<ActivityIndicator size='small' />\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\tstyle={styles.footerText}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tstyle={[styles.emptyListText, { color: colors.onSurfaceVariant }]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t没有在收藏中找到与&thinsp;&ldquo;{query}&rdquo;&thinsp;相关的内容\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tfooterLoadingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tfooterText: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n\temptyListText: {\n\t\tpaddingVertical: 32,\n\t\ttextAlign: 'center',\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/search-result/global/[query].tsx",
    "content": "import { useLocalSearchParams, useRouter } from 'expo-router'\nimport { decode } from 'he'\nimport { useMemo, useEffect, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Appbar, Text, useTheme } from 'react-native-paper'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { useSearchInteractions } from '@/features/playlist/remote/search-result/hooks/useSearchInteractions'\nimport { PlaylistTrackListSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { useSearchResults } from '@/hooks/queries/bilibili/search'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport type { BilibiliSearchVideo } from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport { formatMMSSToSeconds } from '@/utils/time'\n\nconst mapApiItemToTrack = (apiItem: BilibiliSearchVideo): BilibiliTrack => {\n\treturn {\n\t\tid: apiItem.aid,\n\t\tuniqueKey: `bilibili::${apiItem.bvid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: apiItem.title.replace(/<em[^>]*>|<\\/em>/g, ''),\n\t\tartist: {\n\t\t\tid: apiItem.mid,\n\t\t\tname: apiItem.author,\n\t\t\tremoteId: apiItem.mid.toString(),\n\t\t\tsource: 'bilibili',\n\t\t\tcreatedAt: new Date(apiItem.senddate),\n\t\t\tupdatedAt: new Date(apiItem.senddate),\n\t\t},\n\t\tcoverUrl: `https:${apiItem.pic}`,\n\t\tduration: apiItem.duration ? formatMMSSToSeconds(apiItem.duration) : 0,\n\t\tcreatedAt: new Date(apiItem.senddate),\n\t\tupdatedAt: new Date(apiItem.senddate),\n\t\ttitleHtml: apiItem.title,\n\t\tbilibiliMetadata: {\n\t\t\tbvid: apiItem.bvid,\n\t\t\tcid: null,\n\t\t\tisMultiPage: false,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t}\n}\n\nexport default function SearchResultsPage() {\n\tconst { colors } = useTheme()\n\tconst { query } = useLocalSearchParams<{ query: string }>()\n\tconst router = useRouter()\n\n\tconst { selected, selectMode, toggle, enterSelectMode, setSelected } =\n\t\tuseTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst {\n\t\tdata: searchData,\n\t\tisPending: isPendingSearchData,\n\t\tisError: isErrorSearchData,\n\t\thasNextPage,\n\t\trefetch,\n\t\tfetchNextPage,\n\t} = useSearchResults(query)\n\n\tuseEffect(() => {\n\t\tif (query) {\n\t\t\tvoid analyticsService.logSearch('global')\n\t\t}\n\t}, [query])\n\n\tconst { trackMenuItems, playTrack } = useSearchInteractions()\n\n\tconst uniqueSearchData = useMemo(() => {\n\t\tif (!searchData?.pages) {\n\t\t\treturn []\n\t\t}\n\n\t\tconst allTracks = searchData.pages.flatMap((page) => page.result)\n\t\tconst uniqueMap = new Map(\n\t\t\tallTracks.map((track) => [\n\t\t\t\ttrack.bvid,\n\t\t\t\t{\n\t\t\t\t\t...track,\n\t\t\t\t\ttitle: decode(track.title),\n\t\t\t\t},\n\t\t\t]),\n\t\t)\n\t\tconst uniqueTracks = [...uniqueMap.values()]\n\t\treturn uniqueTracks.map(mapApiItemToTrack)\n\t}, [searchData])\n\n\tif (isPendingSearchData) {\n\t\treturn <PlaylistTrackListSkeleton />\n\t}\n\n\tif (isErrorSearchData) {\n\t\treturn <PlaylistError text='加载失败' />\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header elevated>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: `搜索结果\\u2009-\\u2009${query}`\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(new Set(uniqueSearchData.map((t) => t.id)))\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\tuniqueSearchData\n\t\t\t\t\t\t\t\t\t\t\t.filter((t) => !selected.has(t.id))\n\t\t\t\t\t\t\t\t\t\t\t.map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = uniqueSearchData.find((t) => t.id === id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={uniqueSearchData ?? []}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tonEndReached={hasNextPage ? () => fetchNextPage() : undefined}\n\t\t\t\t\thasNextPage={hasNextPage}\n\t\t\t\t\tListHeaderComponent={null}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tstyle={[styles.emptyListText, { color: colors.onSurfaceVariant }]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t没有找到与&thinsp;&ldquo;{query}&rdquo;&thinsp;相关的内容\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\temptyListText: {\n\t\tpaddingVertical: 32,\n\t\ttextAlign: 'center',\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/toview.tsx",
    "content": "import { useImage } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n\tRefreshControl,\n\tStyleSheet,\n\tuseWindowDimensions,\n\tView,\n} from 'react-native'\nimport { Appbar, Menu, Portal, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport { alert } from '@/components/modals/AlertModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { usePlaylistMenu } from '@/features/playlist/remote/hooks/usePlaylistMenu'\nimport { useRemotePlaylist } from '@/features/playlist/remote/hooks/useRemotePlaylist'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport renderToViewItem from '@/features/playlist/remote/toview/components/Item'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport {\n\tuseClearToViewVideoList,\n\tuseDeleteToViewVideo,\n} from '@/hooks/mutations/bilibili/video'\nimport { useGetToViewVideoList } from '@/hooks/queries/bilibili/video'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport { syncFacade } from '@/lib/facades/syncBilibiliPlaylist'\nimport type { BilibiliToViewVideoList } from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { reportErrorToSentry } from '@/utils/log'\nimport { addToQueue } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nconst mapApiItemToTrack = (\n\tapiItem: BilibiliToViewVideoList['list'][0],\n): BilibiliTrack & { progress: number } => {\n\treturn {\n\t\tid: bv2av(apiItem.bvid),\n\t\tuniqueKey: `bilibili::${apiItem.bvid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: apiItem.title,\n\t\tartist: {\n\t\t\tid: apiItem.owner.mid,\n\t\t\tname: apiItem.owner.name,\n\t\t\tremoteId: apiItem.owner.mid.toString(),\n\t\t\tsource: 'bilibili',\n\t\t\tavatarUrl: apiItem.owner.face,\n\t\t\tcreatedAt: new Date(apiItem.pubdate),\n\t\t\tupdatedAt: new Date(apiItem.pubdate),\n\t\t},\n\t\tcoverUrl: apiItem.pic,\n\t\tduration: apiItem.duration,\n\t\tcreatedAt: new Date(apiItem.pubdate),\n\t\tupdatedAt: new Date(apiItem.pubdate),\n\t\tbilibiliMetadata: {\n\t\t\tbvid: apiItem.bvid,\n\t\t\tcid: apiItem.cid,\n\t\t\tisMultiPage: false,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t\tprogress: apiItem.progress,\n\t}\n}\n\nexport default function ToViewPage() {\n\tconst router = useRouter()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst [menuVisiable, setMenuVisiable] = useState(false)\n\tconst insets = useSafeAreaInsets()\n\tconst dimensions = useWindowDimensions()\n\n\tconst coverRef = useImage('', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { selected, selectMode, toggle, enterSelectMode, setSelected } =\n\t\tuseTrackSelection()\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst {\n\t\tdata: rawToViewData,\n\t\tisPending: isToViewDataPending,\n\t\tisError: isToViewDataError,\n\t\trefetch,\n\t} = useGetToViewVideoList()\n\tconst { mutate: deleteToViewVideo } = useDeleteToViewVideo()\n\tconst { mutate: clearToViewVideoList } = useClearToViewVideoList()\n\n\tconst tracksData = useMemo(() => {\n\t\tif (!rawToViewData) {\n\t\t\treturn []\n\t\t}\n\t\treturn rawToViewData.list.map((item) => mapApiItemToTrack(item))\n\t}, [rawToViewData])\n\n\tconst { playTrack } = useRemotePlaylist()\n\n\tconst trackMenuItems = usePlaylistMenu(playTrack)\n\n\tconst handlePlay = useCallback(async (track: BilibiliTrack) => {\n\t\tconst createIt = await syncFacade.addTrackToLocal(track)\n\t\tif (createIt.isErr()) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'将 track 录入本地失败',\n\t\t\t\tcreateIt.error,\n\t\t\t\t'UI.Playlist.Remote',\n\t\t\t)\n\t\t\treportErrorToSentry(\n\t\t\t\tcreateIt.error,\n\t\t\t\t'将 track 录入本地失败',\n\t\t\t\t'UI.Playlist.Remote',\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tvoid addToQueue({\n\t\t\ttracks: [track],\n\t\t\tplayNow: true,\n\t\t\tclearQueue: false,\n\t\t\tstartFromKey: track.uniqueKey,\n\t\t\tplayNext: false,\n\t\t})\n\t}, [])\n\n\tconst handlePlayAll = useCallback(async () => {\n\t\tif (!tracksData || tracksData.length === 0) {\n\t\t\ttoast.error('没有可播放的歌曲')\n\t\t\treturn\n\t\t}\n\n\t\tawait addToQueue({\n\t\t\ttracks: tracksData,\n\t\t\tplayNow: true,\n\t\t\tclearQueue: true,\n\t\t\tplayNext: false,\n\t\t})\n\t}, [tracksData])\n\n\tif (isToViewDataPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isToViewDataError) {\n\t\treturn <PlaylistError text='加载失败' />\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode ? `已选择\\u2009${selected.size}\\u2009首` : '稍后再看'\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() => setSelected(new Set(tracksData.map((t) => t.id)))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\ttracksData\n\t\t\t\t\t\t\t\t\t\t\t.filter((t) => !selected.has(t.id))\n\t\t\t\t\t\t\t\t\t\t\t.map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst trackMap = new Map(tracksData.map((t) => [t.id, t]))\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = trackMap.get(id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t)}\n\t\t\t\t<Appbar.Action\n\t\t\t\t\ticon='dots-vertical'\n\t\t\t\t\tonPress={() => setMenuVisiable(true)}\n\t\t\t\t/>\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={tracksData}\n\t\t\t\t\tplayTrack={handlePlay}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\tcover={coverRef ?? undefined}\n\t\t\t\t\t\t\ttitle={'稍后再看'}\n\t\t\t\t\t\t\tsubtitles={`有\\u2009${tracksData.length}\\u2009首待播放的歌曲`}\n\t\t\t\t\t\t\tdescription={undefined}\n\t\t\t\t\t\t\tonClickMainButton={handlePlayAll}\n\t\t\t\t\t\t\tmainButtonIcon={'play'}\n\t\t\t\t\t\t\tlinkedPlaylistId={undefined}\n\t\t\t\t\t\t\tmainButtonText='播放全部'\n\t\t\t\t\t\t\tid={'稍后再看'}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any -- renderToViewItem 需要一个特化属性 progress，就用 any hack 一下\n\t\t\t\t\trenderCustomItem={renderToViewItem as any}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\n\t\t\t<Portal>\n\t\t\t\t<FunctionalMenu\n\t\t\t\t\tvisible={menuVisiable}\n\t\t\t\t\tonDismiss={() => setMenuVisiable(false)}\n\t\t\t\t\tanchor={{\n\t\t\t\t\t\tx: dimensions.width - 10,\n\t\t\t\t\t\ty: 60 + insets.top,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tsetMenuVisiable(false)\n\t\t\t\t\t\t\tdeleteToViewVideo({\n\t\t\t\t\t\t\t\tdeleteAllViewed: true,\n\t\t\t\t\t\t\t\tavid: undefined,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}}\n\t\t\t\t\t\ttitle='清除所有已播放歌曲'\n\t\t\t\t\t\tleadingIcon='trash-can'\n\t\t\t\t\t/>\n\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tsetMenuVisiable(false)\n\t\t\t\t\t\t\talert(\n\t\t\t\t\t\t\t\t'清除所有稍后再看歌曲',\n\t\t\t\t\t\t\t\t'确定要清除所有稍后再看的歌曲吗？',\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\ttext: '取消',\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t\t\t\t\tclearToViewVideoList()\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}}\n\t\t\t\t\t\ttitle='清除所有歌曲'\n\t\t\t\t\t\tleadingIcon='delete'\n\t\t\t\t\t\ttitleStyle={{ color: colors.error }}\n\t\t\t\t\t/>\n\t\t\t\t</FunctionalMenu>\n\t\t\t</Portal>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/playlist/remote/uploader/[mid].tsx",
    "content": "import { useImage } from 'expo-image'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useEffect, useMemo, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Appbar, Searchbar, Text, useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedStyle,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\n\nimport Button from '@/components/common/Button'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { PlaylistHeader } from '@/features/playlist/remote/components/PlaylistHeader'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { usePlaylistMenu } from '@/features/playlist/remote/hooks/usePlaylistMenu'\nimport { useRemotePlaylist } from '@/features/playlist/remote/hooks/useRemotePlaylist'\nimport { useTrackSelection } from '@/features/playlist/remote/hooks/useTrackSelection'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport {\n\tuseInfiniteGetUserUploadedVideos,\n\tuseOtherUserInfo,\n} from '@/hooks/queries/bilibili/user'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { useDebouncedValue } from '@/hooks/utils/useDebouncedValue'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport type {\n\tBilibiliUserInfo,\n\tBilibiliUserUploadedVideosResponse,\n} from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport { formatMMSSToSeconds } from '@/utils/time'\n\nconst SEARCHBAR_HEIGHT = 72\n\nconst mapApiItemToTrack = (\n\tapiItem: BilibiliUserUploadedVideosResponse['list']['vlist'][0],\n\tuploaderData: BilibiliUserInfo,\n): BilibiliTrack => {\n\treturn {\n\t\tid: bv2av(apiItem.bvid),\n\t\tuniqueKey: `bilibili::${apiItem.bvid}`,\n\t\tsource: 'bilibili',\n\t\ttitle: apiItem.title,\n\t\tartist: {\n\t\t\tid: uploaderData.mid,\n\t\t\tname: uploaderData.name,\n\t\t\tavatarUrl: uploaderData.face,\n\t\t\tsource: 'bilibili',\n\t\t\tremoteId: uploaderData.mid.toString(),\n\t\t\tcreatedAt: new Date(apiItem.created),\n\t\t\tupdatedAt: new Date(apiItem.created),\n\t\t},\n\t\tcoverUrl: apiItem.pic,\n\t\tduration: formatMMSSToSeconds(apiItem.length),\n\t\tbilibiliMetadata: {\n\t\t\tbvid: apiItem.bvid,\n\t\t\tcid: null,\n\t\t\tisMultiPage: false,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t\tcreatedAt: new Date(apiItem.created),\n\t\tupdatedAt: new Date(apiItem.created),\n\t}\n}\n\nexport default function UploaderPage() {\n\tconst { mid } = useLocalSearchParams<{ mid: string }>()\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst router = useRouter()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst enable = useAppStore((state) => state.hasBilibiliCookie())\n\n\tconst {\n\t\tselected,\n\t\tselectMode,\n\t\ttoggle,\n\t\tenterSelectMode,\n\t\texitSelectMode,\n\t\tsetSelected,\n\t} = useTrackSelection()\n\n\tconst selection = useMemo(\n\t\t() => ({\n\t\t\tactive: selectMode,\n\t\t\tselected,\n\t\t\ttoggle,\n\t\t\tenter: enterSelectMode,\n\t\t}),\n\t\t[selectMode, selected, toggle, enterSelectMode],\n\t)\n\n\tconst [searchQuery, setSearchQuery] = useState('')\n\tconst [startSearch, setStartSearch] = useState(false)\n\tconst searchbarHeight = useSharedValue(0)\n\tconst debouncedQuery = useDebouncedValue(searchQuery, 200)\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst searchbarAnimatedStyle = useAnimatedStyle(() => ({\n\t\theight: searchbarHeight.value,\n\t}))\n\n\tuseEffect(() => {\n\t\tsearchbarHeight.set(\n\t\t\twithTiming(startSearch ? SEARCHBAR_HEIGHT : 0, { duration: 180 }),\n\t\t)\n\t}, [searchbarHeight, startSearch])\n\n\tconst {\n\t\tdata: uploadedVideos,\n\t\tisPending: isUploadedVideosPending,\n\t\tisError: isUploadedVideosError,\n\t\tfetchNextPage,\n\t\trefetch,\n\t\thasNextPage,\n\t} = useInfiniteGetUserUploadedVideos(Number(mid), debouncedQuery)\n\n\tconst {\n\t\tdata: uploaderUserInfo,\n\t\tisPending: isUserInfoPending,\n\t\tisError: isUserInfoError,\n\t} = useOtherUserInfo(Number(mid))\n\n\tconst tracks = useMemo(() => {\n\t\tif (!uploadedVideos || !uploaderUserInfo) return []\n\t\treturn uploadedVideos.pages\n\t\t\t.flatMap((page) => page.list.vlist)\n\t\t\t.map((item) => mapApiItemToTrack(item, uploaderUserInfo))\n\t}, [uploadedVideos, uploaderUserInfo])\n\n\tconst coverRef = useImage(uploaderUserInfo?.face ?? '', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst { playTrack } = useRemotePlaylist()\n\n\tconst trackMenuItems = usePlaylistMenu(playTrack)\n\n\tuseEffect(() => {\n\t\tif (typeof mid !== 'string') {\n\t\t\trouter.replace('/+not-found')\n\t\t}\n\t}, [mid, router])\n\n\tusePreventRemove(startSearch || selectMode, () => {\n\t\tif (startSearch) setStartSearch(false)\n\t\tif (selectMode) exitSelectMode()\n\t})\n\n\tif (typeof mid !== 'string') {\n\t\treturn null\n\t}\n\n\tif (!enable) {\n\t\treturn (\n\t\t\t<View\n\t\t\t\tstyle={[styles.loginContainer, { backgroundColor: colors.background }]}\n\t\t\t>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\tstyle={styles.loginText}\n\t\t\t\t>\n\t\t\t\t\t登录{'\\u2009bilibili\\u2009'}账号后才能查看{'\\u2009up\\u2009'}主作品\n\t\t\t\t\t{'\\n\\n'}\n\t\t\t\t\t为什么：bilibili\n\t\t\t\t\t对访问用户个人空间和上传的视频接口有莫名其妙的风控校验\n\t\t\t\t</Text>\n\t\t\t\t<Button\n\t\t\t\t\tmode='contained'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\topenModal('QRCodeLogin', undefined)\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t登录\n\t\t\t\t</Button>\n\t\t\t</View>\n\t\t)\n\t}\n\n\tif (isUserInfoPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isUploadedVideosPending && !startSearch) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isUploadedVideosError || isUserInfoError) {\n\t\treturn <PlaylistError text='加载失败' />\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={\n\t\t\t\t\t\tselectMode\n\t\t\t\t\t\t\t? `已选择\\u2009${selected.size}\\u2009首`\n\t\t\t\t\t\t\t: uploaderUserInfo.name\n\t\t\t\t\t}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t{selectMode ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-all'\n\t\t\t\t\t\t\tonPress={() => setSelected(new Set(tracks.map((t) => t.id)))}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='select-compare'\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tsetSelected(\n\t\t\t\t\t\t\t\t\tnew Set(\n\t\t\t\t\t\t\t\t\t\ttracks.filter((t) => !selected.has(t.id)).map((t) => t.id),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tconst payloads = []\n\t\t\t\t\t\t\t\tfor (const id of selected) {\n\t\t\t\t\t\t\t\t\tconst track = tracks.find((t) => t.id === id)\n\t\t\t\t\t\t\t\t\tif (track) {\n\t\t\t\t\t\t\t\t\t\tpayloads.push({\n\t\t\t\t\t\t\t\t\t\t\ttrack: track as Track,\n\t\t\t\t\t\t\t\t\t\t\tartist: track.artist!,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\topenModal('BatchAddTracksToLocalPlaylist', {\n\t\t\t\t\t\t\t\t\tpayloads,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Appbar.Action\n\t\t\t\t\t\ticon={startSearch ? 'close' : 'magnify'}\n\t\t\t\t\t\tonPress={() => setStartSearch((prev) => !prev)}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</Appbar.Header>\n\n\t\t\t{/* 搜索框 */}\n\t\t\t<Animated.View\n\t\t\t\tstyle={[styles.searchbarContainer, searchbarAnimatedStyle]}\n\t\t\t>\n\t\t\t\t<Searchbar\n\t\t\t\t\tmode='view'\n\t\t\t\t\tplaceholder='搜索歌曲'\n\t\t\t\t\tonChangeText={setSearchQuery}\n\t\t\t\t\tvalue={searchQuery}\n\t\t\t\t/>\n\t\t\t</Animated.View>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={tracks ?? []}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<PlaylistHeader\n\t\t\t\t\t\t\tcover={coverRef ?? undefined}\n\t\t\t\t\t\t\ttitle={uploaderUserInfo.name}\n\t\t\t\t\t\t\tsubtitles={`${uploadedVideos?.pages[0].page.count ?? 0}\\u2009首歌曲`}\n\t\t\t\t\t\t\tdescription={uploaderUserInfo.sign}\n\t\t\t\t\t\t\tonClickMainButton={undefined}\n\t\t\t\t\t\t\tmainButtonIcon={'sync'}\n\t\t\t\t\t\t\tid={Number(mid)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\tonEndReached={hasNextPage ? () => fetchNextPage() : undefined}\n\t\t\t\t\thasNextPage={hasNextPage}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tloginContainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tgap: 16,\n\t\tpaddingHorizontal: 25,\n\t},\n\tloginText: {\n\t\ttextAlign: 'center',\n\t},\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tsearchbarContainer: {\n\t\toverflow: 'hidden',\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/settings/appearance.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { useState } from 'react'\nimport {\n\tPermissionsAndroid,\n\tPlatform,\n\tScrollView,\n\tStyleSheet,\n\tView,\n} from 'react-native'\nimport { Appbar, Checkbox, Switch, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport IconButton from '@/components/common/IconButton'\nimport { alert } from '@/components/modals/AlertModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport useAppStore from '@/hooks/stores/useAppStore'\n\nexport default function AppearanceSettingsPage() {\n\tconst router = useRouter()\n\tconst colors = useTheme().colors\n\tconst insets = useSafeAreaInsets()\n\tconst haveTrack = useCurrentTrack()\n\n\tconst playerBackgroundStyle = useAppStore(\n\t\t(state) => state.settings.playerBackgroundStyle,\n\t)\n\tconst nowPlayingBarStyle = useAppStore(\n\t\t(state) => state.settings.nowPlayingBarStyle,\n\t)\n\tconst enableSpectrumVisualizer = useAppStore(\n\t\t(state) => state.settings.enableSpectrumVisualizer,\n\t)\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\n\tconst [playerBGMenuVisible, setPlayerBGMenuVisible] = useState(false)\n\tconst [nowPlayerBarMenuVisible, setNowPlayerBarMenuVisible] = useState(false)\n\n\tconst setNowPlayingBarStyle = (style: 'float' | 'bottom') => {\n\t\tsetSettings({ nowPlayingBarStyle: style })\n\t\tsetNowPlayerBarMenuVisible(false)\n\t}\n\n\tconst setPlayerBackgroundStyle = (style: 'gradient' | 'md3') => {\n\t\tsetSettings({ playerBackgroundStyle: style })\n\t\tsetPlayerBGMenuVisible(false)\n\t}\n\n\tconst handleSpectrumToggle = () => {\n\t\tif (enableSpectrumVisualizer) {\n\t\t\tsetSettings({ enableSpectrumVisualizer: false })\n\t\t\treturn\n\t\t}\n\n\t\tif (Platform.OS === 'android') {\n\t\t\tvoid PermissionsAndroid.check(\n\t\t\t\tPermissionsAndroid.PERMISSIONS.RECORD_AUDIO,\n\t\t\t).then((hasPermission) => {\n\t\t\t\tif (hasPermission) {\n\t\t\t\t\tsetSettings({ enableSpectrumVisualizer: true })\n\t\t\t\t} else {\n\t\t\t\t\talert(\n\t\t\t\t\t\t'需要麦克风权限',\n\t\t\t\t\t\t'音频频谱功能需要访问麦克风以分析音频数据。这不会录制任何声音。\\n\\n开启后，封面将变为圆形。',\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: '确认',\n\t\t\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t\t\tvoid PermissionsAndroid.request(\n\t\t\t\t\t\t\t\t\t\tPermissionsAndroid.PERMISSIONS.RECORD_AUDIO,\n\t\t\t\t\t\t\t\t\t).then((granted) => {\n\t\t\t\t\t\t\t\t\t\tif (granted === PermissionsAndroid.RESULTS.GRANTED) {\n\t\t\t\t\t\t\t\t\t\t\tsetSettings({ enableSpectrumVisualizer: true })\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t})\n\t\t} else {\n\t\t\tsetSettings({ enableSpectrumVisualizer: true })\n\t\t}\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='外观设置' />\n\t\t\t</Appbar.Header>\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.scrollView}\n\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\tstyles.scrollContent,\n\t\t\t\t\t{ paddingBottom: insets.bottom + (haveTrack ? 70 + 20 : 20) },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<View style={styles.settingTextContainer}>\n\t\t\t\t\t\t<Text>显示音频频谱</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t开启后封面将变为圆形\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableSpectrumVisualizer}\n\t\t\t\t\t\tonValueChange={handleSpectrumToggle}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\n\t\t\t\t{Platform.OS === 'android' && (\n\t\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t\t<Text>选择底部播放条样式</Text>\n\t\t\t\t\t\t<FunctionalMenu\n\t\t\t\t\t\t\tvisible={nowPlayerBarMenuVisible}\n\t\t\t\t\t\t\tonDismiss={() => setNowPlayerBarMenuVisible(false)}\n\t\t\t\t\t\t\tanchor={\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='palette'\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tonPress={() => setNowPlayerBarMenuVisible(true)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\t\tlabel='悬浮（默认）'\n\t\t\t\t\t\t\t\tstatus={\n\t\t\t\t\t\t\t\t\tnowPlayingBarStyle === 'float' ? 'checked' : 'unchecked'\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tonPress={() => setNowPlayingBarStyle('float')}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\t\tlabel='沉浸'\n\t\t\t\t\t\t\t\tstatus={\n\t\t\t\t\t\t\t\t\tnowPlayingBarStyle === 'bottom' ? 'checked' : 'unchecked'\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tonPress={() => setNowPlayingBarStyle('bottom')}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</FunctionalMenu>\n\t\t\t\t\t</View>\n\t\t\t\t)}\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>选择播放器背景样式</Text>\n\t\t\t\t\t<FunctionalMenu\n\t\t\t\t\t\tvisible={playerBGMenuVisible}\n\t\t\t\t\t\tonDismiss={() => setPlayerBGMenuVisible(false)}\n\t\t\t\t\t\tanchor={\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon='palette'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\tonPress={() => setPlayerBGMenuVisible(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\tlabel='渐变'\n\t\t\t\t\t\t\tstatus={\n\t\t\t\t\t\t\t\tplayerBackgroundStyle === 'gradient' ? 'checked' : 'unchecked'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tonPress={() => setPlayerBackgroundStyle('gradient')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\tlabel='默认背景'\n\t\t\t\t\t\t\tstatus={playerBackgroundStyle === 'md3' ? 'checked' : 'unchecked'}\n\t\t\t\t\t\t\tonPress={() => setPlayerBackgroundStyle('md3')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</FunctionalMenu>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 25,\n\t},\n\tsettingRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 16,\n\t},\n\tsettingTextContainer: {\n\t\tflex: 1,\n\t\tmarginRight: 16,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/settings/donate.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { Appbar, List, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nexport default function DonateSettingsPage() {\n\tconst router = useRouter()\n\tconst colors = useTheme().colors\n\tconst insets = useSafeAreaInsets()\n\tconst openModal = useModalStore((state) => state.open)\n\tconst haveTrack = useCurrentTrack()\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='捐赠支持' />\n\t\t\t</Appbar.Header>\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.scrollView}\n\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\tstyles.scrollContent,\n\t\t\t\t\t{ paddingBottom: insets.bottom + (haveTrack ? 70 + 20 : 20) },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.introContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.introText}\n\t\t\t\t\t>\n\t\t\t\t\t\t如果觉得好用的话，欢迎给 Roitium 打赏！您的所有打赏都将用于让\n\t\t\t\t\t\tRoitium 吃顿疯狂星期四或是买一部 GalGame！ 😋\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t\t<List.Item\n\t\t\t\t\ttitle='微信支付'\n\t\t\t\t\tdescription='点击显示收款码'\n\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\ticon='wechat'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\tonPress={() => openModal('DonationQR', { type: 'wechat' })}\n\t\t\t\t/>\n\t\t\t\t<List.Item\n\t\t\t\t\ttitle='支付宝'\n\t\t\t\t\tdescription='点击显示收款码'\n\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\ticon='wallet'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\tright={(props) => (\n\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\ticon='chevron-right'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\tonPress={() => openModal('DonationQR', { type: 'alipay' })}\n\t\t\t\t/>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 16,\n\t},\n\tintroContainer: {\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 20,\n\t\talignItems: 'center',\n\t},\n\tintroText: {\n\t\ttextAlign: 'center',\n\t\tlineHeight: 24,\n\t\topacity: 0.8,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/settings/download.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { useState } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { Appbar, Checkbox, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport IconButton from '@/components/common/IconButton'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport useAppStore from '@/hooks/stores/useAppStore'\n\nconst DOWNLOAD_PARALLEL_OPTIONS = [\n\t{ value: 1, label: '1 个（稳妥）' },\n\t{ value: 2, label: '2 个' },\n\t{ value: 3, label: '3 个' },\n\t{ value: 6, label: '6 个（最快）' },\n] as const\n\nexport default function DownloadSettingsPage() {\n\tconst router = useRouter()\n\tconst colors = useTheme().colors\n\tconst insets = useSafeAreaInsets()\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\tconst haveTrack = useCurrentTrack()\n\n\tconst downloadMaxParallelTasks = useAppStore(\n\t\t(state) => state.settings.downloadMaxParallelTasks,\n\t)\n\n\tconst [downloadParallelMenuVisible, setDownloadParallelMenuVisible] =\n\t\tuseState(false)\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='下载设置' />\n\t\t\t</Appbar.Header>\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.scrollView}\n\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\tstyles.scrollContent,\n\t\t\t\t\t{ paddingBottom: insets.bottom + (haveTrack ? 70 + 20 : 20) },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<View style={styles.settingTextContainer}>\n\t\t\t\t\t\t<Text>同时下载数量</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t当前 {downloadMaxParallelTasks} 个\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t\t<FunctionalMenu\n\t\t\t\t\t\tvisible={downloadParallelMenuVisible}\n\t\t\t\t\t\tonDismiss={() => setDownloadParallelMenuVisible(false)}\n\t\t\t\t\t\tanchor={\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon='download-multiple'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\tonPress={() => setDownloadParallelMenuVisible(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t{DOWNLOAD_PARALLEL_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\t\tkey={option.value}\n\t\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\t\tlabel={option.label}\n\t\t\t\t\t\t\t\tstatus={\n\t\t\t\t\t\t\t\t\tdownloadMaxParallelTasks === option.value\n\t\t\t\t\t\t\t\t\t\t? 'checked'\n\t\t\t\t\t\t\t\t\t\t: 'unchecked'\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\tsetSettings({ downloadMaxParallelTasks: option.value })\n\t\t\t\t\t\t\t\t\tsetDownloadParallelMenuVisible(false)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</FunctionalMenu>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 25,\n\t},\n\tsettingRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 16,\n\t},\n\tsettingTextContainer: {\n\t\tflex: 1,\n\t\tmarginRight: 16,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/settings/general.tsx",
    "content": "import * as FileSystem from 'expo-file-system'\nimport { Image } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport * as Sharing from 'expo-sharing'\nimport { useRef, useState } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { Appbar, Switch, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport IconButton from '@/components/common/IconButton'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { checkForAppUpdate } from '@/lib/services/updateService'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nexport default function GeneralSettingsPage() {\n\tconst router = useRouter()\n\tconst colors = useTheme().colors\n\tconst insets = useSafeAreaInsets()\n\tconst openModal = useModalStore((state) => state.open)\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\tconst haveTrack = useCurrentTrack()\n\n\tconst sendPlayHistory = useAppStore((state) => state.settings.sendPlayHistory)\n\n\tconst setEnableDataCollection = useAppStore(\n\t\t(state) => state.setEnableDataCollection,\n\t)\n\tconst enableDataCollection = useAppStore(\n\t\t(state) => state.settings.enableDataCollection,\n\t)\n\n\tconst setEnableDebugLog = useAppStore((state) => state.setEnableDebugLog)\n\tconst enableDebugLog = useAppStore((state) => state.settings.enableDebugLog)\n\n\tconst [isCheckingForUpdate, setIsCheckingForUpdate] = useState(false)\n\n\tconst handleCheckForUpdate = async () => {\n\t\tsetIsCheckingForUpdate(true)\n\t\ttry {\n\t\t\tconst result = await checkForAppUpdate()\n\t\t\tif (result.isErr()) {\n\t\t\t\ttoast.error('检查更新失败', { description: result.error.message })\n\t\t\t\tsetIsCheckingForUpdate(false)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst { update } = result.value\n\t\t\tif (update) {\n\t\t\t\tif (update.forced) {\n\t\t\t\t\topenModal('UpdateApp', update, { dismissible: false })\n\t\t\t\t} else {\n\t\t\t\t\topenModal('UpdateApp', update)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ttoast.success('已是最新版本')\n\t\t\t}\n\t\t} catch (e) {\n\t\t\ttoast.error('检查更新时发生未知错误', { description: String(e) })\n\t\t}\n\t\tsetIsCheckingForUpdate(false)\n\t}\n\n\tconst [isSharing, setIsSharing] = useState(false)\n\tconst isSharingRef = useRef(false)\n\n\tconst shareLogFile = () => {\n\t\tif (isSharingRef.current) return\n\t\tisSharingRef.current = true\n\t\tsetIsSharing(true)\n\t\tvoid performShareLog(setIsSharing, isSharingRef)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='通用设置' />\n\t\t\t</Appbar.Header>\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.scrollView}\n\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\tstyles.scrollContent,\n\t\t\t\t\t{ paddingBottom: insets.bottom + (haveTrack ? 70 + 20 : 20) },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>向{'\\u2009Bilibili\\u2009'}上报观看进度</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={sendPlayHistory}\n\t\t\t\t\t\tonValueChange={() =>\n\t\t\t\t\t\t\tsetSettings({ sendPlayHistory: !sendPlayHistory })\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>分享数据（崩溃报告 & 匿名统计）</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableDataCollection}\n\t\t\t\t\t\tonValueChange={setEnableDataCollection}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>打开{'\\u2009Debug\\u2009'}日志</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableDebugLog}\n\t\t\t\t\t\tonValueChange={setEnableDebugLog}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>手动设置{'\\u2009Cookie'}</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='open-in-new'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() => openModal('CookieLogin', undefined)}\n\t\t\t\t\t\ttestID='cookie-login-button'\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>重新扫码登录</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='open-in-new'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() => openModal('QRCodeLogin', undefined)}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>手机号登录</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='open-in-new'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() => openModal('PhoneLogin', undefined)}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>分享今日运行日志</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='share-variant'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={shareLogFile}\n\t\t\t\t\t\tloading={isSharing}\n\t\t\t\t\t\tdisabled={isSharing}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>检查更新</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='update'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tloading={isCheckingForUpdate}\n\t\t\t\t\t\tonPress={handleCheckForUpdate}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>下载缺失封面</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='image-sync'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() => openModal('CoverDownloadProgress', undefined)}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>清空图片缓存</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='image-remove'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tawait Image.clearDiskCache()\n\t\t\t\t\t\t\t\tawait Image.clearMemoryCache()\n\t\t\t\t\t\t\t\ttoast.success('已清空图片缓存')\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\ttoastAndLogError('清空图片缓存失败', e, 'UI.Settings.General')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>开发者页面</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='open-in-new'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() => router.push('/test')}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nasync function performShareLog(\n\tsetIsSharing: (v: boolean) => void,\n\tisSharingRef: { current: boolean },\n) {\n\ttry {\n\t\tconst d = new Date()\n\t\tconst dateString = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`\n\t\tconst file = new FileSystem.File(\n\t\t\tFileSystem.Paths.document,\n\t\t\t'logs',\n\t\t\t`${dateString}.log`,\n\t\t)\n\t\tif (file.exists) {\n\t\t\tawait Sharing.shareAsync(file.uri)\n\t\t} else {\n\t\t\ttoastAndLogError('', new Error('无法分享日志：未找到日志文件'), 'UI.Test')\n\t\t}\n\t} catch (e) {\n\t\ttoastAndLogError('', e, 'UI.Settings')\n\t} finally {\n\t\tsetIsSharing(false)\n\t\tisSharingRef.current = false\n\t}\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 25,\n\t},\n\tsettingRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 16,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/settings/lyrics.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useFocusEffect, useRouter } from 'expo-router'\nimport * as WebBrowser from 'expo-web-browser'\nimport { useCallback, useEffect, useState } from 'react'\nimport { AppState, Platform, ScrollView, StyleSheet, View } from 'react-native'\nimport { Appbar, Checkbox, Switch, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport IconButton from '@/components/common/IconButton'\nimport { alert } from '@/components/modals/AlertModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useAppStore } from '@/hooks/stores/useAppStore'\nimport lyricService from '@/lib/services/lyricService'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nexport default function LyricsSettingsPage() {\n\tconst router = useRouter()\n\tconst colors = useTheme().colors\n\tconst insets = useSafeAreaInsets()\n\tconst haveTrack = useCurrentTrack()\n\n\tconst [isDesktopLyricsShown, setIsDesktopLyricsShown] = useState(\n\t\tOrpheus.isDesktopLyricsShown,\n\t)\n\tconst [isDesktopLyricsLocked, setIsDesktopLyricsLocked] = useState(\n\t\tOrpheus.isDesktopLyricsLocked,\n\t)\n\tconst [isStatusBarLyricsEnabled, setIsStatusBarLyricsEnabled] = useState(\n\t\tOrpheus.isStatusBarLyricsEnabled,\n\t)\n\tconst [isCarLyricsEnabled, setIsCarLyricsEnabled] = useState(\n\t\tOrpheus.isCarLyricsEnabled,\n\t)\n\tconst [isSuperLyricApiEnabled, setIsSuperLyricApiEnabled] = useState(\n\t\tOrpheus.isSuperLyricApiEnabled,\n\t)\n\tconst [isLyriconApiEnabled, setIsLyriconApiEnabled] = useState(\n\t\tOrpheus.isLyriconApiEnabled,\n\t)\n\tconst [statusBarLyricsProvider, setStatusBarLyricsProvider] = useState(\n\t\tOrpheus.statusBarLyricsProvider ?? 'lyricon',\n\t)\n\n\tconst lyricSource = useAppStore((state) => state.settings.lyricSource)\n\tconst enableVerbatimLyrics = useAppStore(\n\t\t(state) => state.settings.enableVerbatimLyrics,\n\t)\n\tconst enableOldSchoolStyleLyric = useAppStore(\n\t\t(state) => state.settings.enableOldSchoolStyleLyric,\n\t)\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\n\tconst [lyricSourceMenuVisible, setLyricSourceMenuVisible] = useState(false)\n\tconst [providerMenuVisible, setProviderMenuVisible] = useState(false)\n\n\tconst isStatusBarLyricsProviderActive =\n\t\tstatusBarLyricsProvider === 'lyricon'\n\t\t\t? isLyriconApiEnabled\n\t\t\t: isSuperLyricApiEnabled\n\n\tconst syncStates = useCallback(async () => {\n\t\tconst hasPermission = await Orpheus.checkOverlayPermission()\n\t\t// UI 开关仅在「设置开启」且「有权限」时显示为 ON\n\t\tsetIsDesktopLyricsShown(Orpheus.isDesktopLyricsShown && hasPermission)\n\t\tsetIsDesktopLyricsLocked(Orpheus.isDesktopLyricsLocked)\n\t\tsetIsStatusBarLyricsEnabled(Orpheus.isStatusBarLyricsEnabled)\n\t\tsetIsCarLyricsEnabled(Orpheus.isCarLyricsEnabled)\n\t\tsetIsSuperLyricApiEnabled(Orpheus.isSuperLyricApiEnabled)\n\t\tsetIsLyriconApiEnabled(Orpheus.isLyriconApiEnabled)\n\t\tsetStatusBarLyricsProvider(Orpheus.statusBarLyricsProvider ?? 'lyricon')\n\t}, [])\n\n\tconst enableDesktopLyrics = async () => {\n\t\ttry {\n\t\t\tconst hasPermission = await Orpheus.checkOverlayPermission()\n\t\t\tif (hasPermission) {\n\t\t\t\tawait Orpheus.showDesktopLyrics()\n\t\t\t\tvoid syncStates()\n\t\t\t\t// 立即推送当前正在播放的歌词，不等下一首\n\t\t\t\tconst currentTrack = await Orpheus.getCurrentTrack()\n\t\t\t\tif (currentTrack) {\n\t\t\t\t\tlyricService.pushLyricsToOverlays(currentTrack.id)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\talert(\n\t\t\t\t'桌面歌词',\n\t\t\t\t'启用桌面歌词需要启用悬浮窗权限。跳转到设置后，请找到 BBPlayer，并允许显示悬浮窗',\n\t\t\t\t[\n\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t{\n\t\t\t\t\t\ttext: '去设置',\n\t\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\t\tawait Orpheus.requestOverlayPermission()\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t{ cancelable: true },\n\t\t\t)\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('设置桌面歌词失败', e, 'Settings')\n\t\t}\n\t}\n\n\tuseEffect(() => {\n\t\tconst listener = AppState.addEventListener('change', (state) => {\n\t\t\tif (state === 'active') {\n\t\t\t\tvoid syncStates()\n\t\t\t}\n\t\t})\n\n\t\tconst statusListener = Orpheus.addListener(\n\t\t\t'onStatusBarLyricsStatusChanged',\n\t\t\t() => {\n\t\t\t\tvoid syncStates()\n\t\t\t},\n\t\t)\n\n\t\treturn () => {\n\t\t\tlistener.remove()\n\t\t\tstatusListener.remove()\n\t\t}\n\t}, [syncStates])\n\n\tuseFocusEffect(\n\t\tuseCallback(() => {\n\t\t\tvoid syncStates()\n\t\t}, [syncStates]),\n\t)\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='歌词设置' />\n\t\t\t</Appbar.Header>\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.scrollView}\n\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\tstyles.scrollContent,\n\t\t\t\t\t{ paddingBottom: insets.bottom + (haveTrack ? 70 + 20 : 20) },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>显示逐字歌词</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableVerbatimLyrics}\n\t\t\t\t\t\tonValueChange={() =>\n\t\t\t\t\t\t\tsetSettings({ enableVerbatimLyrics: !enableVerbatimLyrics })\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>恢复旧版歌词样式</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableOldSchoolStyleLyric}\n\t\t\t\t\t\tonValueChange={() =>\n\t\t\t\t\t\t\tsetSettings({\n\t\t\t\t\t\t\t\tenableOldSchoolStyleLyric: !enableOldSchoolStyleLyric,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t{Platform.OS === 'android' && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t\t\t<Text>桌面歌词</Text>\n\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\tvalue={isDesktopLyricsShown}\n\t\t\t\t\t\t\t\tonValueChange={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\t// 如果当前视觉上是开着的，点击必定是想关掉\n\t\t\t\t\t\t\t\t\t\tif (isDesktopLyricsShown) {\n\t\t\t\t\t\t\t\t\t\t\tawait Orpheus.hideDesktopLyrics()\n\t\t\t\t\t\t\t\t\t\t\tvoid syncStates()\n\t\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\t\t// 如果当前视觉上是关着的（可能是没权限，也可能是设置就是关的）\n\t\t\t\t\t\t\t\t\t\t\t// 我们统一走 enable 流程（含权限检查）\n\t\t\t\t\t\t\t\t\t\t\tawait enableDesktopLyrics()\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t\t\t<Text>桌面歌词锁定</Text>\n\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\tvalue={isDesktopLyricsLocked}\n\t\t\t\t\t\t\t\tonValueChange={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tOrpheus.isDesktopLyricsLocked = !isDesktopLyricsLocked\n\t\t\t\t\t\t\t\t\t\tawait syncStates()\n\t\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t\t\t<View style={{ flex: 1, marginRight: 16 }}>\n\t\t\t\t\t\t\t\t<Text>车载歌词</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tfontSize: 12,\n\t\t\t\t\t\t\t\t\t\topacity: 0.55,\n\t\t\t\t\t\t\t\t\t\tmarginTop: 4,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t启用后会把当前歌词显示到媒体信息的标题部分\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\tvalue={isCarLyricsEnabled}\n\t\t\t\t\t\t\t\tonValueChange={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tconst next = !isCarLyricsEnabled\n\t\t\t\t\t\t\t\t\t\tOrpheus.isCarLyricsEnabled = next\n\t\t\t\t\t\t\t\t\t\tawait syncStates()\n\t\t\t\t\t\t\t\t\t\tif (next) {\n\t\t\t\t\t\t\t\t\t\t\tconst currentTrack = await Orpheus.getCurrentTrack()\n\t\t\t\t\t\t\t\t\t\t\tif (currentTrack) {\n\t\t\t\t\t\t\t\t\t\t\t\tlyricService.pushLyricsToOverlays(currentTrack.id)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t\t\t<Text>状态栏歌词框架</Text>\n\t\t\t\t\t\t\t<FunctionalMenu\n\t\t\t\t\t\t\t\tvisible={providerMenuVisible}\n\t\t\t\t\t\t\t\tonDismiss={() => setProviderMenuVisible(false)}\n\t\t\t\t\t\t\t\tanchor={\n\t\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\t\ticon='playlist-music'\n\t\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\t\tonPress={() => setProviderMenuVisible(true)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\t\t\tlabel={`SuperLyric${!isSuperLyricApiEnabled ? '（未激活）' : ''}`}\n\t\t\t\t\t\t\t\t\tstatus={\n\t\t\t\t\t\t\t\t\t\tstatusBarLyricsProvider === 'superlyric'\n\t\t\t\t\t\t\t\t\t\t\t? 'checked'\n\t\t\t\t\t\t\t\t\t\t\t: 'unchecked'\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\t\tOrpheus.statusBarLyricsProvider = 'superlyric'\n\t\t\t\t\t\t\t\t\t\t\tvoid syncStates()\n\t\t\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tsetProviderMenuVisible(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\t\t\tlabel={`词幕 (Lyricon)${statusBarLyricsProvider === 'lyricon' && !isLyriconApiEnabled ? '（未连接）' : ''}`}\n\t\t\t\t\t\t\t\t\tstatus={\n\t\t\t\t\t\t\t\t\t\tstatusBarLyricsProvider === 'lyricon'\n\t\t\t\t\t\t\t\t\t\t\t? 'checked'\n\t\t\t\t\t\t\t\t\t\t\t: 'unchecked'\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\t\tOrpheus.statusBarLyricsProvider = 'lyricon'\n\t\t\t\t\t\t\t\t\t\t\tvoid syncStates()\n\t\t\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tsetProviderMenuVisible(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</FunctionalMenu>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t\t\t<View style={{ flex: 1, marginRight: 16 }}>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t!isStatusBarLyricsProviderActive\n\t\t\t\t\t\t\t\t\t\t\t? { opacity: 0.4 }\n\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t状态栏歌词\n\t\t\t\t\t\t\t\t\t{!isStatusBarLyricsProviderActive\n\t\t\t\t\t\t\t\t\t\t? statusBarLyricsProvider === 'lyricon'\n\t\t\t\t\t\t\t\t\t\t\t? '（需安装词幕模块）'\n\t\t\t\t\t\t\t\t\t\t\t: '（需安装 SuperLyric 模块）'\n\t\t\t\t\t\t\t\t\t\t: ''}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t{!isStatusBarLyricsProviderActive && (\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tfontSize: 12,\n\t\t\t\t\t\t\t\t\t\t\topacity: 0.5,\n\t\t\t\t\t\t\t\t\t\t\tmarginTop: 4,\n\t\t\t\t\t\t\t\t\t\t\tcolor: colors.primary,\n\t\t\t\t\t\t\t\t\t\t\ttextDecorationLine: 'underline',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\t\t\t\tWebBrowser.openBrowserAsync(\n\t\t\t\t\t\t\t\t\t\t\t\t'https://bbplayer.roitium.com/guides/lyrics.html#status-bar-lyric',\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t未检测到可用环境，请点击查看配置文档\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\tdisabled={!isStatusBarLyricsProviderActive}\n\t\t\t\t\t\t\t\tvalue={isStatusBarLyricsEnabled}\n\t\t\t\t\t\t\t\tonValueChange={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tconst next = !isStatusBarLyricsEnabled\n\t\t\t\t\t\t\t\t\t\tOrpheus.isStatusBarLyricsEnabled = next\n\t\t\t\t\t\t\t\t\t\tawait syncStates()\n\t\t\t\t\t\t\t\t\t\tif (next) {\n\t\t\t\t\t\t\t\t\t\t\t// 立即推送当前歌词\n\t\t\t\t\t\t\t\t\t\t\tconst currentTrack = await Orpheus.getCurrentTrack()\n\t\t\t\t\t\t\t\t\t\t\tif (currentTrack) {\n\t\t\t\t\t\t\t\t\t\t\t\tlyricService.pushLyricsToOverlays(currentTrack.id)\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>自动匹配的歌词源（不影响手动搜索）</Text>\n\t\t\t\t\t<FunctionalMenu\n\t\t\t\t\t\tvisible={lyricSourceMenuVisible}\n\t\t\t\t\t\tonDismiss={() => setLyricSourceMenuVisible(false)}\n\t\t\t\t\t\tanchor={\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon='playlist-music'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\tonPress={() => setLyricSourceMenuVisible(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\tlabel='网易云音乐'\n\t\t\t\t\t\t\tstatus={lyricSource === 'netease' ? 'checked' : 'unchecked'}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetSettings({ lyricSource: 'netease' })\n\t\t\t\t\t\t\t\tsetLyricSourceMenuVisible(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\tlabel='QQ 音乐'\n\t\t\t\t\t\t\tstatus={lyricSource === 'qqmusic' ? 'checked' : 'unchecked'}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetSettings({ lyricSource: 'qqmusic' })\n\t\t\t\t\t\t\t\tsetLyricSourceMenuVisible(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\tlabel='酷狗音乐'\n\t\t\t\t\t\t\tstatus={lyricSource === 'kugou' ? 'checked' : 'unchecked'}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetSettings({ lyricSource: 'kugou' })\n\t\t\t\t\t\t\t\tsetLyricSourceMenuVisible(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Checkbox.Item\n\t\t\t\t\t\t\tmode='ios'\n\t\t\t\t\t\t\tlabel='自动 (选择最先返回的数据源)'\n\t\t\t\t\t\t\tstatus={lyricSource === 'auto' ? 'checked' : 'unchecked'}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetSettings({ lyricSource: 'auto' })\n\t\t\t\t\t\t\t\tsetLyricSourceMenuVisible(false)\n\t\t\t\t\t\t\t\ttoast.info(\n\t\t\t\t\t\t\t\t\t'「自动」的意思是：选择最先返回的数据源，但不会考虑匹配度，所以不保证结果一定是最好的',\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</FunctionalMenu>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 25,\n\t},\n\tsettingRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 16,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/settings/playback.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useRouter } from 'expo-router'\nimport { useState } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { Appbar, Switch, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport IconButton from '@/components/common/IconButton'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { toastAndLogError } from '@/utils/error-handling'\n\nexport default function PlaybackSettingsPage() {\n\tconst router = useRouter()\n\tconst colors = useTheme().colors\n\tconst insets = useSafeAreaInsets()\n\tconst haveTrack = useCurrentTrack()\n\n\tconst [enablePersistCurrentPosition, setEnablePersistCurrentPosition] =\n\t\tuseState(Orpheus.restorePlaybackPositionEnabled)\n\tconst [enableLoudnessNormalization, setEnableLoudnessNormalization] =\n\t\tuseState(Orpheus.loudnessNormalizationEnabled)\n\tconst [enableAutostartPlayOnStart, setEnableAutostartPlayOnStart] = useState(\n\t\tOrpheus.autoplayOnStartEnabled,\n\t)\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Appbar.Header>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t\t<Appbar.Content title='播放设置' />\n\t\t\t</Appbar.Header>\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.scrollView}\n\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\tstyles.scrollContent,\n\t\t\t\t\t{ paddingBottom: insets.bottom + (haveTrack ? 70 + 20 : 20) },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>在应用启动时恢复上次播放进度</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enablePersistCurrentPosition}\n\t\t\t\t\t\tonValueChange={() => {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tOrpheus.restorePlaybackPositionEnabled =\n\t\t\t\t\t\t\t\t\t!enablePersistCurrentPosition\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tsetEnablePersistCurrentPosition(!enablePersistCurrentPosition)\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>响度均衡（实验性）</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableLoudnessNormalization}\n\t\t\t\t\t\tonValueChange={() => {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tOrpheus.loudnessNormalizationEnabled =\n\t\t\t\t\t\t\t\t\t!enableLoudnessNormalization\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tsetEnableLoudnessNormalization(!enableLoudnessNormalization)\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>软件启动时自动播放（易社死）</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableAutostartPlayOnStart}\n\t\t\t\t\t\tonValueChange={() => {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tOrpheus.autoplayOnStartEnabled = !enableAutostartPlayOnStart\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\ttoastAndLogError('设置失败', e, 'Settings')\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tsetEnableAutostartPlayOnStart(!enableAutostartPlayOnStart)\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.settingRow}>\n\t\t\t\t\t<Text>启用弹幕（听歌看弹幕到底有神魔用～）</Text>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='format-list-checks'\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\tuseModalStore.getState().open('DanmakuSettings', undefined)\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t},\n\tscrollContent: {\n\t\tpaddingHorizontal: 25,\n\t},\n\tsettingRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 16,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/share/playlist.tsx",
    "content": "import * as Clipboard from 'expo-clipboard'\nimport { useImage } from 'expo-image'\nimport { useLocalSearchParams, useRouter } from 'expo-router'\nimport { useEffect, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport {\n\tAppbar,\n\tAvatar,\n\tBanner,\n\tDivider,\n\tText,\n\tTouchableRipple,\n\tuseTheme,\n} from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { PlaylistError } from '@/features/playlist/remote/components/PlaylistError'\nimport { TrackList } from '@/features/playlist/remote/components/RemoteTrackList'\nimport { useRemotePlaylist } from '@/features/playlist/remote/hooks/useRemotePlaylist'\nimport { PlaylistPageSkeleton } from '@/features/playlist/skeletons/PlaylistSkeleton'\nimport { useSubscribeToSharedPlaylist } from '@/hooks/mutations/db/playlist'\nimport { usePlaylistByShareId } from '@/hooks/queries/db/playlist'\nimport { useSharedPlaylistPreview } from '@/hooks/queries/sharedPlaylistPreview'\nimport { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop'\nimport { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor'\nimport { bv2av } from '@/lib/api/bilibili/utils'\nimport type { SharedPlaylistPreview } from '@/lib/facades/sharedPlaylist'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport toast from '@/utils/toast'\n\nconst mapPreviewTrackToBilibiliTrack = (\n\ttrack: SharedPlaylistPreview['tracks'][number],\n\tindex: number,\n\tnow: Date,\n): BilibiliTrack => {\n\tconst baseId = Number(bv2av(track.bilibili_bvid))\n\tconst cidNum = track.bilibili_cid ? Number(track.bilibili_cid) : undefined\n\tconst id = Number.isFinite(baseId)\n\t\t? baseId * 1000 + (cidNum ?? 0) + index\n\t\t: index + 1\n\tconst artistRemoteId = track.artist_id ?? null\n\tconst artistNumericId = artistRemoteId ? Number(artistRemoteId) : undefined\n\n\treturn {\n\t\tid,\n\t\tuniqueKey: track.unique_key,\n\t\ttitle: track.title,\n\t\tartist:\n\t\t\ttrack.artist_name && track.artist_name.length > 0\n\t\t\t\t? {\n\t\t\t\t\t\tid: Number.isFinite(artistNumericId) ? artistNumericId! : id * 10,\n\t\t\t\t\t\tname: track.artist_name,\n\t\t\t\t\t\tavatarUrl: null,\n\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\tremoteId: artistRemoteId,\n\t\t\t\t\t\tcreatedAt: now,\n\t\t\t\t\t\tupdatedAt: now,\n\t\t\t\t\t}\n\t\t\t\t: null,\n\t\tcoverUrl: track.cover_url ?? null,\n\t\tsource: 'bilibili',\n\t\tcreatedAt: now,\n\t\tupdatedAt: now,\n\t\tduration: track.duration ?? 0,\n\t\tbilibiliMetadata: {\n\t\t\tbvid: track.bilibili_bvid,\n\t\t\tcid: cidNum ?? null,\n\t\t\tisMultiPage: !!track.bilibili_cid,\n\t\t\tvideoIsValid: true,\n\t\t},\n\t}\n}\n\nconst trackMenuItems = () => []\n\nexport default function SharedPlaylistPreviewPage() {\n\tconst { shareId, inviteCode } = useLocalSearchParams<{\n\t\tshareId?: string\n\t\tinviteCode?: string\n\t}>()\n\tconst router = useRouter()\n\tconst theme = useTheme()\n\tconst { colors } = theme\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst parsedShareId = typeof shareId === 'string' ? shareId : undefined\n\tconst parsedInviteCode =\n\t\ttypeof inviteCode === 'string' ? inviteCode : undefined\n\n\tuseEffect(() => {\n\t\tif (!parsedShareId) {\n\t\t\trouter.replace('/+not-found')\n\t\t}\n\t}, [parsedShareId, router])\n\n\tconst { data, isPending, isError, refetch } =\n\t\tuseSharedPlaylistPreview(parsedShareId)\n\n\t// 查本地 DB，判断该歌单是否已加入\n\tconst { data: localPlaylist } = usePlaylistByShareId(parsedShareId)\n\n\t// 推导当前状态\n\tconst isAlreadyJoined = !!localPlaylist\n\tconst localRole = localPlaylist ? localPlaylist.shareRole : null\n\tconst canUpgradeToEditor = localRole === 'subscriber' && !!parsedInviteCode\n\tconst isFullMember = localRole === 'owner' || localRole === 'editor'\n\n\t// 引导提示：说明点击按钮后的权限\n\tconst getActionHint = () => {\n\t\tif (isFullMember) return null // 已是成员，无需提示\n\t\tif (canUpgradeToEditor) return '升级后你将可以添加、删除和排序曲目'\n\t\tif (parsedInviteCode)\n\t\t\treturn '你将以协作编辑者身份加入，可以添加、删除和排序曲目'\n\t\treturn '订阅后你只能查看此歌单的最新内容，无法修改'\n\t}\n\tconst actionHint = getActionHint()\n\n\tconst { mutate: subscribe, isPending: isSubscribing } =\n\t\tuseSubscribeToSharedPlaylist()\n\n\tconst selection = {\n\t\tactive: false,\n\t\tselected: new Set<number>(),\n\t\ttoggle: () => void 0,\n\t\tenter: () => void 0,\n\t}\n\n\tconst [showFullTitle, setShowFullTitle] = useState(false)\n\tconst { playTrack } = useRemotePlaylist()\n\tconst { listRef, handleDoubleTap } = useDoubleTapScrollToTop<BilibiliTrack>()\n\n\tconst coverRef = useImage(data?.playlist.coverUrl ?? '', {\n\t\tonError: () => void 0,\n\t})\n\tconst { backgroundColor, nowPlayingBarColor } = usePlaylistBackgroundColor(\n\t\tcoverRef,\n\t\ttheme.dark,\n\t\tcolors.background,\n\t)\n\n\tconst nowForTracks = new Date()\n\tconst previewTracks = data\n\t\t? data.tracks.map((t, idx) =>\n\t\t\t\tmapPreviewTrackToBilibiliTrack(t, idx, nowForTracks),\n\t\t\t)\n\t\t: []\n\n\tconst subtitleParts: string[] = []\n\tif (data) {\n\t\tsubtitleParts.push(`${data.playlist.trackCount} 首歌曲`)\n\t}\n\n\tconst handleSubscribe = () => {\n\t\tif (!parsedShareId) return\n\t\tsubscribe({ shareId: parsedShareId, inviteCode: parsedInviteCode })\n\t}\n\n\tconst handleGoToPlaylist = () => {\n\t\tif (!localPlaylist) return\n\t\trouter.replace(`/playlist/local/${localPlaylist.id}`)\n\t}\n\n\tif (!parsedShareId) return null\n\n\tif (isPending) {\n\t\treturn <PlaylistPageSkeleton />\n\t}\n\n\tif (isError || !data) {\n\t\treturn (\n\t\t\t<PlaylistError\n\t\t\t\ttext='加载共享歌单失败'\n\t\t\t\tonRetry={refetch}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor }]}>\n\t\t\t<Appbar.Header\n\t\t\t\televated\n\t\t\t\tstyle={{ backgroundColor: 'transparent' }}\n\t\t\t>\n\t\t\t\t<Appbar.Content\n\t\t\t\t\ttitle={data.playlist.title}\n\t\t\t\t\tonPress={handleDoubleTap}\n\t\t\t\t/>\n\t\t\t\t<Appbar.BackAction onPress={() => router.back()} />\n\t\t\t</Appbar.Header>\n\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t<TrackList\n\t\t\t\t\tlistRef={listRef}\n\t\t\t\t\ttracks={previewTracks}\n\t\t\t\t\tplayTrack={playTrack}\n\t\t\t\t\ttrackMenuItems={trackMenuItems}\n\t\t\t\t\tselection={selection}\n\t\t\t\t\tListHeaderComponent={\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<View style={styles.playlistHeader}>\n\t\t\t\t\t\t\t\t{/* 封面 + 文字列 */}\n\t\t\t\t\t\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\t\t\t\tid={data.playlist.id}\n\t\t\t\t\t\t\t\t\t\tcover={coverRef ?? undefined}\n\t\t\t\t\t\t\t\t\t\ttitle={data.playlist.title}\n\t\t\t\t\t\t\t\t\t\tsize={120}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<View style={styles.headerTextContainer}>\n\t\t\t\t\t\t\t\t\t\t<TouchableRipple\n\t\t\t\t\t\t\t\t\t\t\tonPress={() => setShowFullTitle(!showFullTitle)}\n\t\t\t\t\t\t\t\t\t\t\tonLongPress={async () => {\n\t\t\t\t\t\t\t\t\t\t\t\tconst ok = await Clipboard.setStringAsync(\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata.playlist.title,\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\tif (!ok) toast.error('复制失败')\n\t\t\t\t\t\t\t\t\t\t\t\telse toast.success('已复制标题到剪贴板')\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={styles.headerTitle}\n\t\t\t\t\t\t\t\t\t\t\t\tnumberOfLines={showFullTitle ? undefined : 2}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{data.playlist.title}\n\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t</TouchableRipple>\n\t\t\t\t\t\t\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t\t\t\t\t\t\t{subtitleParts.join(' • ')}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t{data.owner && (\n\t\t\t\t\t\t\t\t\t\t\t<View style={styles.shareInfoRow}>\n\t\t\t\t\t\t\t\t\t\t\t\t{data.owner.avatarUrl ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar.Image\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsource={{ uri: data.owner.avatarUrl }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar.Text\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlabel={data.owner.name.slice(0, 1)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={styles.shareOwnerName}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{data.owner.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t{/* 订阅/升级/进入 按钮 */}\n\t\t\t\t\t\t\t\t<View style={styles.actionsContainer}>\n\t\t\t\t\t\t\t\t\t{isFullMember ? (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\t\t\t\ticon='playlist-music'\n\t\t\t\t\t\t\t\t\t\t\tonPress={handleGoToPlaylist}\n\t\t\t\t\t\t\t\t\t\t\ttestID='playlist-header-main-button'\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t前往歌单\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t) : canUpgradeToEditor ? (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\t\t\t\ticon='account-arrow-up'\n\t\t\t\t\t\t\t\t\t\t\tonPress={handleSubscribe}\n\t\t\t\t\t\t\t\t\t\t\tloading={isSubscribing}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isSubscribing}\n\t\t\t\t\t\t\t\t\t\t\ttestID='playlist-header-main-button'\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t升级为协作编辑者\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t) : isAlreadyJoined ? (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\t\t\t\ticon='playlist-music'\n\t\t\t\t\t\t\t\t\t\t\tonPress={handleGoToPlaylist}\n\t\t\t\t\t\t\t\t\t\t\ttestID='playlist-header-main-button'\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t已订阅，前往查看\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\t\t\t\ticon={parsedInviteCode ? 'account-plus' : 'rss'}\n\t\t\t\t\t\t\t\t\t\t\tonPress={handleSubscribe}\n\t\t\t\t\t\t\t\t\t\t\tloading={isSubscribing}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isSubscribing}\n\t\t\t\t\t\t\t\t\t\t\ttestID='playlist-header-main-button'\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{parsedInviteCode ? '加入协作编辑' : '订阅共享歌单'}\n\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t{/* 权限说明提示 */}\n\t\t\t\t\t\t\t\t{actionHint && (\n\t\t\t\t\t\t\t\t\t<Banner\n\t\t\t\t\t\t\t\t\t\tvisible\n\t\t\t\t\t\t\t\t\t\ticon='information-outline'\n\t\t\t\t\t\t\t\t\t\tstyle={styles.hintBanner}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{actionHint}\n\t\t\t\t\t\t\t\t\t</Banner>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{/* 描述 */}\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.description,\n\t\t\t\t\t\t\t\t\t\t!!data.playlist.description && styles.descriptionMargin,\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{data.playlist.description ?? ''}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Divider />\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t{data.playlist.trackCount > data.previewLimit && (\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tstyle={styles.previewHint}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t仅展示前 {data.previewLimit} 首，订阅后会自动拉取完整曲目。\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</>\n\t\t\t\t\t}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing}\n\t\t\t\t\t\t\tonRefresh={async () => {\n\t\t\t\t\t\t\t\tsetRefreshing(true)\n\t\t\t\t\t\t\t\tawait refetch()\n\t\t\t\t\t\t\t\tsetRefreshing(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar backgroundColor={nowPlayingBarColor} />\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n\tpreviewHint: {\n\t\tmarginHorizontal: 16,\n\t\tmarginTop: 12,\n\t\tmarginBottom: 12,\n\t},\n\tplaylistHeader: {\n\t\tflexDirection: 'column',\n\t},\n\theaderContainer: {\n\t\tflexDirection: 'row',\n\t\tpadding: 16,\n\t\talignItems: 'center',\n\t},\n\theaderTextContainer: {\n\t\tmarginLeft: 16,\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t},\n\theaderTitle: {\n\t\tfontWeight: 'bold',\n\t},\n\tactionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'flex-start',\n\t\tmarginHorizontal: 16,\n\t},\n\thintBanner: {\n\t\tmarginHorizontal: 16,\n\t\tmarginTop: 8,\n\t\tborderRadius: 8,\n\t},\n\tdescription: {\n\t\tmargin: 0,\n\t},\n\tdescriptionMargin: {\n\t\tmargin: 16,\n\t},\n\tshareInfoRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tcolumnGap: 6,\n\t\tmarginTop: 8,\n\t},\n\tshareOwnerName: {\n\t\tfontWeight: '600',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/app/test.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport dayjs from 'dayjs'\nimport { asc, sql } from 'drizzle-orm'\nimport * as DocumentPicker from 'expo-document-picker'\nimport { Directory, File, Paths } from 'expo-file-system'\nimport * as Updates from 'expo-updates'\nimport { useRef, useState } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { Dialog, Portal, Text, TextInput, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport AnimatedModalOverlay from '@/components/common/AnimatedModalOverlay'\nimport Button from '@/components/common/Button'\nimport { alert } from '@/components/modals/AlertModal'\nimport NowPlayingBar from '@/components/NowPlayingBar'\nimport { SyncFailuresSheet } from '@/features/playlist/local/components/SyncFailuresSheet'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport db, { expoDb } from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { sharedPlaylistFacade } from '@/lib/facades/sharedPlaylist'\nimport lyricService from '@/lib/services/lyricService'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport log from '@/utils/log'\nimport toast from '@/utils/toast'\n\nconst logger = log.extend('TestPage')\n\nexport default function TestPage() {\n\tconst [loading, setLoading] = useState(false)\n\tconst syncFailuresSheetRef = useRef<TrueSheet>(null)\n\tconst { isUpdatePending } = Updates.useUpdates()\n\tconst insets = useSafeAreaInsets()\n\tconst { colors } = useTheme()\n\tconst haveTrack = useCurrentTrack()\n\tconst [updateChannel, setUpdateChannel] = useState('')\n\tconst [updateChannelModalVisible, setUpdateChannelModalVisible] =\n\t\tuseState(false)\n\tconst [queryDate, setQueryDate] = useState('')\n\n\tconst testCheckUpdate = async () => {\n\t\tsetLoading(true)\n\t\ttry {\n\t\t\tconst result = await Updates.checkForUpdateAsync()\n\t\t\ttoast.success('检查更新结果', {\n\t\t\t\tdescription: `isAvailable: ${result.isAvailable}, whyNotAvailable: ${result.reason}, isRollbackToEmbedding: ${result.isRollBackToEmbedded}`,\n\t\t\t\tduration: Number.POSITIVE_INFINITY,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\ttoast.error('检查更新失败', { description: String(error) })\n\t\t}\n\t\tsetLoading(false)\n\t}\n\n\tconst testUpdatePackage = async () => {\n\t\tsetLoading(true)\n\t\ttry {\n\t\t\tif (isUpdatePending) {\n\t\t\t\texpoDb.closeSync()\n\t\t\t\tawait Updates.reloadAsync()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetLoading(true)\n\t\t\tconst result = await Updates.checkForUpdateAsync()\n\t\t\tif (!result.isAvailable) {\n\t\t\t\ttoast.error('没有可用的更新', {\n\t\t\t\t\tdescription: '当前已是最新版本',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst updateResult = await Updates.fetchUpdateAsync()\n\t\t\tif (updateResult.isNew) {\n\t\t\t\ttoast.success('有新版本可用', {\n\t\t\t\t\tdescription: '现在更新',\n\t\t\t\t})\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\texpoDb.closeSync()\n\t\t\t\t\tsetLoading(false) // I thought this is meaningless\n\t\t\t\t\tvoid Updates.reloadAsync()\n\t\t\t\t}, 1000)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\ttoast.error('更新失败', { description: String(error) })\n\t\t}\n\t\tsetLoading(false)\n\t}\n\n\tconst handleDeleteAllDownloadRecords = () => {\n\t\talert(\n\t\t\t'清除下载缓存',\n\t\t\t'是否清除所有下载缓存？包括下载记录、数据库记录以及实际文件',\n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\ttext: '取消',\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: '确定',\n\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\tsetLoading(true)\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait Orpheus.removeAllDownloads()\n\t\t\t\t\t\t\tlogger.info('清除数据库下载记录及实际文件成功')\n\t\t\t\t\t\t\ttoast.success('清除下载缓存成功')\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\ttoastAndLogError('清除下载缓存失败', error, 'TestPage')\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsetLoading(false)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t{ cancelable: true },\n\t\t)\n\t}\n\n\tconst clearAllLyrcis = () => {\n\t\tconst clearAction = () => {\n\t\t\tsetLoading(true)\n\t\t\tconst result = lyricService.clearAllLyrics()\n\t\t\tif (result.isOk()) {\n\t\t\t\ttoast.success('清除成功')\n\t\t\t} else {\n\t\t\t\ttoast.error('清除歌词失败', {\n\t\t\t\t\tdescription:\n\t\t\t\t\t\tresult.error instanceof Error ? result.error.message : '未知错误',\n\t\t\t\t})\n\t\t\t}\n\t\t\tsetLoading(false)\n\t\t}\n\t\talert(\n\t\t\t'清除所有歌词',\n\t\t\t'是否清除所有已保存的歌词？下次播放时将重新从网络获取歌词',\n\t\t\t[\n\t\t\t\t{\n\t\t\t\t\ttext: '取消',\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttext: '确定',\n\t\t\t\t\tonPress: clearAction,\n\t\t\t\t},\n\t\t\t],\n\t\t)\n\t}\n\n\tconst testPullSharedPlaylist = async () => {\n\t\tsetLoading(true)\n\t\ttry {\n\t\t\tconst result = await sharedPlaylistFacade.pullChanges(44)\n\t\t\tif (result.isErr()) throw result.error\n\t\t\ttoast.success('拉取共享歌单成功', {\n\t\t\t\tdescription: `applied=${result.value.applied}`,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\ttoastAndLogError('拉取共享歌单失败', error, 'TestPage')\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}\n\n\tconst dumpSyncQueue = async () => {\n\t\tsetLoading(true)\n\t\ttry {\n\t\t\tconst rows = await db\n\t\t\t\t.select()\n\t\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t\t.orderBy(asc(schema.playlistSyncQueue.id))\n\t\t\tlogger.info('playlist_sync_queue', rows)\n\t\t\ttoast.success('队列表输出', {\n\t\t\t\tdescription: `rows=${rows.length}（详见日志）`,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\ttoastAndLogError('读取 playlist_sync_queue 失败', error, 'TestPage')\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}\n\n\tconst openSyncFailuresSheet = () => {\n\t\tif (syncFailuresSheetRef.current) {\n\t\t\tvoid syncFailuresSheetRef.current.present()\n\t\t}\n\t}\n\n\tconst handleImportDatabase = async () => {\n\t\talert(\n\t\t\t'导入数据库',\n\t\t\t'导入将覆盖当前数据库并自动重启应用，是否继续？',\n\t\t\t[\n\t\t\t\t{ text: '取消' },\n\t\t\t\t{\n\t\t\t\t\ttext: '确定',\n\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\tsetLoading(true)\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst result = await DocumentPicker.getDocumentAsync({\n\t\t\t\t\t\t\t\ttype: '*/*',\n\t\t\t\t\t\t\t\tcopyToCacheDirectory: true,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\tif (result.canceled) return\n\n\t\t\t\t\t\t\tconst pickedFile = new File(result.assets[0].uri)\n\t\t\t\t\t\t\tconst dbDir = new Directory(Paths.document, 'SQLite')\n\t\t\t\t\t\t\tconst dbFile = new File(dbDir, 'db.db')\n\n\t\t\t\t\t\t\tif (!dbDir.exists) {\n\t\t\t\t\t\t\t\tdbDir.create()\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\texpoDb.closeSync()\n\t\t\t\t\t\t\tif (dbFile.exists) {\n\t\t\t\t\t\t\t\tdbFile.delete()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tpickedFile.copy(dbFile)\n\n\t\t\t\t\t\t\ttoast.success('导入成功')\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\ttoastAndLogError('导入数据库失败', error, 'TestPage')\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\tsetLoading(false)\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t{ cancelable: true },\n\t\t)\n\t}\n\n\tconst handleImportMMKV = async () => {\n\t\talert(\n\t\t\t'导入 MMKV 数据',\n\t\t\t'请同时选择 mmkv.default 和 mmkv.default.crc 文件进行导入。',\n\t\t\t[\n\t\t\t\t{ text: '取消' },\n\t\t\t\t{\n\t\t\t\t\ttext: '确定',\n\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\tsetLoading(true)\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst result = await DocumentPicker.getDocumentAsync({\n\t\t\t\t\t\t\t\ttype: '*/*',\n\t\t\t\t\t\t\t\tcopyToCacheDirectory: true,\n\t\t\t\t\t\t\t\tmultiple: true,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\tif (result.canceled) return\n\n\t\t\t\t\t\t\tconst mmkvDir = new Directory(Paths.document, 'mmkv')\n\t\t\t\t\t\t\tif (!mmkvDir.exists) {\n\t\t\t\t\t\t\t\tmmkvDir.create()\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tfor (const asset of result.assets) {\n\t\t\t\t\t\t\t\tconst pickedFile = new File(asset.uri)\n\t\t\t\t\t\t\t\tconst targetFile = new File(mmkvDir, asset.name)\n\t\t\t\t\t\t\t\tif (targetFile.exists) {\n\t\t\t\t\t\t\t\t\ttargetFile.delete()\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tpickedFile.copy(targetFile)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\ttoast.success('MMKV 导入成功')\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\ttoastAndLogError('导入 MMKV 失败', error, 'TestPage')\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\tsetLoading(false)\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\t{ cancelable: true },\n\t\t)\n\t}\n\n\tconst handleQueryPlayHistoryByDate = async () => {\n\t\tif (!queryDate) {\n\t\t\ttoast.error('请输入日期')\n\t\t\treturn\n\t\t}\n\n\t\tconst date = dayjs(queryDate, 'YYYY/MM/DD', true)\n\t\tif (!date.isValid()) {\n\t\t\ttoast.error('日期格式不正确，请使用 YYYY/MM/DD')\n\t\t\treturn\n\t\t}\n\n\t\tconst startTime = date.startOf('day').valueOf()\n\t\tconst endTime = date.endOf('day').valueOf()\n\n\t\tsetLoading(true)\n\t\ttry {\n\t\t\t// 兼容秒和毫秒时间戳。\n\t\t\t// 如果 startTime > 1e11，认为是毫秒；否则认为是秒。\n\t\t\t// 我们查询时可以简单地查询两个范围，或者使用 SQL 表达式转换。\n\t\t\t// 为了简单起见，我们在 JS 端处理或者用 OR。\n\t\t\tconst rows = await db\n\t\t\t\t.select()\n\t\t\t\t.from(schema.playHistory)\n\t\t\t\t.where(\n\t\t\t\t\tsql`${schema.playHistory.startTime} BETWEEN ${startTime} AND ${endTime}\n                        OR (${schema.playHistory.startTime} * 1000) BETWEEN ${startTime} AND ${endTime}`,\n\t\t\t\t)\n\n\t\t\tlogger.info(`查询 ${queryDate} 的播放历史:`, rows)\n\t\t\ttoast.success(`查询成功: ${queryDate}`, {\n\t\t\t\tdescription: `共找到 ${rows.length} 条记录（详见日志）`,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\ttoastAndLogError('查询播放历史失败', error, 'TestPage')\n\t\t} finally {\n\t\t\tsetLoading(false)\n\t\t}\n\t}\n\n\tconst openModal = useModalStore((state) => state.open)\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<ScrollView\n\t\t\t\tstyle={[styles.scrollView, { paddingTop: insets.top + 30 }]}\n\t\t\t\tcontentContainerStyle={{ paddingBottom: haveTrack ? 80 : 20 }}\n\t\t\t\tcontentInsetAdjustmentBehavior='automatic'\n\t\t\t>\n\t\t\t\t<View style={styles.buttonContainer}>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\tonPress={() => openModal('InputExternalPlaylistInfo', undefined)}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t同步外部歌单\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={testPullSharedPlaylist}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t测试共享歌单增量拉取\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={() => setUpdateChannelModalVisible(true)}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t更改热更新渠道\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={testCheckUpdate}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t查询是否有可热更新的包\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={testUpdatePackage}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t拉取热更新并重载\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={handleDeleteAllDownloadRecords}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t清空下载缓存\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={clearAllLyrcis}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t清空所有歌词缓存\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={() => Orpheus.clear()}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t清空播放器队列\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={dumpSyncQueue}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t输出 playlist_sync_queue\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={openSyncFailuresSheet}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t预览同步失败记录 Sheet\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\tonPress={handleImportDatabase}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t导入数据库 (Import db.db)\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\tonPress={handleImportMMKV}\n\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\tstyle={styles.button}\n\t\t\t\t\t>\n\t\t\t\t\t\t导入 MMKV 数据 (Import mmkv)\n\t\t\t\t\t</Button>\n\n\t\t\t\t\t<View style={{ marginTop: 16 }}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tlabel='查询日期 (YYYY/MM/DD)'\n\t\t\t\t\t\t\tvalue={queryDate}\n\t\t\t\t\t\t\tonChangeText={setQueryDate}\n\t\t\t\t\t\t\tplaceholder='例如 2024/03/22'\n\t\t\t\t\t\t\tstyle={{ marginBottom: 8 }}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\tonPress={handleQueryPlayHistoryByDate}\n\t\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t查询指定日期的播放历史\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t\t<View style={styles.nowPlayingBarContainer}>\n\t\t\t\t<NowPlayingBar />\n\t\t\t</View>\n\n\t\t\t<Portal>\n\t\t\t\t<AnimatedModalOverlay\n\t\t\t\t\tvisible={updateChannelModalVisible}\n\t\t\t\t\tonDismiss={() => setUpdateChannelModalVisible(false)}\n\t\t\t\t>\n\t\t\t\t\t<Dialog.Title>\n\t\t\t\t\t\t设置热更新渠道\n\t\t\t\t\t\t<Text style={{ color: 'red' }}>&thinsp;(高危)&thinsp;</Text>\n\t\t\t\t\t</Dialog.Title>\n\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t<Text style={{ color: 'red' }}>\n\t\t\t\t\t\t\t如果您不知道您正在做什么，请关闭此弹窗！\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text>\n\t\t\t\t\t\t\t{'\\n'}\n\t\t\t\t\t\t\t（注意：所设置的 channel\n\t\t\t\t\t\t\t是持久化的，如果需要恢复请点击下面的按钮）\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tstyle={{ marginTop: 16 }}\n\t\t\t\t\t\t\tonChangeText={setUpdateChannel}\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tlabel='更新渠道'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t<Button onPress={() => setUpdateChannelModalVisible(false)}>\n\t\t\t\t\t\t\t取消\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetUpdateChannelModalVisible(false)\n\t\t\t\t\t\t\t\tUpdates.setUpdateRequestHeadersOverride({\n\t\t\t\t\t\t\t\t\t'expo-channel-name': 'production',\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t恢复默认\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetUpdateChannelModalVisible(false)\n\t\t\t\t\t\t\t\tUpdates.setUpdateRequestHeadersOverride({\n\t\t\t\t\t\t\t\t\t'expo-channel-name': updateChannel,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\tvoid testCheckUpdate()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t保存并查询是否有更新\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t</AnimatedModalOverlay>\n\t\t\t</Portal>\n\t\t\t<SyncFailuresSheet\n\t\t\t\tref={syncFailuresSheetRef}\n\t\t\t\tuseMockData\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tscrollView: {\n\t\tflex: 1,\n\t\tpadding: 16,\n\t},\n\tbuttonContainer: {\n\t\tmarginBottom: 16,\n\t},\n\tbutton: {\n\t\tmarginBottom: 8,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/assets/lottie/play-pause.json",
    "content": "{\n\t\"v\": \"5.6.5\",\n\t\"fr\": 30,\n\t\"ip\": 0,\n\t\"op\": 8,\n\t\"w\": 32,\n\t\"h\": 32,\n\t\"nm\": \"play-pause-circle\",\n\t\"ddd\": 0,\n\t\"assets\": [],\n\t\"layers\": [\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 1,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"play-pause-circle\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n\t\t\t\t\"p\": { \"a\": 0, \"k\": [16, 16, 0], \"ix\": 2 },\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100, 100], \"ix\": 6 }\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t\t[10, 15],\n\t\t\t\t\t\t\t\t\t\t\t\t[10, 9]\n\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\"c\": false\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [9.99, 12.036], \"ix\": 2 },\n\t\t\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [9.99, 12.036], \"ix\": 1 },\n\t\t\t\t\t\t\t\t\t\"s\": {\n\t\t\t\t\t\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\"i\": { \"x\": [0.667, 0.667], \"y\": [1, 1] },\n\t\t\t\t\t\t\t\t\t\t\t\t\"o\": { \"x\": [0.333, 0.333], \"y\": [0, 0] },\n\t\t\t\t\t\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\t\t\t\t\t\"s\": [100, 100]\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t{ \"t\": 8, \"s\": [100, 120] }\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"ix\": 3\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"nm\": \"left line\",\n\t\t\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.667, \"y\": 1 },\n\t\t\t\t\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.333, \"y\": 0 },\n\t\t\t\t\t\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[14, 15],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[14, 12],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[14, 9]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"c\": false\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\"t\": 8,\n\t\t\t\t\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[10, 15.562],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[15.625, 12.125],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[10.062, 8.438]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"c\": false\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 2 },\n\t\t\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"nm\": \"right line\",\n\t\t\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"ix\": 2,\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [12, 12], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"pause symbol\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[-5.523, 0],\n\t\t\t\t\t\t\t\t\t\t[0, -5.523],\n\t\t\t\t\t\t\t\t\t\t[5.522, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 5.522]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[5.522, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 5.522],\n\t\t\t\t\t\t\t\t\t\t[-5.523, 0],\n\t\t\t\t\t\t\t\t\t\t[0, -5.523]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[0, -10],\n\t\t\t\t\t\t\t\t\t\t[10, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 10],\n\t\t\t\t\t\t\t\t\t\t[-10, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [12, 12], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"circle\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 2,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 0,\n\t\t\t\"op\": 8,\n\t\t\t\"st\": 0,\n\t\t\t\"bm\": 0\n\t\t}\n\t],\n\t\"markers\": []\n}\n"
  },
  {
    "path": "apps/mobile/src/assets/lottie/skip-next.json",
    "content": "{\n\t\"v\": \"5.6.5\",\n\t\"fr\": 30,\n\t\"ip\": 0,\n\t\"op\": 60,\n\t\"w\": 32,\n\t\"h\": 32,\n\t\"nm\": \"skip-forward\",\n\t\"ddd\": 0,\n\t\"assets\": [],\n\t\"layers\": [\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 1,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"line\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n\t\t\t\t\"p\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": 0.667, \"y\": 1 },\n\t\t\t\t\t\t\t\"o\": { \"x\": 1, \"y\": 0 },\n\t\t\t\t\t\t\t\"t\": 20,\n\t\t\t\t\t\t\t\"s\": [16, 16, 0],\n\t\t\t\t\t\t\t\"to\": [-2.708, 0, 0],\n\t\t\t\t\t\t\t\"ti\": [0, 0, 0]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": 0, \"y\": 1 },\n\t\t\t\t\t\t\t\"o\": { \"x\": 0.333, \"y\": 0 },\n\t\t\t\t\t\t\t\"t\": 40,\n\t\t\t\t\t\t\t\"s\": [-0.25, 16, 0],\n\t\t\t\t\t\t\t\"to\": [0, 0, 0],\n\t\t\t\t\t\t\t\"ti\": [-2.708, 0, 0]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 60, \"s\": [16, 16, 0] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 2\n\t\t\t\t},\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100, 100], \"ix\": 6 }\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[19, 5],\n\t\t\t\t\t\t\t\t\t\t[19, 19]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"line\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 0,\n\t\t\t\"op\": 60,\n\t\t\t\"st\": 0,\n\t\t\t\"bm\": 0\n\t\t},\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 2,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"triangle 2\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n\t\t\t\t\"p\": { \"a\": 0, \"k\": [16, 16, 0], \"ix\": 2 },\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": [0, 0, 0.667], \"y\": [1, 1, 1] },\n\t\t\t\t\t\t\t\"o\": { \"x\": [0.333, 0.333, 0.333], \"y\": [0, 0, 0] },\n\t\t\t\t\t\t\t\"t\": 50,\n\t\t\t\t\t\t\t\"s\": [75, 75, 100]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 70, \"s\": [100, 100, 100] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 6\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"hasMask\": true,\n\t\t\t\"masksProperties\": [\n\t\t\t\t{\n\t\t\t\t\t\"inv\": false,\n\t\t\t\t\t\"mode\": \"a\",\n\t\t\t\t\t\"pt\": {\n\t\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 40,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-15.5, -1.167],\n\t\t\t\t\t\t\t\t\t\t\t[-15.5, 24.5],\n\t\t\t\t\t\t\t\t\t\t\t[-0.333, 24.5],\n\t\t\t\t\t\t\t\t\t\t\t[-0.333, -1.167]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 43,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-12.333, -1.333],\n\t\t\t\t\t\t\t\t\t\t\t[-12.333, 24.333],\n\t\t\t\t\t\t\t\t\t\t\t[2.833, 24.333],\n\t\t\t\t\t\t\t\t\t\t\t[2.833, -1.333]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 44,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-8.5, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-8.5, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[6.667, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[6.667, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 45,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-4.833, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-4.833, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[10.333, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[10.333, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 46,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-2, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-2, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[13.167, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[13.167, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 47,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-0.167, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-0.167, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[15, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[15, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"t\": 48,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[2.5, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[2.5, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[17.667, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[17.667, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"ix\": 1\n\t\t\t\t\t},\n\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 3 },\n\t\t\t\t\t\"x\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\"nm\": \"Mask 1\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[-5, -8],\n\t\t\t\t\t\t\t\t\t\t[5, 0],\n\t\t\t\t\t\t\t\t\t\t[-5, 8]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [10, 12], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"triangle\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 40,\n\t\t\t\"op\": 60,\n\t\t\t\"st\": 40,\n\t\t\t\"bm\": 0\n\t\t},\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 3,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"triangle\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n\t\t\t\t\"p\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 1 },\n\t\t\t\t\t\t\t\"o\": { \"x\": 1, \"y\": 0 },\n\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\"s\": [16, 16, 0],\n\t\t\t\t\t\t\t\"to\": [3.333, 0, 0],\n\t\t\t\t\t\t\t\"ti\": [-3.333, 0, 0]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 15, \"s\": [36, 16, 0] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 2\n\t\t\t\t},\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": [0, 0, 0.667], \"y\": [1, 1, 1] },\n\t\t\t\t\t\t\t\"o\": { \"x\": [0.333, 0.333, 0.333], \"y\": [0, 0, 0] },\n\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\"s\": [100, 100, 100]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 20, \"s\": [75, 75, 100] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 6\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"hasMask\": true,\n\t\t\t\"masksProperties\": [\n\t\t\t\t{\n\t\t\t\t\t\"inv\": false,\n\t\t\t\t\t\"mode\": \"a\",\n\t\t\t\t\t\"pt\": {\n\t\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 0.125],\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 24],\n\t\t\t\t\t\t\t\t\t\t\t[19.125, 24],\n\t\t\t\t\t\t\t\t\t\t\t[19.125, 0.125]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 5,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 0.125],\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 23.571],\n\t\t\t\t\t\t\t\t\t\t\t[19.232, 23.571],\n\t\t\t\t\t\t\t\t\t\t\t[19.232, 0.125]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 7,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-0.936, 0.277],\n\t\t\t\t\t\t\t\t\t\t\t[-0.936, 23.723],\n\t\t\t\t\t\t\t\t\t\t\t[18.172, 23.723],\n\t\t\t\t\t\t\t\t\t\t\t[18.172, 0.277]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 8,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-1.717, 0.12],\n\t\t\t\t\t\t\t\t\t\t\t[-1.717, 23.567],\n\t\t\t\t\t\t\t\t\t\t\t[17.39, 23.567],\n\t\t\t\t\t\t\t\t\t\t\t[17.39, 0.12]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 9,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-2.846, 0.282],\n\t\t\t\t\t\t\t\t\t\t\t[-2.846, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[16.261, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[16.261, 0.282]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 10,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-4.513, 0.282],\n\t\t\t\t\t\t\t\t\t\t\t[-4.513, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[14.595, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[14.595, 0.282]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 11,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-7.013, 0.615],\n\t\t\t\t\t\t\t\t\t\t\t[-7.013, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[12.095, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[12.095, 0.615]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 12,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-10.179, 0.615],\n\t\t\t\t\t\t\t\t\t\t\t[-10.179, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[8.928, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[8.928, 0.615]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 13,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-15.179, 0.115],\n\t\t\t\t\t\t\t\t\t\t\t[-15.179, 23.561],\n\t\t\t\t\t\t\t\t\t\t\t[3.928, 23.561],\n\t\t\t\t\t\t\t\t\t\t\t[3.928, 0.115]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"t\": 14,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-22.013, 0.282],\n\t\t\t\t\t\t\t\t\t\t\t[-22.013, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[-2.905, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[-2.905, 0.282]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"ix\": 1\n\t\t\t\t\t},\n\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 3 },\n\t\t\t\t\t\"x\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\"nm\": \"Mask 1\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[-5, -8],\n\t\t\t\t\t\t\t\t\t\t[5, 0],\n\t\t\t\t\t\t\t\t\t\t[-5, 8]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [10, 12], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"triangle\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 0,\n\t\t\t\"op\": 15,\n\t\t\t\"st\": 0,\n\t\t\t\"bm\": 0\n\t\t}\n\t],\n\t\"markers\": []\n}\n"
  },
  {
    "path": "apps/mobile/src/assets/lottie/skip-prev.json",
    "content": "{\n\t\"v\": \"5.6.5\",\n\t\"fr\": 30,\n\t\"ip\": 0,\n\t\"op\": 60,\n\t\"w\": 32,\n\t\"h\": 32,\n\t\"nm\": \"skip-back\",\n\t\"ddd\": 0,\n\t\"assets\": [],\n\t\"layers\": [\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 1,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"line\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 180, \"ix\": 10 },\n\t\t\t\t\"p\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": 0.667, \"y\": 1 },\n\t\t\t\t\t\t\t\"o\": { \"x\": 1, \"y\": 0 },\n\t\t\t\t\t\t\t\"t\": 20,\n\t\t\t\t\t\t\t\"s\": [16, 16, 0],\n\t\t\t\t\t\t\t\"to\": [2.708, 0, 0],\n\t\t\t\t\t\t\t\"ti\": [0, 0, 0]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": 0, \"y\": 1 },\n\t\t\t\t\t\t\t\"o\": { \"x\": 0.333, \"y\": 0 },\n\t\t\t\t\t\t\t\"t\": 40,\n\t\t\t\t\t\t\t\"s\": [32.25, 16, 0],\n\t\t\t\t\t\t\t\"to\": [0, 0, 0],\n\t\t\t\t\t\t\t\"ti\": [2.708, 0, 0]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 60, \"s\": [16, 16, 0] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 2\n\t\t\t\t},\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100, 100], \"ix\": 6 }\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[19, 5],\n\t\t\t\t\t\t\t\t\t\t[19, 19]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"line\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 0,\n\t\t\t\"op\": 60,\n\t\t\t\"st\": 0,\n\t\t\t\"bm\": 0\n\t\t},\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 2,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"triangle 2\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 180, \"ix\": 10 },\n\t\t\t\t\"p\": { \"a\": 0, \"k\": [16, 16, 0], \"ix\": 2 },\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": [0, 0, 0.667], \"y\": [1, 1, 1] },\n\t\t\t\t\t\t\t\"o\": { \"x\": [0.333, 0.333, 0.333], \"y\": [0, 0, 0] },\n\t\t\t\t\t\t\t\"t\": 50,\n\t\t\t\t\t\t\t\"s\": [75, 75, 100]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 60, \"s\": [100, 100, 100] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 6\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"hasMask\": true,\n\t\t\t\"masksProperties\": [\n\t\t\t\t{\n\t\t\t\t\t\"inv\": false,\n\t\t\t\t\t\"mode\": \"a\",\n\t\t\t\t\t\"pt\": {\n\t\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 40,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-15.5, -1.167],\n\t\t\t\t\t\t\t\t\t\t\t[-15.5, 24.5],\n\t\t\t\t\t\t\t\t\t\t\t[-0.333, 24.5],\n\t\t\t\t\t\t\t\t\t\t\t[-0.333, -1.167]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 43,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-12.333, -1.333],\n\t\t\t\t\t\t\t\t\t\t\t[-12.333, 24.333],\n\t\t\t\t\t\t\t\t\t\t\t[2.833, 24.333],\n\t\t\t\t\t\t\t\t\t\t\t[2.833, -1.333]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 44,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-8.5, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-8.5, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[6.667, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[6.667, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 45,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-4.833, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-4.833, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[10.333, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[10.333, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 46,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-2, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-2, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[13.167, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[13.167, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 47,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-0.167, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[-0.167, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[15, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[15, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"t\": 48,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[2.5, -0.833],\n\t\t\t\t\t\t\t\t\t\t\t[2.5, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[17.667, 24.833],\n\t\t\t\t\t\t\t\t\t\t\t[17.667, -0.833]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"ix\": 1\n\t\t\t\t\t},\n\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 3 },\n\t\t\t\t\t\"x\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\"nm\": \"Mask 1\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[-5, -8],\n\t\t\t\t\t\t\t\t\t\t[5, 0],\n\t\t\t\t\t\t\t\t\t\t[-5, 8]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [10, 12], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"triangle\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 40,\n\t\t\t\"op\": 60,\n\t\t\t\"st\": 40,\n\t\t\t\"bm\": 0\n\t\t},\n\t\t{\n\t\t\t\"ddd\": 0,\n\t\t\t\"ind\": 3,\n\t\t\t\"ty\": 4,\n\t\t\t\"nm\": \"triangle\",\n\t\t\t\"sr\": 1,\n\t\t\t\"ks\": {\n\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n\t\t\t\t\"r\": { \"a\": 0, \"k\": 180, \"ix\": 10 },\n\t\t\t\t\"p\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 1 },\n\t\t\t\t\t\t\t\"o\": { \"x\": 1, \"y\": 0 },\n\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\"s\": [16, 16, 0],\n\t\t\t\t\t\t\t\"to\": [-3.333, 0, 0],\n\t\t\t\t\t\t\t\"ti\": [3.333, 0, 0]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 15, \"s\": [-4, 16, 0] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 2\n\t\t\t\t},\n\t\t\t\t\"a\": { \"a\": 0, \"k\": [12, 12, 0], \"ix\": 1 },\n\t\t\t\t\"s\": {\n\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"i\": { \"x\": [0, 0, 0.667], \"y\": [1, 1, 1] },\n\t\t\t\t\t\t\t\"o\": { \"x\": [0.333, 0.333, 0.333], \"y\": [0, 0, 0] },\n\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\"s\": [100, 100, 100]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ \"t\": 20, \"s\": [75, 75, 100] }\n\t\t\t\t\t],\n\t\t\t\t\t\"ix\": 6\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"ao\": 0,\n\t\t\t\"hasMask\": true,\n\t\t\t\"masksProperties\": [\n\t\t\t\t{\n\t\t\t\t\t\"inv\": false,\n\t\t\t\t\t\"mode\": \"a\",\n\t\t\t\t\t\"pt\": {\n\t\t\t\t\t\t\"a\": 1,\n\t\t\t\t\t\t\"k\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 0,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 0.125],\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 24],\n\t\t\t\t\t\t\t\t\t\t\t[19.125, 24],\n\t\t\t\t\t\t\t\t\t\t\t[19.125, 0.125]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 5,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 0.125],\n\t\t\t\t\t\t\t\t\t\t\t[0.125, 23.571],\n\t\t\t\t\t\t\t\t\t\t\t[19.232, 23.571],\n\t\t\t\t\t\t\t\t\t\t\t[19.232, 0.125]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 7,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-0.936, 0.277],\n\t\t\t\t\t\t\t\t\t\t\t[-0.936, 23.723],\n\t\t\t\t\t\t\t\t\t\t\t[18.172, 23.723],\n\t\t\t\t\t\t\t\t\t\t\t[18.172, 0.277]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 8,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-1.717, 0.12],\n\t\t\t\t\t\t\t\t\t\t\t[-1.717, 23.567],\n\t\t\t\t\t\t\t\t\t\t\t[17.39, 23.567],\n\t\t\t\t\t\t\t\t\t\t\t[17.39, 0.12]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 9,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-2.846, 0.282],\n\t\t\t\t\t\t\t\t\t\t\t[-2.846, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[16.261, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[16.261, 0.282]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 10,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-4.513, 0.282],\n\t\t\t\t\t\t\t\t\t\t\t[-4.513, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[14.595, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[14.595, 0.282]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 11,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-7.013, 0.615],\n\t\t\t\t\t\t\t\t\t\t\t[-7.013, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[12.095, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[12.095, 0.615]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 12,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-10.179, 0.615],\n\t\t\t\t\t\t\t\t\t\t\t[-10.179, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[8.928, 24.061],\n\t\t\t\t\t\t\t\t\t\t\t[8.928, 0.615]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"i\": { \"x\": 0.833, \"y\": 0.833 },\n\t\t\t\t\t\t\t\t\"o\": { \"x\": 0.167, \"y\": 0.167 },\n\t\t\t\t\t\t\t\t\"t\": 13,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-15.179, 0.115],\n\t\t\t\t\t\t\t\t\t\t\t[-15.179, 23.561],\n\t\t\t\t\t\t\t\t\t\t\t[3.928, 23.561],\n\t\t\t\t\t\t\t\t\t\t\t[3.928, 0.115]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"t\": 14,\n\t\t\t\t\t\t\t\t\"s\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t\t[-22.013, 0.282],\n\t\t\t\t\t\t\t\t\t\t\t[-22.013, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[-2.905, 23.728],\n\t\t\t\t\t\t\t\t\t\t\t[-2.905, 0.282]\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"ix\": 1\n\t\t\t\t\t},\n\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 3 },\n\t\t\t\t\t\"x\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\"nm\": \"Mask 1\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"shapes\": [\n\t\t\t\t{\n\t\t\t\t\t\"ty\": \"gr\",\n\t\t\t\t\t\"it\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ind\": 0,\n\t\t\t\t\t\t\t\"ty\": \"sh\",\n\t\t\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\t\t\"ks\": {\n\t\t\t\t\t\t\t\t\"a\": 0,\n\t\t\t\t\t\t\t\t\"k\": {\n\t\t\t\t\t\t\t\t\t\"i\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"o\": [\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t\t\t[0, 0]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"v\": [\n\t\t\t\t\t\t\t\t\t\t[-5, -8],\n\t\t\t\t\t\t\t\t\t\t[5, 0],\n\t\t\t\t\t\t\t\t\t\t[-5, 8]\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"c\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"ix\": 2\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"nm\": \"Path 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Shape - Group\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"st\",\n\t\t\t\t\t\t\t\"c\": { \"a\": 0, \"k\": [1, 1, 1, 1], \"ix\": 3 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 4 },\n\t\t\t\t\t\t\t\"w\": { \"a\": 0, \"k\": 2, \"ix\": 5 },\n\t\t\t\t\t\t\t\"lc\": 2,\n\t\t\t\t\t\t\t\"lj\": 2,\n\t\t\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\t\t\"nm\": \"Stroke 1\",\n\t\t\t\t\t\t\t\"mn\": \"ADBE Vector Graphic - Stroke\",\n\t\t\t\t\t\t\t\"hd\": false\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"ty\": \"tr\",\n\t\t\t\t\t\t\t\"p\": { \"a\": 0, \"k\": [10, 12], \"ix\": 2 },\n\t\t\t\t\t\t\t\"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n\t\t\t\t\t\t\t\"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n\t\t\t\t\t\t\t\"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n\t\t\t\t\t\t\t\"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n\t\t\t\t\t\t\t\"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n\t\t\t\t\t\t\t\"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n\t\t\t\t\t\t\t\"nm\": \"Transform\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"nm\": \"triangle\",\n\t\t\t\t\t\"np\": 2,\n\t\t\t\t\t\"cix\": 2,\n\t\t\t\t\t\"bm\": 0,\n\t\t\t\t\t\"ix\": 1,\n\t\t\t\t\t\"mn\": \"ADBE Vector Group\",\n\t\t\t\t\t\"hd\": false\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"ip\": 0,\n\t\t\t\"op\": 15,\n\t\t\t\"st\": 0,\n\t\t\t\"bm\": 0\n\t\t}\n\t],\n\t\"markers\": []\n}\n"
  },
  {
    "path": "apps/mobile/src/components/ErrorBoundary.tsx",
    "content": "import { StyleSheet, Text, View } from 'react-native'\nimport { Button } from 'react-native-paper'\n\nimport { flatErrorMessage } from '@/utils/log'\n\nexport default function GlobalErrorFallback({\n\terror,\n\tresetError,\n}: {\n\terror: unknown\n\tresetError: () => void\n}) {\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<Text style={styles.title}>发生未捕获错误</Text>\n\t\t\t<Text style={styles.message}>\n\t\t\t\t{error instanceof Error ? flatErrorMessage(error) : String(error)}\n\t\t\t</Text>\n\t\t\t<Button\n\t\t\t\tmode='contained'\n\t\t\t\tlabelStyle={styles.buttonLabel}\n\t\t\t\tonPress={resetError}\n\t\t\t>\n\t\t\t\t重试\n\t\t\t</Button>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 20,\n\t},\n\ttitle: {\n\t\tmarginBottom: 8,\n\t\tfontWeight: 'bold',\n\t\tfontSize: 20,\n\t},\n\tmessage: {\n\t\tmarginBottom: 20,\n\t\ttextAlign: 'center',\n\t},\n\tbuttonLabel: {\n\t\tfontWeight: 'bold',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/ModalRegistry.tsx",
    "content": "import type { ComponentType } from 'react'\nimport { lazy } from 'react'\n\nimport type { ModalKey, ModalPropsMap } from '@/types/navigation'\n\nconst AlertModal = lazy(() => import('./modals/AlertModal'))\nconst DonationQRModal = lazy(() => import('./modals/app/DonationQRModal'))\nconst UpdateAppModal = lazy(() => import('./modals/app/UpdateAppModal'))\nconst WelcomeModal = lazy(() => import('./modals/app/WelcomeModal'))\nconst AddToFavoriteListsModal = lazy(\n\t() => import('./modals/bilibili/AddVideoToBilibiliFavModal'),\n)\nconst EditPlaylistMetadataModal = lazy(\n\t() => import('./modals/edit-metadata/editPlaylistMetadataModal'),\n)\nconst EditTrackMetadataModal = lazy(\n\t() => import('./modals/edit-metadata/editTrackMetadataModal'),\n)\nconst CookieLoginModal = lazy(() => import('./modals/login/CookieLoginModal'))\nconst QrCodeLoginModal = lazy(() => import('./modals/login/QRCodeLoginModal'))\nconst PhoneLoginModal = lazy(() => import('./modals/login/PhoneLoginModal'))\nconst EditLyricsModal = lazy(() => import('./modals/lyrics/EditLyrics'))\nconst ManualSearchLyricsModal = lazy(\n\t() => import('./modals/lyrics/ManualSearchLyrics'),\n)\nconst SleepTimerModal = lazy(() => import('./modals/player/SleepTimerModal'))\nconst BatchAddTracksToLocalPlaylistModal = lazy(\n\t() => import('./modals/playlist/BatchAddTracksToLocalPlaylist'),\n)\nconst CreatePlaylistModal = lazy(\n\t() => import('./modals/playlist/CreatePlaylistModal'),\n)\nconst DuplicateLocalPlaylistModal = lazy(\n\t() => import('./modals/playlist/DuplicateLocalPlaylistModal'),\n)\nconst UpdateTrackLocalPlaylistsModal = lazy(\n\t() => import('./modals/playlist/UpdateTrackLocalPlaylistsModal'),\n)\nconst SaveQueueToPlaylistModal = lazy(\n\t() => import('./modals/playlist/SaveQueueToPlaylistModal'),\n)\nconst PlaybackSpeedModal = lazy(\n\t() => import('./modals/player/PlaybackSpeedModal'),\n)\nconst LyricsSelectionModal = lazy(\n\t() => import('./modals/player/LyricsSelectionModal'),\n)\nconst SongShareModal = lazy(() => import('./modals/player/SongShareModal'))\nconst SyncLocalToBilibiliModal = lazy(\n\t() => import('./modals/playlist/SyncLocalToBilibiliModal'),\n)\nconst FavoriteSyncProgressModal = lazy(\n\t() => import('./modals/playlist/FavoriteSyncProgressModal'),\n)\nconst ManualMatchExternalSyncModal = lazy(\n\t() => import('./modals/playlist/ManualMatchExternalSync'),\n)\n\nconst InputExternalPlaylistInfoModal = lazy(\n\t() => import('./modals/playlist/InputExternalPlaylistInfo'),\n)\nconst DanmakuSettingsModal = lazy(\n\t() => import('./modals/player/DanmakuSettingsModal'),\n)\nconst CoverDownloadProgressModal = lazy(\n\t() => import('./modals/settings/CoverDownloadProgressModal'),\n)\nconst EnableSharingModal = lazy(\n\t() => import('./modals/playlist/EnableSharingModal'),\n)\nconst SubscribeToSharedPlaylistModal = lazy(\n\t() => import('./modals/playlist/SubscribeToSharedPlaylistModal'),\n)\nconst MergePlaylistsModal = lazy(\n\t() => import('./modals/playlist/MergePlaylistsModal'),\n)\n\ntype ModalComponent<K extends ModalKey> = ComponentType<ModalPropsMap[K] & {}>\n\nexport const modalRegistry: { [K in ModalKey]: ModalComponent<K> } = {\n\tPlaybackSpeed: PlaybackSpeedModal,\n\tAddVideoToBilibiliFavorite: AddToFavoriteListsModal,\n\tEditPlaylistMetadata: EditPlaylistMetadataModal,\n\tEditTrackMetadata: EditTrackMetadataModal,\n\tBatchAddTracksToLocalPlaylist: BatchAddTracksToLocalPlaylistModal,\n\tCookieLogin: CookieLoginModal,\n\tQRCodeLogin: QrCodeLoginModal,\n\tPhoneLogin: PhoneLoginModal,\n\tCreatePlaylist: CreatePlaylistModal,\n\tUpdateApp: UpdateAppModal,\n\tWelcome: WelcomeModal,\n\tUpdateTrackLocalPlaylists: UpdateTrackLocalPlaylistsModal,\n\tDuplicateLocalPlaylist: DuplicateLocalPlaylistModal,\n\tManualSearchLyrics: ManualSearchLyricsModal,\n\tInputExternalPlaylistInfo: InputExternalPlaylistInfoModal,\n\tAlert: AlertModal,\n\tEditLyrics: EditLyricsModal,\n\tSleepTimer: SleepTimerModal,\n\tDonationQR: DonationQRModal,\n\tSaveQueueToPlaylist: SaveQueueToPlaylistModal,\n\tLyricsSelection: LyricsSelectionModal,\n\tSongShare: SongShareModal,\n\tSyncLocalToBilibili: SyncLocalToBilibiliModal,\n\tFavoriteSyncProgress: FavoriteSyncProgressModal,\n\tManualMatchExternalSync: ManualMatchExternalSyncModal,\n\tDanmakuSettings: DanmakuSettingsModal,\n\tCoverDownloadProgress: CoverDownloadProgressModal,\n\tEnableSharing: EnableSharingModal,\n\tSubscribeToSharedPlaylist: SubscribeToSharedPlaylistModal,\n\tMergePlaylists: MergePlaylistsModal,\n}\n"
  },
  {
    "path": "apps/mobile/src/components/NowPlayingBar.tsx",
    "content": "import {\n\tOrpheus,\n\tPlaybackState,\n\tuseIsPlaying,\n\tusePlaybackState,\n} from '@bbplayer/orpheus'\nimport { Image } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { memo, useLayoutEffect, useRef } from 'react'\nimport { Platform, StyleSheet, View } from 'react-native'\nimport {\n\tDirections,\n\tGesture,\n\tGestureDetector,\n\tRectButton,\n} from 'react-native-gesture-handler'\nimport { Icon, Text, useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedStyle,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\nimport { scheduleOnRN } from 'react-native-worklets'\n\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport useSmoothProgress from '@/hooks/player/useSmoothProgress'\nimport { useBottomTabBarHeight } from '@/hooks/router/useBottomTabBarHeight'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport * as Haptics from '@/utils/haptics'\n\nconst ProgressBar = memo(function ProgressBar() {\n\tconst { position: sharedProgress, duration: sharedDuration } =\n\t\tuseSmoothProgress(false)\n\tconst sharedTrackViewWidth = useSharedValue(0)\n\tconst trackViewRef = useRef<View>(null)\n\tconst { colors } = useTheme()\n\n\tconst animatedStyle = useAnimatedStyle(() => {\n\t\tconst progressRatio = Math.min(\n\t\t\tsharedProgress.value / Math.max(sharedDuration.value, 1),\n\t\t\t1,\n\t\t)\n\t\t// 靠 transform 实现滑动效果，避免掉 reflow\n\t\treturn {\n\t\t\ttransform: [\n\t\t\t\t{\n\t\t\t\t\ttranslateX: (progressRatio - 1) * sharedTrackViewWidth.value,\n\t\t\t\t},\n\t\t\t],\n\t\t}\n\t})\n\n\tuseLayoutEffect(() => {\n\t\ttrackViewRef.current?.measure((_x, _y, width) => {\n\t\t\tsharedTrackViewWidth.value = width\n\t\t})\n\t}, [sharedTrackViewWidth, trackViewRef])\n\n\treturn (\n\t\t<View style={styles.progressBarContainer}>\n\t\t\t<View\n\t\t\t\tref={trackViewRef}\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.progressBarTrack,\n\t\t\t\t\t// { backgroundColor: colors.outlineVariant },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<Animated.View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tanimatedStyle,\n\t\t\t\t\t\tstyles.progressBarIndicator,\n\t\t\t\t\t\t{ backgroundColor: colors.primary },\n\t\t\t\t\t]}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</View>\n\t)\n})\n\nconst NowPlayingBar = memo(function NowPlayingBar({\n\tbackgroundColor,\n}: {\n\tbackgroundColor?: string\n}) {\n\tconst { colors } = useTheme()\n\tconst isPlaying = useIsPlaying()\n\tconst state = usePlaybackState()\n\tconst currentTrack = useCurrentTrack()\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\tconst opacity = useSharedValue(1)\n\tconst isVisible = currentTrack !== null\n\tconst bottomBarHeight = useBottomTabBarHeight()\n\n\tconst nowPlayingBarStyle = useAppStore(\n\t\t(state) => state.settings.nowPlayingBarStyle,\n\t)\n\n\tconst finalPlayingIndicator =\n\t\tstate === PlaybackState.BUFFERING ? 'loading' : isPlaying ? 'pause' : 'play'\n\n\tconst prevTap = Gesture.Tap().onEnd((_e, success) => {\n\t\tif (success) {\n\t\t\tscheduleOnRN(Haptics.performHaptics, Haptics.AndroidHaptics.Context_Click)\n\t\t\tscheduleOnRN(() => Orpheus.skipToPrevious())\n\t\t}\n\t})\n\tconst playTap = Gesture.Tap().onEnd((_e, success) => {\n\t\tif (success) {\n\t\t\tscheduleOnRN(Haptics.performHaptics, Haptics.AndroidHaptics.Context_Click)\n\t\t\tscheduleOnRN(async (_isPlaying) => {\n\t\t\t\tconst isPlaying = await Orpheus.getIsPlaying()\n\t\t\t\tif (isPlaying) {\n\t\t\t\t\tvoid Orpheus.pause()\n\t\t\t\t} else {\n\t\t\t\t\t// 或许可以解决 play 无响应的问题？\n\t\t\t\t\tawait Orpheus.pause()\n\t\t\t\t\tawait Orpheus.play()\n\t\t\t\t}\n\t\t\t}, isPlaying)\n\t\t}\n\t})\n\tconst nextTap = Gesture.Tap().onEnd((_e, success) => {\n\t\tif (success) {\n\t\t\tscheduleOnRN(Haptics.performHaptics, Haptics.AndroidHaptics.Context_Click)\n\t\t\tscheduleOnRN(() => Orpheus.skipToNext())\n\t\t}\n\t})\n\n\tconst navigateOnPlayerUpFling = Gesture.Fling()\n\t\t.direction(Directions.UP)\n\t\t.onStart(() => {\n\t\t\tscheduleOnRN(router.navigate, '/player')\n\t\t})\n\n\tconst preFling = Gesture.Fling()\n\t\t.direction(Directions.LEFT)\n\t\t.onStart(() => {\n\t\t\tscheduleOnRN(() => Orpheus.skipToPrevious())\n\t\t})\n\n\tconst nextFling = Gesture.Fling()\n\t\t.direction(Directions.RIGHT)\n\t\t.onStart(() => {\n\t\t\tscheduleOnRN(() => Orpheus.skipToNext())\n\t\t})\n\n\tconst outerTap = Gesture.Tap()\n\t\t.requireExternalGestureToFail(\n\t\t\tprevTap,\n\t\t\tplayTap,\n\t\t\tnextTap,\n\t\t\tnavigateOnPlayerUpFling,\n\t\t\tpreFling,\n\t\t\tnextFling,\n\t\t)\n\t\t.onBegin(() => {\n\t\t\topacity.value = withTiming(0.7, { duration: 100 })\n\t\t})\n\t\t.onFinalize((_e, success) => {\n\t\t\topacity.value = withTiming(1, { duration: 100 })\n\n\t\t\tif (success) {\n\t\t\t\tscheduleOnRN(router.navigate, '/player')\n\t\t\t}\n\t\t})\n\n\tconst combinedGesture = Gesture.Race(\n\t\tnavigateOnPlayerUpFling,\n\t\tpreFling,\n\t\tnextFling,\n\t\touterTap,\n\t)\n\n\tconst playerStyle =\n\t\tnowPlayingBarStyle === 'bottom'\n\t\t\t? [styles.nowPlayingBarBottom]\n\t\t\t: [styles.nowPlayingBarFloat]\n\n\tconst animatedStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\topacity: opacity.get(),\n\t\t}\n\t})\n\n\tlet bottomMargin = 0\n\tif (Platform.OS === 'ios') {\n\t\tif (bottomBarHeight === 0) {\n\t\t\tbottomMargin = insets.bottom + 10\n\t\t} else {\n\t\t\tbottomMargin = 10 + bottomBarHeight\n\t\t}\n\t} else {\n\t\tbottomMargin = nowPlayingBarStyle === 'bottom' ? 0 : insets.bottom + 10\n\t}\n\n\treturn (\n\t\t<View\n\t\t\tpointerEvents='box-none'\n\t\t\tstyle={styles.nowPlayingBarContainer}\n\t\t>\n\t\t\t{isVisible && (\n\t\t\t\t<GestureDetector gesture={combinedGesture}>\n\t\t\t\t\t<Animated.View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tplayerStyle,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tbackgroundColor: backgroundColor ?? colors.elevation.level2,\n\t\t\t\t\t\t\t\tmarginBottom: bottomMargin,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tanimatedStyle,\n\t\t\t\t\t\t]}\n\t\t\t\t\t\ttestID='now-playing-bar'\n\t\t\t\t\t>\n\t\t\t\t\t\t<View style={styles.nowPlayingBarContent}>\n\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\tsource={{\n\t\t\t\t\t\t\t\t\turi:\n\t\t\t\t\t\t\t\t\t\tresolveTrackCover(\n\t\t\t\t\t\t\t\t\t\t\tcurrentTrack.uniqueKey,\n\t\t\t\t\t\t\t\t\t\t\tcurrentTrack.coverUrl,\n\t\t\t\t\t\t\t\t\t\t) ?? undefined,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.nowPlayingBarImage,\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tborderColor: colors.primary,\n\t\t\t\t\t\t\t\t\t\tborderRadius: nowPlayingBarStyle === 'bottom' ? 12 : 24,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\trecyclingKey={currentTrack.uniqueKey}\n\t\t\t\t\t\t\t\tcachePolicy={'disk'}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t<View style={styles.nowPlayingBarTextContainer}>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='titleSmall'\n\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurface }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{currentTrack.title ?? '未知曲目'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{currentTrack.artist?.name ?? '未知'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t\t<View style={styles.nowPlayingBarControls}>\n\t\t\t\t\t\t\t\t<GestureDetector gesture={prevTap}>\n\t\t\t\t\t\t\t\t\t<RectButton style={styles.nowPlayingBarControlButton}>\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource='skip-previous'\n\t\t\t\t\t\t\t\t\t\t\tsize={16}\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.onSurface}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t\t\t\t</GestureDetector>\n\n\t\t\t\t\t\t\t\t<GestureDetector gesture={playTap}>\n\t\t\t\t\t\t\t\t\t<RectButton style={styles.nowPlayingBarControlButton}>\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource={finalPlayingIndicator}\n\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t\t\t\t</GestureDetector>\n\n\t\t\t\t\t\t\t\t<GestureDetector gesture={nextTap}>\n\t\t\t\t\t\t\t\t\t<RectButton style={styles.nowPlayingBarControlButton}>\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource='skip-next'\n\t\t\t\t\t\t\t\t\t\t\tsize={16}\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.onSurface}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t\t\t\t</GestureDetector>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.nowPlayingBarProgressContainer,\n\t\t\t\t\t\t\t\tnowPlayingBarStyle === 'bottom'\n\t\t\t\t\t\t\t\t\t? { left: 0, right: 0 }\n\t\t\t\t\t\t\t\t\t: { width: '88%', left: 26, right: 0 },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ProgressBar />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</Animated.View>\n\t\t\t\t</GestureDetector>\n\t\t\t)}\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tprogressBarContainer: {\n\t\twidth: '100%',\n\t},\n\tprogressBarTrack: {\n\t\theight: 2,\n\t\toverflow: 'hidden',\n\t\tposition: 'relative',\n\t},\n\tprogressBarIndicator: {\n\t\theight: 2,\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\ttop: 0,\n\t\tbottom: 0,\n\t\tright: 0,\n\t},\n\tnowPlayingBarContainer: {\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\tright: 0,\n\t\tbottom: 0,\n\t},\n\tnowPlayingBarBottom: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tborderTopLeftRadius: 24,\n\t\tborderTopRightRadius: 24,\n\t\tpaddingHorizontal: 20,\n\t\tposition: 'relative',\n\t\theight: 70,\n\t},\n\tnowPlayingBarFloat: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tborderRadius: 24,\n\t\tmarginHorizontal: 20,\n\t\tposition: 'relative',\n\t\theight: 48,\n\t\tshadowColor: '#000',\n\t\tshadowOffset: {\n\t\t\twidth: 0,\n\t\t\theight: 3,\n\t\t},\n\t\tshadowOpacity: 0.29,\n\t\tshadowRadius: 4.65,\n\t\televation: 7,\n\t},\n\tnowPlayingBarContent: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tnowPlayingBarImage: {\n\t\theight: 48,\n\t\twidth: 48,\n\t\tborderWidth: 1,\n\t\tzIndex: 2,\n\t},\n\tnowPlayingBarTextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\tmarginRight: 8,\n\t},\n\tnowPlayingBarControls: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginRight: 4,\n\t},\n\tnowPlayingBarControlButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n\tnowPlayingBarProgressContainer: {\n\t\talignSelf: 'center',\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tzIndex: 1,\n\t},\n})\n\nNowPlayingBar.displayName = 'NowPlayingBar'\n\nexport default NowPlayingBar\n"
  },
  {
    "path": "apps/mobile/src/components/common/AnimatedModalOverlay.tsx",
    "content": "import { useState } from 'react'\nimport type { ViewStyle } from 'react-native'\nimport { Pressable, StyleSheet } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'\nimport { useTheme } from 'react-native-paper'\nimport {\n\tcreateAnimatedComponent,\n\tuseAnimatedStyle,\n} from 'react-native-reanimated'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\ninterface Props {\n\tvisible: boolean\n\tonDismiss: () => void\n\tchildren?: React.ReactNode\n\tcontentStyle?: ViewStyle\n}\n\nconst AnimatedPressable = createAnimatedComponent(Pressable)\n\nexport default function AnimatedModalOverlay({\n\tvisible,\n\tonDismiss,\n\tchildren,\n\tcontentStyle,\n}: Props) {\n\tconst insets = useSafeAreaInsets()\n\tconst { height } = useReanimatedKeyboardAnimation()\n\tconst theme = useTheme()\n\tconst [showContent, setShowContent] = useState(false)\n\n\tconst wrapperAvoiding = useAnimatedStyle(() => {\n\t\tconst k = Math.max(0, Math.abs(height.value) - insets.bottom)\n\t\treturn { paddingBottom: k }\n\t})\n\n\tif (!visible) return null\n\n\treturn (\n\t\t<AnimatedPressable\n\t\t\tstyle={[styles.wrapper, wrapperAvoiding]}\n\t\t\tonPress={onDismiss}\n\t\t>\n\t\t\t<Pressable\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.content,\n\t\t\t\t\t{\n\t\t\t\t\t\tmarginHorizontal: Math.max(insets.left, insets.right, 26),\n\t\t\t\t\t\topacity: showContent ? 1 : 0,\n\t\t\t\t\t},\n\t\t\t\t]}\n\t\t\t\tonLayout={(e) => {\n\t\t\t\t\tsetShowContent(\n\t\t\t\t\t\te.nativeEvent.layout.height > 0 && e.nativeEvent.layout.width > 0,\n\t\t\t\t\t)\n\t\t\t\t}}\n\t\t\t\tonPress={(e) => e.stopPropagation()}\n\t\t\t>\n\t\t\t\t<SquircleView\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.contentInner,\n\t\t\t\t\t\t{ backgroundColor: theme.colors.surface },\n\t\t\t\t\t\tcontentStyle,\n\t\t\t\t\t]}\n\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t</SquircleView>\n\t\t\t</Pressable>\n\t\t</AnimatedPressable>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\twrapper: {\n\t\t...StyleSheet.absoluteFill,\n\t\tjustifyContent: 'center',\n\t\tbackgroundColor: 'rgba(0,0,0,0.5)',\n\t\tzIndex: 1000,\n\t},\n\tcontent: {\n\t\tmaxHeight: '85%',\n\t},\n\tcontentInner: {\n\t\tpaddingTop: 10,\n\t\televation: 24,\n\t\tborderRadius: 32,\n\t\toverflow: 'hidden',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/common/Button.tsx",
    "content": "import color from 'color'\nimport { type ComponentRef, forwardRef } from 'react'\nimport type { StyleProp, TextStyle, ViewStyle } from 'react-native'\nimport { StyleSheet, View } from 'react-native'\nimport { BaseButton } from 'react-native-gesture-handler'\nimport {\n\tActivityIndicator,\n\tIcon,\n\tSurface,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport type { MD3Theme } from 'react-native-paper'\nimport type { IconSource } from 'react-native-paper/lib/typescript/components/Icon'\n\nexport type ButtonMode =\n\t| 'text'\n\t| 'outlined'\n\t| 'contained'\n\t| 'elevated'\n\t| 'contained-tonal'\n\nexport interface ButtonProps {\n\t/**\n\t * Mode of the button. You can change the mode to adjust the styling to give it desired emphasis.\n\t * - `text` - flat button without background or outline (low emphasis)\n\t * - `outlined` - button with an outline (medium emphasis)\n\t * - `contained` - button with a background color and elevation shadow (high emphasis)\n\t * - `elevated` - button with a background color and elevation shadow, less prominent than contained (high emphasis)\n\t * - `contained-tonal` - button with a secondary background color and no elevation shadow (high emphasis)\n\t */\n\tmode?: ButtonMode\n\t/**\n\t * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch.\n\t */\n\tdisabled?: boolean\n\t/**\n\t * Whether to show a loading indicator.\n\t */\n\tloading?: boolean\n\t/**\n\t * Icon to display for the `Button`.\n\t */\n\ticon?: IconSource\n\t/**\n\t * Label text of the button.\n\t */\n\tchildren: React.ReactNode\n\t/**\n\t * Custom text color for flat button, or the icon size.\n\t */\n\ttextColor?: string\n\t/**\n\t * Custom button color.\n\t */\n\tbuttonColor?: string\n\t/**\n\t * Color of the ripple effect.\n\t */\n\trippleColor?: string\n\t/**\n\t * Whether the button should be compact.\n\t */\n\tcompact?: boolean\n\t/**\n\t * Style of button's inner content.\n\t * Use this prop to apply custom height and width and to set the icon on the right with `flexDirection: 'row-reverse'`.\n\t */\n\tcontentStyle?: StyleProp<ViewStyle>\n\tstyle?: StyleProp<ViewStyle>\n\t/**\n\t * Style for the button text.\n\t */\n\tlabelStyle?: StyleProp<TextStyle>\n\t/**\n\t * Function to execute on press.\n\t */\n\tonPress?: () => void\n\t/**\n\t * TestID used for testing purposes\n\t */\n\ttestID?: string\n}\n\nconst getButtonColors = ({\n\ttheme,\n\tmode,\n\tcustomButtonColor,\n\tcustomTextColor,\n\tdisabled,\n}: {\n\ttheme: MD3Theme\n\tmode: ButtonMode\n\tcustomButtonColor?: string\n\tcustomTextColor?: string\n\tdisabled?: boolean\n}) => {\n\tconst isMode = (m: ButtonMode) => mode === m\n\n\tif (disabled) {\n\t\t// Disabled states\n\t\tif (isMode('outlined')) {\n\t\t\treturn {\n\t\t\t\tbackgroundColor: 'transparent',\n\t\t\t\tborderColor: theme.colors.surfaceDisabled,\n\t\t\t\ttextColor: theme.colors.onSurfaceDisabled,\n\t\t\t\tborderWidth: 1,\n\t\t\t}\n\t\t}\n\t\tif (isMode('text')) {\n\t\t\treturn {\n\t\t\t\tbackgroundColor: 'transparent',\n\t\t\t\tborderColor: 'transparent',\n\t\t\t\ttextColor: theme.colors.onSurfaceDisabled,\n\t\t\t\tborderWidth: 0,\n\t\t\t}\n\t\t}\n\t\t// contained, elevated, contained-tonal\n\t\treturn {\n\t\t\tbackgroundColor: theme.colors.surfaceDisabled,\n\t\t\tborderColor: 'transparent',\n\t\t\ttextColor: theme.colors.onSurfaceDisabled,\n\t\t\tborderWidth: 0,\n\t\t}\n\t}\n\n\t// Active states\n\tlet backgroundColor = customButtonColor\n\tlet textColor = customTextColor\n\tlet borderColor = 'transparent'\n\tlet borderWidth = 0\n\n\tif (isMode('contained')) {\n\t\tbackgroundColor = customButtonColor ?? theme.colors.primary\n\t\ttextColor = customTextColor ?? theme.colors.onPrimary\n\t} else if (isMode('contained-tonal')) {\n\t\tbackgroundColor = customButtonColor ?? theme.colors.secondaryContainer\n\t\ttextColor = customTextColor ?? theme.colors.onSecondaryContainer\n\t} else if (isMode('elevated')) {\n\t\tbackgroundColor = customButtonColor ?? theme.colors.surface\n\t\ttextColor = customTextColor ?? theme.colors.primary\n\t} else if (isMode('outlined')) {\n\t\tbackgroundColor = customButtonColor ?? 'transparent'\n\t\ttextColor = customTextColor ?? theme.colors.primary\n\t\tborderColor = theme.colors.outline\n\t\tborderWidth = 1\n\t} else if (isMode('text')) {\n\t\tbackgroundColor = customButtonColor ?? 'transparent'\n\t\ttextColor = customTextColor ?? theme.colors.primary\n\t}\n\n\treturn {\n\t\tbackgroundColor,\n\t\tborderColor,\n\t\ttextColor,\n\t\tborderWidth,\n\t}\n}\n\nconst Button = forwardRef<ComponentRef<typeof BaseButton>, ButtonProps>(\n\t(\n\t\t{\n\t\t\tmode = 'text',\n\t\t\tdisabled,\n\t\t\tloading,\n\t\t\ticon,\n\t\t\tchildren,\n\t\t\ttextColor: customTextColor,\n\t\t\tbuttonColor: customButtonColor,\n\t\t\trippleColor: customRippleColor,\n\t\t\tcompact,\n\t\t\tcontentStyle,\n\t\t\tstyle,\n\t\t\tlabelStyle,\n\t\t\tonPress,\n\t\t\ttestID = 'button',\n\t\t\t...rest\n\t\t},\n\t\tref,\n\t) => {\n\t\tconst theme = useTheme()\n\t\tconst { backgroundColor, borderColor, textColor, borderWidth } =\n\t\t\tgetButtonColors({\n\t\t\t\ttheme,\n\t\t\t\tmode,\n\t\t\t\tcustomButtonColor,\n\t\t\t\tcustomTextColor,\n\t\t\t\tdisabled,\n\t\t\t})\n\n\t\tconst rippleColor =\n\t\t\tcustomRippleColor ?? color(textColor).alpha(0.12).rgb().string()\n\n\t\tconst font = theme.fonts.labelLarge\n\n\t\tconst isMode = (m: ButtonMode) => mode === m\n\t\tconst hasElevation = isMode('elevated') || isMode('contained')\n\n\t\tconst borderRadius = theme.roundness * 5\n\n\t\tconst iconStyle =\n\t\t\tStyleSheet.flatten(contentStyle)?.flexDirection === 'row-reverse'\n\t\t\t\t? [\n\t\t\t\t\t\tstyles.iconReverse,\n\t\t\t\t\t\tstyles[`md3IconReverse${compact ? 'Compact' : ''}`],\n\t\t\t\t\t\tisMode('text') &&\n\t\t\t\t\t\t\tstyles[`md3IconReverseTextMode${compact ? 'Compact' : ''}`],\n\t\t\t\t\t]\n\t\t\t\t: [\n\t\t\t\t\t\tstyles.icon,\n\t\t\t\t\t\tstyles[`md3Icon${compact ? 'Compact' : ''}`],\n\t\t\t\t\t\tisMode('text') &&\n\t\t\t\t\t\t\tstyles[`md3IconTextMode${compact ? 'Compact' : ''}`],\n\t\t\t\t\t]\n\n\t\treturn (\n\t\t\t<Surface\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.surface,\n\t\t\t\t\t{\n\t\t\t\t\t\tborderRadius,\n\t\t\t\t\t\tbackgroundColor,\n\t\t\t\t\t\tborderColor,\n\t\t\t\t\t\tborderWidth,\n\t\t\t\t\t},\n\t\t\t\t\thasElevation &&\n\t\t\t\t\t\t!disabled && { elevation: isMode('elevated') ? 1 : 2 },\n\t\t\t\t\tstyle,\n\t\t\t\t]}\n\t\t\t\televation={hasElevation && !disabled ? (isMode('elevated') ? 1 : 2) : 0}\n\t\t\t>\n\t\t\t\t<BaseButton\n\t\t\t\t\tref={ref}\n\t\t\t\t\tonPress={onPress}\n\t\t\t\t\tenabled={!disabled}\n\t\t\t\t\trippleColor={rippleColor}\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.button,\n\t\t\t\t\t\tcompact && styles.compact,\n\t\t\t\t\t\tcontentStyle,\n\t\t\t\t\t\t{ borderRadius },\n\t\t\t\t\t]}\n\t\t\t\t\ttestID={testID}\n\t\t\t\t\t{...rest}\n\t\t\t\t>\n\t\t\t\t\t<View style={[styles.content]}>\n\t\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\t\tsize={18}\n\t\t\t\t\t\t\t\tcolor={textColor}\n\t\t\t\t\t\t\t\tstyle={iconStyle}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : icon ? (\n\t\t\t\t\t\t\t<View style={iconStyle}>\n\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\tsource={icon}\n\t\t\t\t\t\t\t\t\tsize={18}\n\t\t\t\t\t\t\t\t\tcolor={textColor}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\trole='button'\n\t\t\t\t\t\t\tvariant='labelLarge'\n\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.label,\n\t\t\t\t\t\t\t\t{ color: textColor },\n\t\t\t\t\t\t\t\tfont,\n\t\t\t\t\t\t\t\tisMode('text')\n\t\t\t\t\t\t\t\t\t? icon || loading\n\t\t\t\t\t\t\t\t\t\t? styles.md3LabelTextAddons\n\t\t\t\t\t\t\t\t\t\t: styles.md3LabelText\n\t\t\t\t\t\t\t\t\t: styles.md3Label,\n\t\t\t\t\t\t\t\tcompact && styles.compactLabel,\n\t\t\t\t\t\t\t\tlabelStyle,\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{children}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t</BaseButton>\n\t\t\t</Surface>\n\t\t)\n\t},\n)\n\nButton.displayName = 'Button'\n\nconst styles = StyleSheet.create({\n\tsurface: {\n\t\tminWidth: 64,\n\t\tborderStyle: 'solid',\n\t},\n\tbutton: {\n\t\tminWidth: 64,\n\t\tborderStyle: 'solid',\n\t\toverflow: 'hidden',\n\t},\n\tcompact: {\n\t\tminWidth: 'auto',\n\t},\n\tcontent: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\ticon: {\n\t\tmarginLeft: 12,\n\t\tmarginRight: -4,\n\t},\n\ticonReverse: {\n\t\tmarginRight: 12,\n\t\tmarginLeft: -4,\n\t},\n\tmd3Icon: {\n\t\tmarginLeft: 16,\n\t\tmarginRight: -16,\n\t},\n\tmd3IconCompact: {\n\t\tmarginLeft: 8,\n\t\tmarginRight: 0,\n\t},\n\tmd3IconReverse: {\n\t\tmarginLeft: -16,\n\t\tmarginRight: 16,\n\t},\n\tmd3IconReverseCompact: {\n\t\tmarginLeft: 0,\n\t\tmarginRight: 8,\n\t},\n\tmd3IconTextMode: {\n\t\tmarginLeft: 12,\n\t\tmarginRight: -8,\n\t},\n\tmd3IconTextModeCompact: {\n\t\tmarginLeft: 6,\n\t\tmarginRight: 0,\n\t},\n\tmd3IconReverseTextMode: {\n\t\tmarginLeft: -8,\n\t\tmarginRight: 12,\n\t},\n\tmd3IconReverseTextModeCompact: {\n\t\tmarginLeft: 0,\n\t\tmarginRight: 6,\n\t},\n\tlabel: {\n\t\ttextAlign: 'center',\n\t\tletterSpacing: 0.1,\n\t\tlineHeight: 20,\n\t\tmarginVertical: 9,\n\t\tmarginHorizontal: 16,\n\t},\n\tcompactLabel: {\n\t\tmarginHorizontal: 8,\n\t},\n\tmd3Label: {\n\t\tmarginVertical: 10,\n\t\tmarginHorizontal: 24,\n\t},\n\tmd3LabelText: {\n\t\tmarginHorizontal: 12,\n\t},\n\tmd3LabelTextAddons: {\n\t\tmarginHorizontal: 16,\n\t},\n})\n\nexport default Button\n"
  },
  {
    "path": "apps/mobile/src/components/common/CoverWithPlaceHolder.tsx",
    "content": "import type { ImageRef } from 'expo-image'\nimport { Image } from 'expo-image'\nimport { LinearGradient } from 'expo-linear-gradient'\nimport { memo, useMemo } from 'react'\nimport type { ColorSchemeName, StyleProp, ViewStyle } from 'react-native'\nimport { StyleSheet, Text, useColorScheme } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { runes } from 'runes2'\n\nimport { getGradientColors } from '@/utils/color'\n\n/**\n * 组件 Props 定义\n */\ninterface CoverWithPlaceHolderProps {\n\t/**\n\t * 用于 recyclingKey 的唯一 ID (来自 item.id)\n\t */\n\tid: string | number\n\t/**\n\t * 用于生成渐变和首字母的标题 (来自 item.title)\n\t */\n\ttitle: string\n\t/**\n\t * 封面图片源 (URL string or ImageRef)\n\t */\n\tcover?: string | null | undefined | ImageRef\n\t/**\n\t * 封面/占位符的尺寸（宽高相同）\n\t */\n\tsize: number\n\t/**\n\t * 圆角\n\t */\n\tborderRadius?: number\n\t/**\n\t * 允许外部传入的容器样式\n\t */\n\tstyle?: StyleProp<ViewStyle>\n\t/**\n\t * 图片缓存策略\n\t */\n\tcachePolicy?: 'none' | 'memory' | 'disk' | 'memory-disk' | null | undefined\n}\n\n/**\n * 一个带渐变占位符的封面组件\n * 它会始终显示渐变占位符，如果 cover 存在，\n * 则会将图片淡入显示在占位符之上。\n */\nconst CoverWithPlaceHolder = memo(function CoverWithPlaceHolder({\n\tid,\n\ttitle,\n\tcover,\n\tsize,\n\tborderRadius,\n\tcachePolicy = 'disk',\n\tstyle,\n}: CoverWithPlaceHolderProps) {\n\tconst colorScheme: ColorSchemeName = useColorScheme()\n\tconst isDark: boolean = colorScheme === 'dark'\n\n\tconst computedBorderRadius = borderRadius ?? size * 0.22\n\n\tconst validTitle = title.trim()\n\tconst { color1, color2 } = getGradientColors(\n\t\tvalidTitle ? validTitle : String(id),\n\t\tisDark,\n\t)\n\n\tconst firstChar =\n\t\tvalidTitle.length > 0 ? runes(validTitle)[0].toUpperCase() : undefined\n\n\tconst coverSource = useMemo(() => {\n\t\tif (typeof cover === 'string') {\n\t\t\treturn { uri: cover }\n\t\t}\n\t\treturn cover\n\t}, [cover])\n\tconst policy =\n\t\ttypeof cover === 'string' && cover.startsWith('file://')\n\t\t\t? 'none'\n\t\t\t: cachePolicy\n\n\treturn (\n\t\t<SquircleView\n\t\t\tstyle={[\n\t\t\t\tstyles.container,\n\t\t\t\t{ width: size, height: size, borderRadius: computedBorderRadius },\n\t\t\t\tstyle,\n\t\t\t]}\n\t\t\tcornerSmoothing={0.6}\n\t\t>\n\t\t\t<LinearGradient\n\t\t\t\tcolors={[color1, color2]}\n\t\t\t\tstyle={styles.gradient}\n\t\t\t\tstart={{ x: 0, y: 0 }}\n\t\t\t\tend={{ x: 1, y: 1 }}\n\t\t\t>\n\t\t\t\t<Text style={[styles.placeholderText, { fontSize: size * 0.45 }]}>\n\t\t\t\t\t{firstChar}\n\t\t\t\t</Text>\n\t\t\t</LinearGradient>\n\n\t\t\t<Image\n\t\t\t\tsource={coverSource}\n\t\t\t\trecyclingKey={String(id)}\n\t\t\t\tstyle={[styles.image, { width: size, height: size }]}\n\t\t\t\ttransition={0}\n\t\t\t\tcachePolicy={policy}\n\t\t\t/>\n\t\t</SquircleView>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\toverflow: 'hidden',\n\t},\n\tgradient: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tplaceholderText: {\n\t\tfontWeight: 'bold',\n\t\tcolor: 'rgba(255, 255, 255, 0.7)',\n\t},\n\timage: {\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\ttop: 0,\n\t},\n})\n\nCoverWithPlaceHolder.displayName = 'CoverWithPlaceHolder'\n\nexport default CoverWithPlaceHolder\n"
  },
  {
    "path": "apps/mobile/src/components/common/FunctionalMenu.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { memo, useCallback, useEffect, useState } from 'react'\nimport { View } from 'react-native'\nimport { Menu } from 'react-native-paper'\n\nimport * as Haptics from '@/utils/haptics'\n\ntype FunctionalMenuProps = PropsWithChildren<Parameters<typeof Menu>[0]>\n\nconst FunctionalMenu = memo(function FunctionalMenu({\n\tchildren,\n\tonDismiss,\n\tvisible,\n\t...props\n}: FunctionalMenuProps) {\n\tconst [showContent, setShowContent] = useState(false)\n\tconst onClose = useCallback(() => {\n\t\tsetShowContent(false)\n\t\tonDismiss?.()\n\t}, [onDismiss])\n\n\tuseEffect(() => {\n\t\tif (visible) {\n\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Context_Click)\n\t\t}\n\t}, [visible])\n\n\treturn (\n\t\t<>\n\t\t\t<Menu\n\t\t\t\t{...props}\n\t\t\t\tonDismiss={onClose}\n\t\t\t\tvisible={visible}\n\t\t\t\tkey={String(visible)}\n\t\t\t\tstyle={[{ opacity: showContent ? 1 : 0 }, props.style]}\n\t\t\t>\n\t\t\t\t<View\n\t\t\t\t\t// new arch issue: 第一次打开 Menu 时会有闪烁，采用这种方法躲闪...\n\t\t\t\t\tonLayout={() => {\n\t\t\t\t\t\tsetTimeout(() => setShowContent(true), 100)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t{children}\n\t\t\t</Menu>\n\t\t</>\n\t)\n})\nexport default FunctionalMenu\n"
  },
  {
    "path": "apps/mobile/src/components/common/IconButton.tsx",
    "content": "import { forwardRef, type ComponentRef } from 'react'\nimport type { StyleProp, ViewStyle } from 'react-native'\nimport { StyleSheet, View } from 'react-native'\nimport { BaseButton } from 'react-native-gesture-handler'\nimport { ActivityIndicator, Icon, useTheme } from 'react-native-paper'\nimport type { MD3Theme } from 'react-native-paper'\nimport type { IconSource } from 'react-native-paper/lib/typescript/components/Icon'\n\ntype IconButtonMode = 'outlined' | 'contained' | 'contained-tonal'\n\nexport interface IconButtonProps {\n\t/**\n\t * Icon to display.\n\t */\n\ticon: IconSource\n\t/**\n\t * Mode of the icon button. By default there is no specified mode - only pressable icon will be rendered.\n\t */\n\tmode?: IconButtonMode\n\t/**\n\t * Color of the icon.\n\t */\n\ticonColor?: string\n\t/**\n\t * Background color of the icon container.\n\t */\n\tcontainerColor?: string\n\t/**\n\t * Color of the ripple effect.\n\t */\n\trippleColor?: string\n\t/**\n\t * Whether icon button is selected. A selected button receives alternative combination of icon and container colors.\n\t */\n\tselected?: boolean\n\t/**\n\t * Size of the icon.\n\t */\n\tsize?: number\n\t/**\n\t * Whether the button is disabled. A disabled button is greyed out and `onPress` is not called on touch.\n\t */\n\tdisabled?: boolean\n\t/**\n\t * Whether an icon change is animated.\n\t */\n\tanimated?: boolean\n\t/**\n\t * Style of button's inner content.\n\t * Use this prop to apply custom height and width or to set a custom padding`.\n\t */\n\tcontentStyle?: StyleProp<ViewStyle>\n\tstyle?: StyleProp<ViewStyle>\n\t/**\n\t * Function to execute on press.\n\t */\n\tonPress?: () => void\n\t/**\n\t * TestID used for testing purposes\n\t */\n\ttestID?: string\n\t/**\n\t * Whether to show a loading indicator.\n\t */\n\tloading?: boolean\n}\n\n// Extracted from react-native-paper/src/components/IconButton/utils.ts\nconst getIconButtonColor = ({\n\ttheme,\n\tdisabled,\n\tmode,\n\tselected,\n\tcustomIconColor,\n\tcustomContainerColor,\n}: {\n\ttheme: MD3Theme\n\tdisabled?: boolean\n\tselected?: boolean\n\tmode?: IconButtonMode\n\tcustomIconColor?: string\n\tcustomContainerColor?: string\n}) => {\n\tif (disabled) {\n\t\treturn {\n\t\t\ticonColor: theme.colors.onSurfaceDisabled,\n\t\t\tbackgroundColor:\n\t\t\t\tmode === 'contained' || mode === 'contained-tonal'\n\t\t\t\t\t? theme.colors.surfaceDisabled\n\t\t\t\t\t: undefined,\n\t\t\tborderColor: undefined,\n\t\t}\n\t}\n\n\tlet iconColor = customIconColor\n\tlet backgroundColor = customContainerColor\n\tlet borderColor\n\n\tif (mode === 'contained') {\n\t\tif (selected) {\n\t\t\tbackgroundColor = backgroundColor ?? theme.colors.primary\n\t\t\ticonColor = iconColor ?? theme.colors.onPrimary\n\t\t} else {\n\t\t\tbackgroundColor = backgroundColor ?? theme.colors.surfaceVariant\n\t\t\ticonColor = iconColor ?? theme.colors.primary\n\t\t}\n\t} else if (mode === 'contained-tonal') {\n\t\tif (selected) {\n\t\t\tbackgroundColor = backgroundColor ?? theme.colors.secondaryContainer\n\t\t\ticonColor = iconColor ?? theme.colors.onSecondaryContainer\n\t\t} else {\n\t\t\tbackgroundColor = backgroundColor ?? theme.colors.surfaceVariant\n\t\t\ticonColor = iconColor ?? theme.colors.onSurfaceVariant\n\t\t}\n\t} else if (mode === 'outlined') {\n\t\tborderColor = theme.colors.outline\n\t\tif (selected) {\n\t\t\tbackgroundColor = backgroundColor ?? theme.colors.inverseSurface\n\t\t\ticonColor = iconColor ?? theme.colors.inverseOnSurface\n\t\t} else {\n\t\t\ticonColor = iconColor ?? theme.colors.onSurfaceVariant\n\t\t}\n\t} else {\n\t\t// Standard (no mode)\n\t\tif (selected) {\n\t\t\ticonColor = iconColor ?? theme.colors.primary\n\t\t} else {\n\t\t\ticonColor = iconColor ?? theme.colors.onSurfaceVariant\n\t\t}\n\t}\n\n\t// Fallback for non-V3 themes or simple overrides if needed\n\tif (!iconColor) {\n\t\ticonColor = theme.colors.onSurface\n\t}\n\n\treturn {\n\t\ticonColor,\n\t\tbackgroundColor,\n\t\tborderColor,\n\t}\n}\n\nconst IconButton = forwardRef<ComponentRef<typeof BaseButton>, IconButtonProps>(\n\t(\n\t\t{\n\t\t\ticon,\n\t\t\ticonColor: customIconColor,\n\t\t\tcontainerColor: customContainerColor,\n\t\t\trippleColor: customRippleColor,\n\t\t\tsize = 24,\n\t\t\tdisabled,\n\t\t\tonPress,\n\t\t\tselected = false,\n\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unused-vars\n\t\t\tanimated = false,\n\t\t\tmode,\n\t\t\tstyle,\n\t\t\ttestID = 'icon-button',\n\t\t\tloading = false,\n\t\t\tcontentStyle,\n\t\t\t...rest\n\t\t},\n\t\tref,\n\t) => {\n\t\tconst theme = useTheme()\n\n\t\tconst { iconColor, backgroundColor, borderColor } = getIconButtonColor({\n\t\t\ttheme,\n\t\t\tdisabled,\n\t\t\tselected,\n\t\t\tmode,\n\t\t\tcustomIconColor,\n\t\t\tcustomContainerColor,\n\t\t})\n\n\t\tconst buttonSize = size + 2 * 8 // PADDING = 8\n\t\tconst borderRadius = buttonSize / 2\n\n\t\t// Ripple color calculation\n\t\tconst rippleColorFinal =\n\t\t\tcustomRippleColor ??\n\t\t\t(iconColor\n\t\t\t\t? theme.isV3\n\t\t\t\t\t? `${iconColor}1F`\n\t\t\t\t\t: `${iconColor}32`\n\t\t\t\t: undefined)\n\n\t\tconst handlePress = () => {\n\t\t\tonPress?.()\n\t\t}\n\n\t\treturn (\n\t\t\t<BaseButton\n\t\t\t\tref={ref}\n\t\t\t\tonPress={handlePress}\n\t\t\t\tenabled={!disabled}\n\t\t\t\trippleColor={rippleColorFinal}\n\t\t\t\tstyle={[\n\t\t\t\t\t{\n\t\t\t\t\t\twidth: buttonSize,\n\t\t\t\t\t\theight: buttonSize,\n\t\t\t\t\t\tborderRadius,\n\t\t\t\t\t\tbackgroundColor,\n\t\t\t\t\t\tborderColor,\n\t\t\t\t\t\tborderWidth: mode === 'outlined' && !selected ? 1 : 0,\n\t\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\t},\n\t\t\t\t\tstyles.container,\n\t\t\t\t\tstyle,\n\t\t\t\t\tstyles.touchable,\n\t\t\t\t\tcontentStyle,\n\t\t\t\t]}\n\t\t\t\ttestID={testID}\n\t\t\t\t{...rest}\n\t\t\t>\n\t\t\t\t<View style={styles.content}>\n\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\tsize={size}\n\t\t\t\t\t\t\tcolor={iconColor}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\tsource={icon}\n\t\t\t\t\t\t\tcolor={iconColor}\n\t\t\t\t\t\t\tsize={size}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</BaseButton>\n\t\t)\n\t},\n)\n\nIconButton.displayName = 'IconButton'\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tmargin: 6,\n\t\televation: 0,\n\t},\n\ttouchable: {\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tcontent: {\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n})\n\nexport default IconButton\n"
  },
  {
    "path": "apps/mobile/src/components/modals/AlertModal.tsx",
    "content": "import React from 'react'\nimport { Dialog, Text } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nexport interface AlertButton {\n\ttext: string\n\tonPress?: () => void\n}\n\nexport interface AlertOptions {\n\tcancelable?: boolean\n}\n\nexport interface AlertModalProps {\n\ttitle: string\n\tmessage?: React.ReactNode\n\tbuttons: readonly [AlertButton, AlertButton?] // [negative, positive]\n\toptions?: AlertOptions\n}\n\nexport default function AlertModal({\n\ttitle,\n\tmessage,\n\tbuttons,\n}: AlertModalProps) {\n\tconst close = useModalStore((state) => state.close)\n\n\tconst renderButton = (button: AlertButton | undefined, index: number) => {\n\t\tif (!button) return null\n\t\tswitch (index) {\n\t\t\tcase 0: {\n\t\t\t\treturn (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\tonPress={button.onPress ?? (() => close('Alert'))}\n\t\t\t\t\t\tmode='text'\n\t\t\t\t\t>\n\t\t\t\t\t\t{button.text}\n\t\t\t\t\t</Button>\n\t\t\t\t)\n\t\t\t}\n\t\t\tcase 1: {\n\t\t\t\tconst handlePress = () => {\n\t\t\t\t\tclose('Alert')\n\t\t\t\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\t\t\t\tbutton.onPress?.()\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\tonPress={handlePress}\n\t\t\t\t\t\tmode='text'\n\t\t\t\t\t>\n\t\t\t\t\t\t{button.text}\n\t\t\t\t\t</Button>\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>{title}</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t{typeof message === 'string' || typeof message === 'number' ? (\n\t\t\t\t\t<Text variant='bodyMedium'>{message}</Text>\n\t\t\t\t) : (\n\t\t\t\t\tmessage\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>{buttons.map(renderButton)}</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nexport function alert(\n\ttitle: string,\n\tmessage: React.ReactNode,\n\tbuttons: readonly [AlertButton, AlertButton?],\n\toptions?: AlertOptions,\n) {\n\tuseModalStore\n\t\t.getState()\n\t\t.open(\n\t\t\t'Alert',\n\t\t\t{ title, message, buttons, options },\n\t\t\t{ dismissible: !!options?.cancelable },\n\t\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/components/modals/PlayerQueueModal.tsx",
    "content": "import type { Track as OrpheusTrack } from '@bbplayer/orpheus'\nimport { Orpheus } from '@bbplayer/orpheus'\nimport {\n\tTrueSheet,\n\ttype TrueSheetProps,\n} from '@lodev09/react-native-true-sheet'\nimport type { FlashListRef } from '@shopify/flash-list'\nimport { FlashList } from '@shopify/flash-list'\nimport {\n\tmemo,\n\tuseCallback,\n\tuseEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n\ttype RefObject,\n} from 'react'\nimport { View } from 'react-native'\nimport {\n\tGestureHandlerRootView,\n\tRectButton,\n} from 'react-native-gesture-handler'\nimport { Surface, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport IconButton from '@/components/common/IconButton'\nimport useCurrentTrackId from '@/hooks/player/useCurrentTrackId'\nimport { usePlayerQueue } from '@/hooks/queries/orpheus'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nconst TrackItem = memo(\n\t({\n\t\ttrack,\n\t\tonSwitchTrack,\n\t\tonRemoveTrack,\n\t\tisCurrentTrack,\n\t\tindex,\n\t}: {\n\t\ttrack: OrpheusTrack\n\t\tonSwitchTrack: (index: number) => void\n\t\tonRemoveTrack: (index: number) => void\n\t\tisCurrentTrack: boolean\n\t\tindex: number\n\t}) => {\n\t\tconst colors = useTheme().colors\n\t\treturn (\n\t\t\t<Surface\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundColor: isCurrentTrack ? colors.elevation.level5 : undefined,\n\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\tborderRadius: 8,\n\t\t\t\t\tminHeight: 56, // Enforce min height for visual consistency\n\t\t\t\t}}\n\t\t\t\televation={0}\n\t\t\t>\n\t\t\t\t<RectButton onPress={() => onSwitchTrack(index)}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\tjustifyContent: 'space-between',\n\t\t\t\t\t\t\tpadding: 8,\n\t\t\t\t\t\t\tflex: 1,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tpaddingRight: 0,\n\t\t\t\t\t\t\t\tflex: 1,\n\t\t\t\t\t\t\t\tmarginLeft: 12,\n\t\t\t\t\t\t\t\tflexDirection: 'column',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\tstyle={{ fontWeight: 'bold' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{track.title}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\tstyle={{ fontWeight: 'thin' }}\n\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{track.artist ?? '未知作者'}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon='close-circle-outline'\n\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tonRemoveTrack(index)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t</RectButton>\n\t\t\t</Surface>\n\t\t)\n\t},\n)\n\nTrackItem.displayName = 'TrackItem'\n\ninterface PlayerQueueModalProps extends TrueSheetProps {\n\tsheetRef: RefObject<TrueSheet | null>\n\tisVisible: boolean\n}\n\nfunction PlayerQueueModal({\n\tsheetRef,\n\tisVisible,\n\t...props\n}: PlayerQueueModalProps) {\n\tconst currentTrackId = useCurrentTrackId()\n\tconst theme = useTheme()\n\tconst [didInitialScroll, setDidInitialScroll] = useState(false)\n\tconst flatListRef = useRef<FlashListRef<OrpheusTrack>>(null)\n\n\tconst { data: queue, refetch } = usePlayerQueue(isVisible)\n\n\tconst currentIndex = useMemo(() => {\n\t\tif (!currentTrackId || !queue) return -1\n\t\treturn queue.findIndex((t) => t.id === currentTrackId)\n\t}, [currentTrackId, queue])\n\n\tconst insets = useSafeAreaInsets()\n\n\tconst switchTrackHandler = useCallback(\n\t\tasync (index: number) => {\n\t\t\tif (!queue) return\n\t\t\tif (index === -1) return\n\t\t\tconst target = queue[index]\n\t\t\tif (!target) return\n\t\t\tif (target.id === currentTrackId) return\n\t\t\tawait Orpheus.skipTo(index)\n\t\t\tvoid refetch()\n\t\t},\n\t\t[queue, refetch, currentTrackId],\n\t)\n\n\tconst removeTrackHandler = useCallback(\n\t\tasync (index: number) => {\n\t\t\tawait Orpheus.removeTrack(index)\n\t\t\tvoid refetch()\n\t\t},\n\t\t[refetch],\n\t)\n\n\tconst keyExtractor = useCallback((item: OrpheusTrack) => item.id, [])\n\n\tconst renderItem = useCallback(\n\t\t({ item, index }: { item: OrpheusTrack; index: number }) => (\n\t\t\t<TrackItem\n\t\t\t\ttrack={item}\n\t\t\t\tonSwitchTrack={switchTrackHandler}\n\t\t\t\tonRemoveTrack={removeTrackHandler}\n\t\t\t\tisCurrentTrack={item.id === currentTrackId}\n\t\t\t\tindex={index}\n\t\t\t/>\n\t\t),\n\t\t[switchTrackHandler, removeTrackHandler, currentTrackId],\n\t)\n\n\t// oxlint-disable-next-line react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change\n\tuseEffect(() => {\n\t\tif (isVisible) {\n\t\t\tvoid refetch()\n\t\t} else {\n\t\t\tsetDidInitialScroll(false)\n\t\t}\n\t}, [isVisible, refetch])\n\n\tuseEffect(() => {\n\t\tif (\n\t\t\tisVisible &&\n\t\t\tcurrentIndex !== -1 &&\n\t\t\t!didInitialScroll &&\n\t\t\tqueue?.length\n\t\t) {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tvoid flatListRef.current?.scrollToIndex({\n\t\t\t\t\tanimated: false,\n\t\t\t\t\tindex: currentIndex,\n\t\t\t\t\tviewPosition: 0.5,\n\t\t\t\t})\n\t\t\t\tsetDidInitialScroll(true)\n\t\t\t}, 100)\n\t\t\treturn () => clearTimeout(timer)\n\t\t}\n\t}, [isVisible, currentIndex, didInitialScroll, queue])\n\n\treturn (\n\t\t<TrueSheet\n\t\t\tref={sheetRef}\n\t\t\tdetents={[0.75]}\n\t\t\tcornerRadius={24}\n\t\t\tbackgroundColor={theme.colors.elevation.level1}\n\t\t\tscrollable\n\t\t\t{...props}\n\t\t>\n\t\t\t<GestureHandlerRootView style={{ flex: 1 }}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\t\t\tjustifyContent: 'space-between',\n\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\tpaddingHorizontal: 16,\n\t\t\t\t\t\t\tpaddingTop: 8,\n\t\t\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\t\t\tborderBottomColor: theme.colors.elevation.level2,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Text variant='titleMedium'>播放队列 ({queue?.length ?? 0})</Text>\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon='content-save-outline'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tif (queue && queue.length > 0) {\n\t\t\t\t\t\t\t\t\tuseModalStore.getState().open('SaveQueueToPlaylist', {\n\t\t\t\t\t\t\t\t\t\ttrackIds: queue.map((t) => t.id),\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={!queue || queue.length === 0}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t\t<View style={{ flex: 1, minHeight: 2 }}>\n\t\t\t\t\t\t<FlashList\n\t\t\t\t\t\t\tref={flatListRef}\n\t\t\t\t\t\t\tdata={queue}\n\t\t\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\t\t\t\tpaddingBottom: insets.bottom + 20,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\t\t\t\tnestedScrollEnabled\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</GestureHandlerRootView>\n\t\t</TrueSheet>\n\t)\n}\n\nexport default PlayerQueueModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/app/DonationQRModal.tsx",
    "content": "import { Asset } from 'expo-asset'\nimport { Image } from 'expo-image'\nimport * as MediaLibrary from 'expo-media-library'\nimport { useState } from 'react'\nimport { Pressable, StyleSheet, View } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { Dialog, SegmentedButtons, Text } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n/* oxlint-disable @typescript-eslint/no-unsafe-argument */\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport toast from '@/utils/toast'\n\n// oxlint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment\nconst WECHAT_QR = require('../../../../assets/images/wechat.png')\n// oxlint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment\nconst ALIPAY_QR = require('../../../../assets/images/alipay.jpg')\n\ntype DonationType = 'wechat' | 'alipay'\n\nexport default function DonationQRModal({\n\ttype: initialType,\n}: {\n\ttype: DonationType\n}) {\n\tconst close = useModalStore((state) => state.close)\n\tconst [currentType, setCurrentType] = useState<DonationType>(initialType)\n\tconst [permissionResponse, requestPermission] = MediaLibrary.usePermissions()\n\n\tconst handleLongPress = async () => {\n\t\tconst permissionStatus = permissionResponse\n\t\t\t? permissionResponse.status\n\t\t\t: undefined\n\t\tconst accessPrivileges = permissionResponse\n\t\t\t? permissionResponse.accessPrivileges\n\t\t\t: undefined\n\n\t\tconst needsPermission =\n\t\t\tpermissionStatus !== MediaLibrary.PermissionStatus.GRANTED &&\n\t\t\taccessPrivileges !== 'all'\n\n\t\ttry {\n\t\t\tif (needsPermission) {\n\t\t\t\tconst { status } = await requestPermission()\n\t\t\t\tif (status !== MediaLibrary.PermissionStatus.GRANTED) {\n\t\t\t\t\ttoast.error('无法保存图片', {\n\t\t\t\t\t\tdescription: '请在设置中允许访问相册',\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst asset = Asset.fromModule(\n\t\t\t\tcurrentType === 'wechat' ? WECHAT_QR : ALIPAY_QR,\n\t\t\t)\n\t\t\tif (!asset.downloaded) {\n\t\t\t\tawait asset.downloadAsync()\n\t\t\t}\n\n\t\t\tlet uri = asset.localUri\n\t\t\tif (!uri) {\n\t\t\t\turi = asset.uri\n\t\t\t}\n\n\t\t\tif (!uri) {\n\t\t\t\ttoast.error('保存失败', { description: '无法获取图片路径' })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tawait MediaLibrary.saveToLibraryAsync(uri)\n\t\t\ttoast.success('已保存到相册')\n\t\t} catch (e) {\n\t\t\ttoast.error('保存失败', { description: String(e) })\n\t\t}\n\t}\n\n\tconst qrImage = currentType === 'wechat' ? WECHAT_QR : ALIPAY_QR\n\tconst title = currentType === 'wechat' ? '微信支付' : '支付宝'\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title style={{ textAlign: 'center' }}>{title}</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<View style={styles.tabContainer}>\n\t\t\t\t\t<SegmentedButtons\n\t\t\t\t\t\tvalue={currentType}\n\t\t\t\t\t\tonValueChange={(value) => setCurrentType(value)}\n\t\t\t\t\t\tbuttons={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvalue: 'wechat',\n\t\t\t\t\t\t\t\tlabel: '微信支付',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvalue: 'alipay',\n\t\t\t\t\t\t\t\tlabel: '支付宝',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.imageContainer}>\n\t\t\t\t\t<Pressable\n\t\t\t\t\t\tonLongPress={handleLongPress}\n\t\t\t\t\t\tdelayLongPress={500}\n\t\t\t\t\t>\n\t\t\t\t\t\t<SquircleView\n\t\t\t\t\t\t\tstyle={styles.image}\n\t\t\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-assignment\n\t\t\t\t\t\t\t\tsource={qrImage}\n\t\t\t\t\t\t\t\tstyle={styles.imageInner}\n\t\t\t\t\t\t\t\tcontentFit='contain'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</SquircleView>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tstyle={styles.hint}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t长按保存收款码\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Pressable>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => close('DonationQR')}>关闭</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\ttabContainer: {\n\t\tmarginBottom: 20,\n\t},\n\timageContainer: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\timage: {\n\t\twidth: 200,\n\t\theight: 200,\n\t\tbackgroundColor: '#f0f0f0',\n\t\tmarginBottom: 10,\n\t\tborderRadius: 44,\n\t\toverflow: 'hidden',\n\t},\n\timageInner: {\n\t\twidth: 200,\n\t\theight: 200,\n\t},\n\thint: {\n\t\ttextAlign: 'center',\n\t\topacity: 0.6,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/app/UpdateAppModal.tsx",
    "content": "import {\n\tcanRequestPackageInstallsAsync,\n\tdownloadAndInstallApkAsync,\n\tgetSupportedAbisAsync,\n\topenPackageInstallerSettingsAsync,\n} from '@bbplayer/native'\nimport * as Clipboard from 'expo-clipboard'\nimport * as WebBrowser from 'expo-web-browser'\nimport { useCallback, useState } from 'react'\nimport { Platform, StyleSheet, View } from 'react-native'\nimport { Dialog, Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { UpdateDownloads } from '@/lib/services/updateService'\nimport { storage } from '@/utils/mmkv'\nimport toast from '@/utils/toast'\n\nexport interface UpdateModalProps {\n\tversion: string\n\tnotes: string\n\tlisted_notes?: string[]\n\tforced?: boolean\n\turl: string\n\tdownloads?: UpdateDownloads\n}\n\nexport default function UpdateAppModal({\n\tversion,\n\tnotes,\n\tlisted_notes,\n\turl,\n\tdownloads,\n\tforced = false,\n}: UpdateModalProps) {\n\tconst colors = useTheme().colors\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(() => _close('UpdateApp'), [_close])\n\tconst [isUpdating, setIsUpdating] = useState(false)\n\n\tconst onUpdate = async () => {\n\t\tif (isUpdating) return\n\t\tif (Platform.OS !== 'android') {\n\t\t\tawait openReleaseUrl()\n\t\t\treturn\n\t\t}\n\n\t\tlet toastId: string | number | undefined\n\t\ttry {\n\t\t\tconst canInstall = await canRequestPackageInstallsAsync()\n\t\t\tif (!canInstall) {\n\t\t\t\tawait openPackageInstallerSettingsAsync()\n\t\t\t\ttoast.info('请允许 BBPlayer 安装未知来源应用后再次更新')\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tsetIsUpdating(true)\n\t\t\ttoastId = toast.loading('正在下载更新包', {\n\t\t\t\tdescription: '下载完成后会打开系统安装器',\n\t\t\t\tduration: Infinity,\n\t\t\t})\n\t\t\tconst downloadUrl = await resolveAndroidDownloadUrl()\n\t\t\tif (!downloadUrl) {\n\t\t\t\ttoast.dismiss(toastId)\n\t\t\t\tawait openReleaseUrl()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tawait downloadAndInstallApkAsync({\n\t\t\t\turl: downloadUrl,\n\t\t\t\tfileName: `BBPlayer-${version}-${Date.now()}.apk`,\n\t\t\t\ttitle: `BBPlayer ${version}`,\n\t\t\t\tdescription: '下载完成后安装更新',\n\t\t\t})\n\t\t\ttoast.success('更新包下载完成', { id: toastId })\n\t\t\tclose()\n\t\t} catch (e) {\n\t\t\ttoast.error('更新失败，已将下载链接复制到剪贴板', {\n\t\t\t\tdescription: String(e),\n\t\t\t\tid: toastId,\n\t\t\t})\n\t\t\tvoid Clipboard.setStringAsync(url)\n\t\t} finally {\n\t\t\tsetIsUpdating(false)\n\t\t}\n\t}\n\n\tconst openReleaseUrl = async () => {\n\t\ttry {\n\t\t\tif (url) await WebBrowser.openBrowserAsync(url)\n\t\t} catch (e) {\n\t\t\tvoid Clipboard.setStringAsync(url)\n\t\t\ttoast.error('无法打开浏览器，已将链接复制到剪贴板', {\n\t\t\t\tdescription: String(e),\n\t\t\t})\n\t\t}\n\t\tclose()\n\t}\n\n\tconst resolveAndroidDownloadUrl = async (): Promise<string | null> => {\n\t\tif (!downloads?.android) return isApkUrl(url) ? url : null\n\t\tconst supportedAbis = await getSupportedAbisAsync()\n\t\tfor (const abi of supportedAbis) {\n\t\t\tconst abiUrl = downloads.android[abi]\n\t\t\tif (abiUrl) return abiUrl\n\t\t}\n\t\treturn downloads.android.universal ?? (isApkUrl(url) ? url : null)\n\t}\n\n\tconst onSkip = () => {\n\t\tstorage.set('skip_version', version)\n\t\tclose()\n\t}\n\n\tconst onCancel = () => {\n\t\tclose()\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>发现新版本 {version}</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t{forced ? (\n\t\t\t\t\t<Text style={[styles.forcedText, { color: colors.error }]}>\n\t\t\t\t\t\t此更新为强制更新，必须安装后继续使用。\n\t\t\t\t\t</Text>\n\t\t\t\t) : null}\n\t\t\t\t{listed_notes && listed_notes.length > 0 ? (\n\t\t\t\t\tlisted_notes.map((note, index) => (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tselectable\n\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\tstyle={styles.noteText}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{`• ${note}`}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t))\n\t\t\t\t) : (\n\t\t\t\t\t<Text selectable>\n\t\t\t\t\t\t{/* 小米对联，偷了！ */}\n\t\t\t\t\t\t{notes?.trim() || '提高软件稳定性，优化软件流畅度'}\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions style={styles.actionsContainer}>\n\t\t\t\t{!forced ? (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={onSkip}\n\t\t\t\t\t\tdisabled={isUpdating}\n\t\t\t\t\t>\n\t\t\t\t\t\t跳过此版本\n\t\t\t\t\t</Button>\n\t\t\t\t) : (\n\t\t\t\t\t<View />\n\t\t\t\t)}\n\t\t\t\t<View style={styles.rightActionsContainer}>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={onCancel}\n\t\t\t\t\t\tdisabled={forced || isUpdating}\n\t\t\t\t\t>\n\t\t\t\t\t\t取消\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={onUpdate}\n\t\t\t\t\t\tdisabled={isUpdating}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isUpdating ? '下载中' : '去更新'}\n\t\t\t\t\t</Button>\n\t\t\t\t</View>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst isApkUrl = (value: string) => value.toLowerCase().includes('.apk')\n\nconst styles = StyleSheet.create({\n\tforcedText: {\n\t\tmarginBottom: 8,\n\t\tfontWeight: 'bold',\n\t},\n\tnoteText: {\n\t\tmarginBottom: 4,\n\t},\n\tactionsContainer: {\n\t\tjustifyContent: 'space-between',\n\t},\n\trightActionsContainer: {\n\t\tflexDirection: 'row',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/app/WelcomeModal.tsx",
    "content": "import {\n\tuseCallback,\n\tuseEffect,\n\tuseLayoutEffect,\n\tuseRef,\n\tuseState,\n} from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, Menu, Text } from 'react-native-paper'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport Animated, {\n\tuseAnimatedStyle,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\n\nimport Button from '@/components/common/Button'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { storage } from '@/utils/mmkv'\n\nconst titles = ['欢迎使用 BBPlayer', '登录？']\n\nfunction Step0() {\n\treturn (\n\t\t<View>\n\t\t\t<Text>\n\t\t\t\t看起来你是第一次打开 BBPlayer，容我介绍一下：BBPlayer\n\t\t\t\t是一款开源、简洁的音乐播放器，你可以使用他播放来自\n\t\t\t\t{' BiliBili '}的歌曲。\n\t\t\t\t{'\\n\\n'}\n\t\t\t\t风险声明：虽然开发者尽力负责任地调用{' BiliBili API'}，但\n\t\t\t\t<Text style={styles.boldText}>仍不保证</Text>\n\t\t\t\t您的账号安全无虞，你可能会遇到包括但不限于：账号被风控、短期封禁乃至永久封禁等风险。请权衡利弊后再选择登录。（虽然我用了这么久还没遇到任何问题）\n\t\t\t\t{'\\n\\n'}\n\t\t\t\t如果您选择「游客模式」，本地播放列表、搜索、查看合集等大部分功能仍可使用，但无法访问并即时查看您自己收藏夹中的更新。\n\t\t\t</Text>\n\t\t</View>\n\t)\n}\n\nfunction Step1({\n\tonLoginQRCode,\n\tonLoginPhone,\n\tonGuestMode,\n}: {\n\tonLoginQRCode: () => void\n\tonLoginPhone: () => void\n\tonGuestMode: () => void\n}) {\n\tconst [menuVisible, setMenuVisible] = useState(false)\n\treturn (\n\t\t<View>\n\t\t\t<Text>最后一步！选择登录还是游客模式？</Text>\n\n\t\t\t<View style={styles.stepButtonContainer}>\n\t\t\t\t<FunctionalMenu\n\t\t\t\t\tvisible={menuVisible}\n\t\t\t\t\tonDismiss={() => setMenuVisible(false)}\n\t\t\t\t\tanchor={\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\ticon='chevron-down'\n\t\t\t\t\t\t\tonPress={() => setMenuVisible(true)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t登录账号\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\tleadingIcon='qrcode-scan'\n\t\t\t\t\t\ttitle='扫码登录'\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tsetMenuVisible(false)\n\t\t\t\t\t\t\tonLoginQRCode()\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\tleadingIcon='cellphone'\n\t\t\t\t\t\ttitle='手机号登录'\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tsetMenuVisible(false)\n\t\t\t\t\t\t\tonLoginPhone()\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</FunctionalMenu>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={onGuestMode}\n\t\t\t\t\ttestID='welcome-guest-mode'\n\t\t\t\t>\n\t\t\t\t\t游客模式\n\t\t\t\t</Button>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nexport default function WelcomeModal() {\n\tconst _close = useModalStore((s) => s.close)\n\tconst close = useCallback(() => _close('Welcome'), [_close])\n\tconst open = useModalStore((s) => s.open)\n\n\tconst [step, setStep] = useState(0)\n\n\tconst containerRef = useRef<View>(null)\n\tconst [measuredWidth, setMeasuredWidth] = useState(0)\n\tconst [stepHeights, setStepHeights] = useState<[number, number]>([0, 0])\n\n\tconst translateX = useSharedValue(0)\n\tconst containerHeight = useSharedValue(0)\n\n\tconst animatedContainerStyle = useAnimatedStyle(() => ({\n\t\theight: containerHeight.value,\n\t\toverflow: 'hidden',\n\t}))\n\n\tconst animatedRowStyle = useAnimatedStyle(() => ({\n\t\ttransform: [{ translateX: translateX.value }],\n\t}))\n\n\tuseEffect(() => {\n\t\t// oxlint-disable-next-line react-you-might-not-need-an-effect/no-event-handler\n\t\tif (measuredWidth <= 0) return\n\t\ttranslateX.set(withTiming(-step * measuredWidth, { duration: 300 }))\n\t\tcontainerHeight.set(withTiming(stepHeights[step], { duration: 300 }))\n\t}, [step, translateX, containerHeight, stepHeights, measuredWidth])\n\n\tuseLayoutEffect(() => {\n\t\tcontainerRef.current?.measure((_x, _y, width) => {\n\t\t\tsetMeasuredWidth(width)\n\t\t})\n\t}, [containerRef])\n\n\tconst goToStep = (index: number) => {\n\t\tconst maxIndex = Math.max(0, (titles.length || stepHeights.length) - 1)\n\t\tconst idx = Math.max(0, Math.min(maxIndex, index))\n\t\tsetStep(idx)\n\t}\n\n\tconst confirmGuestMode = useCallback(() => {\n\t\tstorage.set('first_open', false)\n\t\tclose()\n\t}, [close])\n\tconst confirmLoginQRCode = useCallback(() => {\n\t\tstorage.set('first_open', false)\n\t\topen('QRCodeLogin', undefined)\n\t\tclose()\n\t}, [close, open])\n\tconst confirmLoginPhone = useCallback(() => {\n\t\tstorage.set('first_open', false)\n\t\topen('PhoneLogin', undefined)\n\t\tclose()\n\t}, [close, open])\n\n\tusePreventRemove(true, () => goToStep(step - 1))\n\n\treturn (\n\t\t<>\n\t\t\t<View\n\t\t\t\tstyle={styles.hiddenStepsContainer}\n\t\t\t\taccessible={false}\n\t\t\t>\n\t\t\t\t<View\n\t\t\t\t\tstyle={{ width: measuredWidth }}\n\t\t\t\t\tcollapsable={false}\n\t\t\t\t\tonLayout={(e) => {\n\t\t\t\t\t\tconst height = e.nativeEvent.layout.height ?? 0\n\t\t\t\t\t\tif (height <= stepHeights[0]) {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsetStepHeights((s) => [height, s[1]])\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Step0 />\n\t\t\t\t</View>\n\t\t\t\t<View\n\t\t\t\t\tcollapsable={false}\n\t\t\t\t\tstyle={{ width: measuredWidth }}\n\t\t\t\t\tonLayout={(e) => {\n\t\t\t\t\t\tconst height = e.nativeEvent.layout.height ?? 0\n\t\t\t\t\t\tif (height <= stepHeights[1]) {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsetStepHeights((s) => [s[0], height])\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Step1\n\t\t\t\t\t\tonLoginQRCode={confirmLoginQRCode}\n\t\t\t\t\t\tonLoginPhone={confirmLoginPhone}\n\t\t\t\t\t\tonGuestMode={confirmGuestMode}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t\t<Dialog.Title>{titles[step]}</Dialog.Title>\n\n\t\t\t<Dialog.Content>\n\t\t\t\t<Animated.View\n\t\t\t\t\tstyle={[animatedContainerStyle]}\n\t\t\t\t\tref={containerRef}\n\t\t\t\t>\n\t\t\t\t\t<Animated.View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tanimatedRowStyle,\n\t\t\t\t\t\t\t{ flexDirection: 'row', width: measuredWidth * 2 },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<View style={{ width: measuredWidth }}>\n\t\t\t\t\t\t\t<Step0 />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={{ width: measuredWidth }}>\n\t\t\t\t\t\t\t<Step1\n\t\t\t\t\t\t\t\tonLoginQRCode={confirmLoginQRCode}\n\t\t\t\t\t\t\t\tonLoginPhone={confirmLoginPhone}\n\t\t\t\t\t\t\t\tonGuestMode={confirmGuestMode}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</Animated.View>\n\t\t\t\t</Animated.View>\n\t\t\t</Dialog.Content>\n\n\t\t\t<Dialog.Actions>\n\t\t\t\t{step > 0 && <Button onPress={() => goToStep(step - 1)}>上一步</Button>}\n\n\t\t\t\t{step < 1 && (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={() => goToStep(step + 1)}\n\t\t\t\t\t\ttestID='welcome-next-step'\n\t\t\t\t\t>\n\t\t\t\t\t\t下一步\n\t\t\t\t\t</Button>\n\t\t\t\t)}\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tboldText: {\n\t\tfontWeight: '800',\n\t},\n\tstepButtonContainer: {\n\t\tflexDirection: 'row',\n\t\tgap: 8,\n\t\tpaddingTop: 20,\n\t\tjustifyContent: 'flex-end',\n\t\talignItems: 'center',\n\t},\n\thiddenStepsContainer: {\n\t\tposition: 'absolute',\n\t\ttop: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t\tbottom: 0,\n\t\tpointerEvents: 'none',\n\t\topacity: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/bilibili/AddVideoToBilibiliFavModal.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query'\nimport { memo, useCallback, useEffect, useState } from 'react'\nimport { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native'\nimport { Checkbox, Dialog, Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useDealFavoriteForOneVideo } from '@/hooks/mutations/bilibili/favorite'\nimport {\n\tfavoriteListQueryKeys,\n\tuseGetFavoriteForOneVideo,\n} from '@/hooks/queries/bilibili/favorite'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { BilibiliPlaylist } from '@/types/apis/bilibili'\n\nconst FavoriteListItem = memo(function FavoriteListItem({\n\tname,\n\tid,\n\tcheckedList,\n\tsetCheckedList,\n}: {\n\tname: string\n\tid: number\n\tcheckedList: string[]\n\tsetCheckedList: (checkedList: string[]) => void\n}) {\n\tconst handlePress = useCallback(() => {\n\t\tsetCheckedList(\n\t\t\tcheckedList.includes(id.toString())\n\t\t\t\t? checkedList.filter((item) => item !== id.toString())\n\t\t\t\t: [...checkedList, id.toString()],\n\t\t)\n\t}, [checkedList, id, setCheckedList])\n\n\treturn (\n\t\t<Checkbox.Item\n\t\t\tstatus={checkedList.includes(id.toString()) ? 'checked' : 'unchecked'}\n\t\t\tonPress={handlePress}\n\t\t\tlabel={name}\n\t\t/>\n\t)\n})\nFavoriteListItem.displayName = 'FavoriteListItem'\n\nconst AddToFavoriteListsModal = memo(function AddToFavoriteListsModal({\n\tbvid,\n}: {\n\tbvid: string\n}) {\n\tconst { colors } = useTheme()\n\tconst queryClient = useQueryClient()\n\tconst { data: personalInfo } = usePersonalInformation()\n\tconst enable = useAppStore((state) => state.hasBilibiliCookie())\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(\n\t\t() => _close('AddVideoToBilibiliFavorite'),\n\t\t[_close],\n\t)\n\tconst open = useModalStore((state) => state.open)\n\n\tconst {\n\t\tdata: playlists,\n\t\trefetch,\n\t\tisPending,\n\t\tisError,\n\t} = useGetFavoriteForOneVideo(bvid, personalInfo?.mid)\n\n\tconst { mutate: dealFavorite, isPending: isMutating } =\n\t\tuseDealFavoriteForOneVideo()\n\n\tconst [checkedList, setCheckedList] = useState<string[]>([])\n\n\tuseEffect(() => {\n\t\tif (playlists) {\n\t\t\tconst initialCheckedIds = playlists\n\t\t\t\t.filter((item) => item.fav_state === 1)\n\t\t\t\t.map((item) => item.id.toString())\n\n\t\t\t// oxlint-disable-next-line react-you-might-not-need-an-effect/no-derived-state -- 暂时没想到更好的解决办法\n\t\t\tsetCheckedList(initialCheckedIds)\n\t\t}\n\t}, [playlists])\n\n\tconst handleConfirm = useCallback(() => {\n\t\tif (!playlists || isMutating) return\n\n\t\tconst initialCheckedIds = new Set(\n\t\t\tplaylists\n\t\t\t\t.filter((item) => item.fav_state === 1)\n\t\t\t\t.map((item) => item.id.toString()),\n\t\t)\n\n\t\tconst currentCheckedIds = new Set(checkedList)\n\n\t\tconst addToFavoriteIds: string[] = []\n\t\tconst delInFavoriteIds: string[] = []\n\n\t\tfor (const id of currentCheckedIds) {\n\t\t\tif (!initialCheckedIds.has(id)) {\n\t\t\t\taddToFavoriteIds.push(id)\n\t\t\t}\n\t\t}\n\n\t\tfor (const id of initialCheckedIds) {\n\t\t\tif (!currentCheckedIds.has(id)) {\n\t\t\t\tdelInFavoriteIds.push(id)\n\t\t\t}\n\t\t}\n\n\t\tif (addToFavoriteIds.length === 0 && delInFavoriteIds.length === 0) {\n\t\t\tclose()\n\t\t\treturn\n\t\t}\n\n\t\ttry {\n\t\t\tdealFavorite({\n\t\t\t\tbvid,\n\t\t\t\taddToFavoriteIds,\n\t\t\t\tdelInFavoriteIds,\n\t\t\t})\n\t\t} catch {\n\t\t\t// empty\n\t\t}\n\n\t\tclose()\n\t\tqueryClient.removeQueries({\n\t\t\tqueryKey: favoriteListQueryKeys.favoriteForOneVideo(\n\t\t\t\tbvid,\n\t\t\t\tpersonalInfo?.mid,\n\t\t\t),\n\t\t})\n\t}, [\n\t\tplaylists,\n\t\tisMutating,\n\t\tcheckedList,\n\t\tclose,\n\t\tdealFavorite,\n\t\tbvid,\n\t\tqueryClient,\n\t\tpersonalInfo?.mid,\n\t])\n\n\tconst renderFavoriteListItem = useCallback(\n\t\t({ item }: { item: BilibiliPlaylist }) => (\n\t\t\t<FavoriteListItem\n\t\t\t\tname={item.title}\n\t\t\t\tid={item.id}\n\t\t\t\tcheckedList={checkedList}\n\t\t\t\tsetCheckedList={setCheckedList}\n\t\t\t/>\n\t\t),\n\t\t[checkedList],\n\t)\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliPlaylist) => item.id.toString(),\n\t\t[],\n\t)\n\n\tconst renderContent = () => {\n\t\tif (!enable) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.loginPromptContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\tstyle={styles.loginPromptText}\n\t\t\t\t\t>\n\t\t\t\t\t\t登录{'\\u2009bilibili\\u2009'}账号后才能查看收藏夹\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tclose()\n\t\t\t\t\t\t\topen('QRCodeLogin', undefined)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t登录\n\t\t\t\t\t</Button>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\t\tif (isPending) {\n\t\t\treturn (\n\t\t\t\t<Dialog.Content style={styles.loadingContainer}>\n\t\t\t\t\t<ActivityIndicator size={'large'} />\n\t\t\t\t</Dialog.Content>\n\t\t\t)\n\t\t}\n\n\t\tif (isError) {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t<Text style={[styles.errorText, { color: colors.error }]}>\n\t\t\t\t\t\t\t加载收藏夹失败\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tonPress={close}\n\t\t\t\t\t\t\tdisabled={isMutating}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t关闭\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button onPress={() => refetch()}>重试</Button>\n\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.ScrollArea style={styles.listContainer}>\n\t\t\t\t\t<FlatList\n\t\t\t\t\t\tdata={playlists || []}\n\t\t\t\t\t\textraData={checkedList} // 必须添加\n\t\t\t\t\t\trenderItem={renderFavoriteListItem}\n\t\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t\t<View style={styles.emptyListContainer}>\n\t\t\t\t\t\t\t\t<Text style={styles.emptyListText}>暂无收藏夹</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</Dialog.ScrollArea>\n\t\t\t\t<Dialog.Actions style={styles.actionsContainer}>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={close}\n\t\t\t\t\t\tdisabled={isMutating}\n\t\t\t\t\t>\n\t\t\t\t\t\t取消\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\t\tloading={isMutating}\n\t\t\t\t\t\tdisabled={isMutating}\n\t\t\t\t\t>\n\t\t\t\t\t\t确定\n\t\t\t\t\t</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>添加到收藏夹</Dialog.Title>\n\t\t\t{renderContent()}\n\t\t</>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tloginPromptContainer: {\n\t\tpaddingTop: 16,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tgap: 16,\n\t},\n\tloginPromptText: {\n\t\ttextAlign: 'center',\n\t},\n\tloadingContainer: {\n\t\talignItems: 'center',\n\t\tpaddingVertical: 20,\n\t},\n\terrorText: {\n\t\ttextAlign: 'center',\n\t\tpadding: 16,\n\t},\n\tlistContainer: {\n\t\theight: 300,\n\t},\n\temptyListContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\temptyListText: {\n\t\tpadding: 16,\n\t},\n\tactionsContainer: {\n\t\tmarginTop: 16,\n\t},\n})\n\nAddToFavoriteListsModal.displayName = 'AddToFavoriteListsModal'\n\nexport default AddToFavoriteListsModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/edit-metadata/editPlaylistMetadataModal.tsx",
    "content": "import * as DocumentPicker from 'expo-document-picker'\nimport * as FileSystem from 'expo-file-system'\nimport { useCallback, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport IconButton from '@/components/common/IconButton'\nimport { useEditPlaylistMetadata } from '@/hooks/mutations/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { bilibiliFacade } from '@/lib/facades/bilibili'\nimport type { Playlist } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport log from '@/utils/log'\nimport toast from '@/utils/toast'\n\nconst logger = log.extend('Components.EditPlaylistMetadataModal')\n\nexport default function EditPlaylistMetadataModal({\n\tplaylist,\n}: {\n\tplaylist: Playlist\n}) {\n\tconst { mutate: editPlaylistMetadata } = useEditPlaylistMetadata()\n\tconst [title, setTitle] = useState(playlist.title)\n\tconst [description, setDescription] = useState(playlist.description)\n\tconst [coverUrl, setCoverUrl] = useState(playlist.coverUrl)\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(() => _close('EditPlaylistMetadata'), [_close])\n\n\tconst fetchRemoteMetadata = useCallback(async () => {\n\t\tif (!playlist.remoteSyncId) {\n\t\t\ttoast.error('播放列表的 remoteSyncId 为空，无法获取远程数据')\n\t\t\treturn\n\t\t}\n\t\tconst result = await bilibiliFacade.fetchRemotePlaylistMetadata(\n\t\t\tplaylist.remoteSyncId,\n\t\t\tplaylist.type,\n\t\t)\n\t\tif (result.isErr()) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'获取远程播放列表元数据失败',\n\t\t\t\tresult.error,\n\t\t\t\t'Components.EditPlaylistMetadataModal',\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tconst metadata = result.value\n\t\tsetTitle(metadata.title)\n\t\tsetDescription(metadata.description)\n\t\tsetCoverUrl(metadata.coverUrl)\n\t\tlogger.debug('获取远程播放列表元数据成功', metadata)\n\t\ttoast.success('获取远程播放列表元数据成功')\n\t}, [playlist.remoteSyncId, playlist.type])\n\n\tconst handleConfirm = useCallback(() => {\n\t\tif (title.trim().length === 0) {\n\t\t\ttoast.error('标题不能为空')\n\t\t\treturn\n\t\t}\n\t\teditPlaylistMetadata({\n\t\t\tplaylistId: playlist.id,\n\t\t\tpayload: {\n\t\t\t\ttitle,\n\t\t\t\tdescription: description ?? undefined,\n\t\t\t\tcoverUrl: coverUrl ?? undefined,\n\t\t\t},\n\t\t})\n\t\tclose()\n\t}, [close, coverUrl, description, editPlaylistMetadata, playlist.id, title])\n\n\tconst handleImagePicker = useCallback(async () => {\n\t\tconst result = await DocumentPicker.getDocumentAsync({\n\t\t\ttype: 'image/*',\n\t\t\tcopyToCacheDirectory: true,\n\t\t\tmultiple: false,\n\t\t})\n\t\tif (result.canceled || result.assets.length === 0) return\n\t\tconst assetFile = new FileSystem.File(result.assets[0].uri)\n\t\tconst coverDir = new FileSystem.Directory(\n\t\t\tFileSystem.Paths.document,\n\t\t\t'covers',\n\t\t)\n\t\tif (!coverDir.exists) {\n\t\t\tcoverDir.create({ intermediates: true })\n\t\t}\n\t\tconst coverFile = new FileSystem.File(coverDir, assetFile.name)\n\t\tif (coverFile.exists) {\n\t\t\tcoverFile.delete()\n\t\t}\n\t\tassetFile.copy(coverFile)\n\t\tsetCoverUrl(coverFile.uri)\n\t}, [])\n\n\tconst handleDismiss = useCallback(() => {\n\t\tclose()\n\t\tsetTitle('')\n\t\tsetDescription('')\n\t\tsetCoverUrl('')\n\t}, [close])\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>编辑信息</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='标题'\n\t\t\t\t\tvalue={title}\n\t\t\t\t\tonChangeText={setTitle}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t/>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='描述'\n\t\t\t\t\tonChangeText={setDescription}\n\t\t\t\t\tvalue={description ?? undefined}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tmultiline\n\t\t\t\t\tstyle={styles.descriptionInput}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t/>\n\t\t\t\t<View style={styles.coverUrlContainer}>\n\t\t\t\t\t<TextInput\n\t\t\t\t\t\tlabel='封面'\n\t\t\t\t\t\tonChangeText={setCoverUrl}\n\t\t\t\t\t\tvalue={coverUrl ?? undefined}\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\t\tstyle={styles.coverUrlInput}\n\t\t\t\t\t/>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='image-plus'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tstyle={styles.imagePickerButton}\n\t\t\t\t\t\tonPress={handleImagePicker}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions style={styles.actionsContainer}>\n\t\t\t\t{playlist.type !== 'local' && playlist.type !== 'dynamic' ? (\n\t\t\t\t\t<Button onPress={fetchRemoteMetadata}>获取远程数据</Button>\n\t\t\t\t) : (\n\t\t\t\t\t<View />\n\t\t\t\t)}\n\t\t\t\t<View style={styles.rightActionsContainer}>\n\t\t\t\t\t<Button onPress={handleDismiss}>取消</Button>\n\t\t\t\t\t<Button onPress={handleConfirm}>确定</Button>\n\t\t\t\t</View>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tgap: 5,\n\t},\n\tdescriptionInput: {\n\t\tmaxHeight: 150,\n\t},\n\tcoverUrlContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tcoverUrlInput: {\n\t\tflex: 1,\n\t},\n\timagePickerButton: {\n\t\tmarginTop: 13,\n\t},\n\tactionsContainer: {\n\t\tjustifyContent: 'space-between',\n\t},\n\trightActionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/edit-metadata/editTrackMetadataModal.tsx",
    "content": "import { useCallback, useState } from 'react'\nimport { StyleSheet } from 'react-native'\nimport { Dialog, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useRenameTrack } from '@/hooks/mutations/db/track'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { Track } from '@/types/core/media'\nimport toast from '@/utils/toast'\n\nexport default function EditTrackMetadataModal({ track }: { track: Track }) {\n\tconst [title, setTitle] = useState<string>(track.title)\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(() => _close('EditTrackMetadata'), [_close])\n\n\tconst { mutate: editTrackMetadata } = useRenameTrack()\n\n\tconst handleConfirm = () => {\n\t\tif (!title) {\n\t\t\ttoast.error('标题不能为空')\n\t\t\treturn\n\t\t}\n\t\teditTrackMetadata({\n\t\t\ttrackId: track.id,\n\t\t\tnewTitle: title,\n\t\t\tsource: track.source,\n\t\t})\n\t\tclose()\n\t}\n\n\tconst handleDismiss = () => {\n\t\tclose()\n\t\tsetTitle('')\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>改名</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='标题'\n\t\t\t\t\tvalue={title}\n\t\t\t\t\tonChangeText={setTitle}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={handleDismiss}>取消</Button>\n\t\t\t\t<Button onPress={handleConfirm}>确定</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tgap: 5,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/CookieLoginModal.tsx",
    "content": "import { useQueryClient } from '@tanstack/react-query'\nimport { useCallback, useMemo, useState } from 'react'\nimport { StyleSheet } from 'react-native'\nimport { Dialog, Divider, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { favoriteListQueryKeys } from '@/hooks/queries/bilibili/favorite'\nimport { userQueryKeys } from '@/hooks/queries/bilibili/user'\nimport useAppStore, { serializeCookieObject } from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nexport default function CookieLoginModal() {\n\tconst queryClient = useQueryClient()\n\tconst cookieObjectFromStore = useAppStore((state) => state.bilibiliCookie)\n\tconst setBilibiliCookie = useAppStore((state) => state.setBilibiliCookie)\n\tconst clearBilibiliCookie = useAppStore((state) => state.clearBilibiliCookie)\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(() => _close('CookieLogin'), [_close])\n\n\tconst displayCookieString = useMemo(() => {\n\t\tif (!cookieObjectFromStore) return ''\n\t\treturn serializeCookieObject(cookieObjectFromStore)\n\t}, [cookieObjectFromStore])\n\n\tconst [inputCookie, setInputCookie] = useState(displayCookieString)\n\tconst [isLoading, setIsLoading] = useState(false)\n\n\tconst handleConfirm = async () => {\n\t\tsetIsLoading(true)\n\t\tconst cookie = inputCookie?.trim()\n\t\ttry {\n\t\t\tif (!cookie) {\n\t\t\t\tclearBilibiliCookie()\n\t\t\t\tawait queryClient.cancelQueries()\n\t\t\t\tqueryClient.clear()\n\t\t\t\ttoast.success('Cookie 已清除')\n\t\t\t\tclose()\n\t\t\t\tsetIsLoading(false)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (inputCookie === displayCookieString) {\n\t\t\t\tclose()\n\t\t\t\tsetIsLoading(false)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst result = setBilibiliCookie(inputCookie)\n\t\t\tif (result.isErr()) {\n\t\t\t\ttoast.error(result.error.message)\n\t\t\t\tsetIsLoading(false)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoast.success('Cookie 已更新')\n\t\t\tawait queryClient.cancelQueries()\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: favoriteListQueryKeys.all,\n\t\t\t})\n\t\t\tawait queryClient.invalidateQueries({ queryKey: userQueryKeys.all })\n\t\t\tclose()\n\t\t} catch (error) {\n\t\t\ttoastAndLogError('操作失败', error, 'Components.CookieLoginModal')\n\t\t}\n\t\tsetIsLoading(false)\n\t}\n\n\tconst handleDismiss = () => {\n\t\tif (isLoading) return\n\t\tclose()\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>设置 Bilibili Cookie</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='Cookie'\n\t\t\t\t\tkey={displayCookieString}\n\t\t\t\t\tvalue={inputCookie}\n\t\t\t\t\tonChangeText={setInputCookie}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tnumberOfLines={5}\n\t\t\t\t\tmultiline\n\t\t\t\t\tstyle={styles.cookieInput}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\ttestID='cookie-input'\n\t\t\t\t/>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\tstyle={styles.cookieDescription}\n\t\t\t\t>\n\t\t\t\t\t请在此处粘贴您的{'\\u2009Bilibili\\u2009Cookie\\u2009'}以使用完整\n\t\t\t\t\t{'\\u2009BBPlayer\\u2009'}功能。\n\t\t\t\t</Text>\n\t\t\t\t<Divider style={styles.divider} />\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={handleDismiss}>取消</Button>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\ttestID='cookie-login-confirm'\n\t\t\t\t>\n\t\t\t\t\t确定\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcookieInput: {\n\t\tmaxHeight: 200,\n\t},\n\tcookieDescription: {\n\t\tmarginTop: 8,\n\t},\n\tdivider: {\n\t\tmarginTop: 16,\n\t\tmarginBottom: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/PhoneLoginModal.tsx",
    "content": "import { usePhoneLogin } from '@/hooks/auth/usePhoneLogin'\n\nimport GeetestVerifyStep from './steps/GeetestVerifyStep'\nimport InputCodeStep from './steps/InputCodeStep'\nimport InputPhoneStep from './steps/InputPhoneStep'\nimport SuccessStep from './steps/SuccessStep'\n\nexport default function PhoneLoginModal() {\n\tconst {\n\t\tstep,\n\t\ttel,\n\t\tsetTel,\n\t\tsmsCode,\n\t\tsetSmsCode,\n\t\tcaptchaParams,\n\t\tisSendingCode,\n\t\tisLoggingIn,\n\t\tphoneError,\n\t\tsetPhoneError,\n\t\tcodeError,\n\t\tsetCodeError,\n\t\tclose,\n\t\thandleRequestCode,\n\t\thandleGeetestMessage,\n\t\thandleLogin,\n\t\tcancelGeetest,\n\t\tprevStep,\n\t} = usePhoneLogin()\n\n\tif (step === 'success') return <SuccessStep />\n\n\tif (step === 'input_code') {\n\t\treturn (\n\t\t\t<InputCodeStep\n\t\t\t\ttel={tel}\n\t\t\t\tsmsCode={smsCode}\n\t\t\t\tsetSmsCode={setSmsCode}\n\t\t\t\tcodeError={codeError}\n\t\t\t\tsetCodeError={setCodeError}\n\t\t\t\tisLoggingIn={isLoggingIn}\n\t\t\t\tonPrev={prevStep}\n\t\t\t\tonLogin={handleLogin}\n\t\t\t/>\n\t\t)\n\t}\n\n\tif (step === 'geetest_verify') {\n\t\tif (!captchaParams) return null\n\t\treturn (\n\t\t\t<GeetestVerifyStep\n\t\t\t\tgt={captchaParams.gt}\n\t\t\t\tchallenge={captchaParams.challenge}\n\t\t\t\tonMessage={handleGeetestMessage}\n\t\t\t\tonCancel={cancelGeetest}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<InputPhoneStep\n\t\t\ttel={tel}\n\t\t\tsetTel={setTel}\n\t\t\tphoneError={phoneError}\n\t\t\tsetPhoneError={setPhoneError}\n\t\t\tisSendingCode={isSendingCode}\n\t\t\tonBack={close}\n\t\t\tonRequestCode={handleRequestCode}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/QRCodeLoginModal.tsx",
    "content": "import * as Sentry from '@sentry/react-native'\nimport { useQueryClient } from '@tanstack/react-query'\nimport * as Clipboard from 'expo-clipboard'\nimport * as WebBrowser from 'expo-web-browser'\nimport { useCallback, useEffect, useReducer } from 'react'\nimport { Pressable, StyleSheet } from 'react-native'\nimport { Dialog, Text } from 'react-native-paper'\nimport QRCode from 'react-native-qrcode-svg'\nimport * as setCookieParser from 'set-cookie-parser'\n\nimport Button from '@/components/common/Button'\nimport { favoriteListQueryKeys } from '@/hooks/queries/bilibili/favorite'\nimport { userQueryKeys } from '@/hooks/queries/bilibili/user'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { BilibiliQrCodeLoginStatus } from '@/types/apis/bilibili'\nimport toast from '@/utils/toast'\n\ntype Status =\n\t| 'prompting'\n\t| 'generating'\n\t| 'polling'\n\t| 'expired'\n\t| 'success'\n\t| 'error'\n\ninterface State {\n\tstatus: Status\n\tstatusText: string\n\tqrcodeKey: string\n\tqrcodeUrl: string\n}\n\ntype Action =\n\t| { type: 'START_LOGIN' }\n\t| { type: 'RESET' }\n\t| {\n\t\t\ttype: 'GENERATE_SUCCESS'\n\t\t\tpayload: { qrcode_key: string; url: string }\n\t  }\n\t| { type: 'GENERATE_FAILURE'; payload: string }\n\t| { type: 'POLL_UPDATE'; payload: { code: number } }\n\t| { type: 'LOGIN_SUCCESS' }\n\nconst initialState: State = {\n\tstatus: 'prompting',\n\tstatusText: '是否开始扫码登录？',\n\tqrcodeKey: '',\n\tqrcodeUrl: '',\n}\n\nfunction reducer(state: State, action: Action): State {\n\tswitch (action.type) {\n\t\tcase 'START_LOGIN':\n\t\t\treturn { ...state, status: 'generating', statusText: '正在生成二维码...' }\n\t\tcase 'RESET':\n\t\t\treturn initialState\n\t\tcase 'GENERATE_SUCCESS':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tstatus: 'polling',\n\t\t\t\tstatusText: '等待扫码',\n\t\t\t\tqrcodeKey: action.payload.qrcode_key,\n\t\t\t\tqrcodeUrl: action.payload.url,\n\t\t\t}\n\t\tcase 'GENERATE_FAILURE':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tstatus: 'error',\n\t\t\t\tstatusText: `获取二维码失败:\\u2009${action.payload}`,\n\t\t\t}\n\t\tcase 'POLL_UPDATE':\n\t\t\tswitch (action.payload.code as BilibiliQrCodeLoginStatus) {\n\t\t\t\tcase BilibiliQrCodeLoginStatus.QRCODE_LOGIN_STATUS_WAIT:\n\t\t\t\t\treturn { ...state, statusText: '等待扫码' }\n\t\t\t\tcase BilibiliQrCodeLoginStatus.QRCODE_LOGIN_STATUS_SCANNED_BUT_NOT_CONFIRMED:\n\t\t\t\t\treturn { ...state, statusText: '等待确认' }\n\t\t\t\tcase BilibiliQrCodeLoginStatus.QRCODE_LOGIN_STATUS_QRCODE_EXPIRED:\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...state,\n\t\t\t\t\t\tstatus: 'expired',\n\t\t\t\t\t\tstatusText: '二维码已过期，请重新打开窗口',\n\t\t\t\t\t\tqrcodeKey: '',\n\t\t\t\t\t\tqrcodeUrl: '',\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t\treturn state\n\t\t\t}\n\t\tcase 'LOGIN_SUCCESS':\n\t\t\treturn { ...state, status: 'success', statusText: '登录成功' }\n\t\tdefault:\n\t\t\treturn state\n\t}\n}\n\nconst QrCodeLoginModal = () => {\n\tconst queryClient = useQueryClient()\n\tconst setCookie = useAppStore((state) => state.updateBilibiliCookie)\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(() => _close('QRCodeLogin'), [_close])\n\n\tconst [state, dispatch] = useReducer(reducer, initialState)\n\tconst { status, statusText, qrcodeKey, qrcodeUrl } = state\n\n\tuseEffect(() => {\n\t\tif (status !== 'generating') return\n\n\t\tconst generateQrCode = async () => {\n\t\t\tconst response = await bilibiliApi.getLoginQrCode()\n\t\t\tif (response.isErr()) {\n\t\t\t\tdispatch({\n\t\t\t\t\ttype: 'GENERATE_FAILURE',\n\t\t\t\t\tpayload: String(response.error.message),\n\t\t\t\t})\n\t\t\t\ttoast.error('获取二维码失败', { id: 'bilibili-qrcode-login-error' })\n\t\t\t\tsetTimeout(() => close(), 2000)\n\t\t\t} else {\n\t\t\t\tdispatch({ type: 'GENERATE_SUCCESS', payload: response.value })\n\t\t\t}\n\t\t}\n\t\tvoid generateQrCode()\n\t}, [status, close])\n\n\tuseEffect(() => {\n\t\tif (status !== 'polling' || !qrcodeKey) return\n\n\t\tconst interval = setInterval(async () => {\n\t\t\tconst response = await bilibiliApi.pollQrCodeLoginStatus(qrcodeKey)\n\t\t\tif (response.isErr()) {\n\t\t\t\ttoast.error('获取二维码登录状态失败', {\n\t\t\t\t\tid: 'bilibili-qrcode-login-status-error',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst pollData = response.value\n\t\t\tif (\n\t\t\t\tpollData.status ===\n\t\t\t\tBilibiliQrCodeLoginStatus.QRCODE_LOGIN_STATUS_SUCCESS\n\t\t\t) {\n\t\t\t\tclearInterval(interval) // 成功后立刻停止轮询\n\t\t\t\tdispatch({ type: 'LOGIN_SUCCESS' })\n\n\t\t\t\tconst splitedCookie = setCookieParser.splitCookiesString(\n\t\t\t\t\tpollData.cookies,\n\t\t\t\t)\n\t\t\t\tconst parsedCookie = setCookieParser.parse(splitedCookie)\n\t\t\t\tconst finalCookieObject = Object.fromEntries(\n\t\t\t\t\tparsedCookie.map((c) => [c.name, c.value]),\n\t\t\t\t)\n\t\t\t\tconst result = setCookie(finalCookieObject)\n\t\t\t\tif (result.isErr()) {\n\t\t\t\t\ttoast.error('保存 cookie 失败：' + result.error.message)\n\t\t\t\t\tSentry.captureException(result.error, {\n\t\t\t\t\t\ttags: { Component: 'QrCodeLoginModal' },\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttoast.success('登录成功', { id: 'bilibili-qrcode-login-success' })\n\t\t\t\tawait queryClient.cancelQueries()\n\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: favoriteListQueryKeys.all,\n\t\t\t\t})\n\t\t\t\tawait queryClient.invalidateQueries({ queryKey: userQueryKeys.all })\n\t\t\t\tsetTimeout(() => close(), 1000)\n\t\t\t} else {\n\t\t\t\tdispatch({ type: 'POLL_UPDATE', payload: { code: pollData.status } })\n\t\t\t}\n\t\t}, 2000)\n\n\t\treturn () => clearInterval(interval)\n\t}, [status, qrcodeKey, setCookie, queryClient, close])\n\n\tconst renderDialogContent = () => {\n\t\tif (status === 'prompting') {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Text style={styles.statusText}>{statusText}</Text>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\tonPress={() => dispatch({ type: 'START_LOGIN' })}\n\t\t\t\t\t>\n\t\t\t\t\t\t开始\n\t\t\t\t\t</Button>\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\n\t\tif (status === 'generating' || status === 'error' || status === 'expired') {\n\t\t\treturn <Text style={styles.statusText}>{statusText}</Text>\n\t\t}\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Text style={styles.statusText}>\n\t\t\t\t\t{statusText}\n\t\t\t\t\t{'（点击二维码可直接跳转登录）'}\n\t\t\t\t</Text>\n\t\t\t\t<Pressable\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tif (!qrcodeUrl) return\n\t\t\t\t\t\tWebBrowser.openBrowserAsync(qrcodeUrl).catch((e) => {\n\t\t\t\t\t\t\tvoid Clipboard.setStringAsync(qrcodeUrl)\n\t\t\t\t\t\t\ttoast.error('无法调用浏览器打开网页，已将链接复制到剪贴板', {\n\t\t\t\t\t\t\t\tdescription: String(e),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{qrcodeUrl ? (\n\t\t\t\t\t\t<QRCode\n\t\t\t\t\t\t\tvalue={qrcodeUrl}\n\t\t\t\t\t\t\tsize={200}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Text style={styles.statusText}>正在生成二维码...</Text>\n\t\t\t\t\t)}\n\t\t\t\t</Pressable>\n\t\t\t</>\n\t\t)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>扫码登录</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t{renderDialogContent()}\n\t\t\t</Dialog.Content>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tstatusText: {\n\t\ttextAlign: 'center',\n\t\tpadding: 16,\n\t},\n})\n\nexport default QrCodeLoginModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/steps/GeetestVerifyStep.tsx",
    "content": "import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'\nimport { Dialog, Portal, Text } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\nimport { WebView } from 'react-native-webview'\nimport type { WebViewMessageEvent } from 'react-native-webview'\n\nimport Button from '@/components/common/Button'\n\ninterface Props {\n\tgt: string\n\tchallenge: string\n\tonMessage: (event: WebViewMessageEvent) => void\n\tonCancel: () => void\n}\n\nfunction buildGeetestHtml(gt: string, challenge: string): string {\n\tconst gtJson = JSON.stringify(gt)\n\tconst challengeJson = JSON.stringify(challenge)\n\treturn `<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <style>\n    * { box-sizing: border-box; margin: 0; padding: 0; }\n    body {\n      display: flex; flex-direction: column;\n      align-items: center; justify-content: center;\n      min-height: 100vh; background: #f5f5f5;\n      font-family: -apple-system, BlinkMacSystemFont, sans-serif;\n    }\n    .card {\n      background: #fff; border-radius: 8px; padding: 20px;\n      box-shadow: 0 2px 8px rgba(0,0,0,0.12); width: 90%; max-width: 340px;\n    }\n    h3 { text-align: center; margin-bottom: 16px; font-size: 16px; color: #333; }\n    .err { color: #d32f2f; text-align: center; margin-top: 10px; font-size: 14px; }\n  </style>\n</head>\n<body>\n  <div class=\"card\">\n    <h3>请完成安全验证</h3>\n    <div id=\"captcha\"></div>\n    <div class=\"err\" id=\"err-msg\"></div>\n  </div>\n  <script src=\"https://static.geetest.com/static/js/gt.0.4.9.js\"></script>\n  <script>\n    initGeetest({\n      gt: ${gtJson},\n      challenge: ${challengeJson},\n      offline: false,\n      new_captcha: true,\n      product: 'popup',\n      width: '100%',\n      https: true\n    }, function(captchaObj) {\n      captchaObj.appendTo('#captcha');\n      captchaObj.onSuccess(function() {\n        var r = captchaObj.getValidate();\n        window.ReactNativeWebView.postMessage(JSON.stringify({\n          validate: r.geetest_validate,\n          seccode: r.geetest_seccode,\n          challenge: r.geetest_challenge\n        }));\n      });\n      captchaObj.onError(function() {\n        document.getElementById('err-msg').textContent = '验证出错，请关闭后重试';\n      });\n    });\n  </script>\n</body>\n</html>`\n}\n\nexport default function GeetestVerifyStep({\n\tgt,\n\tchallenge,\n\tonMessage,\n\tonCancel,\n}: Props) {\n\tconst insets = useSafeAreaInsets()\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>安全验证</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<ActivityIndicator\n\t\t\t\t\tsize='large'\n\t\t\t\t\tstyle={styles.geetestLoading}\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={onCancel}>取消</Button>\n\t\t\t</Dialog.Actions>\n\t\t\t<Portal>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tStyleSheet.absoluteFill,\n\t\t\t\t\t\tstyles.geetestPortalContainer,\n\t\t\t\t\t\t{ paddingTop: insets.top, paddingBottom: insets.bottom },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<View style={styles.geetestModalHeader}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.geetestModalTitle}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t安全验证\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Pressable\n\t\t\t\t\t\t\tonPress={onCancel}\n\t\t\t\t\t\t\tstyle={styles.geetestModalClose}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Text variant='labelLarge'>取消</Text>\n\t\t\t\t\t\t</Pressable>\n\t\t\t\t\t</View>\n\t\t\t\t\t<WebView\n\t\t\t\t\t\tstyle={styles.geetestWebView}\n\t\t\t\t\t\tsource={{\n\t\t\t\t\t\t\thtml: buildGeetestHtml(gt, challenge),\n\t\t\t\t\t\t\tbaseUrl: 'https://www.bilibili.com',\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonMessage={onMessage}\n\t\t\t\t\t\tjavaScriptEnabled\n\t\t\t\t\t\toriginWhitelist={['*']}\n\t\t\t\t\t\tmixedContentMode='always'\n\t\t\t\t\t\tstartInLoadingState\n\t\t\t\t\t\trenderLoading={() => (\n\t\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\t\t\t\t\t\tsize='large'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Portal>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tgeetestLoading: {\n\t\tmarginVertical: 24,\n\t},\n\tgeetestPortalContainer: {\n\t\tbackgroundColor: '#f5f5f5',\n\t},\n\tgeetestModalHeader: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 12,\n\t\tbackgroundColor: '#fff',\n\t\tborderBottomWidth: StyleSheet.hairlineWidth,\n\t\tborderBottomColor: 'rgba(0,0,0,0.1)',\n\t},\n\tgeetestModalTitle: {\n\t\tflex: 1,\n\t},\n\tgeetestModalClose: {\n\t\tpaddingLeft: 16,\n\t\tpaddingVertical: 4,\n\t},\n\tgeetestWebView: {\n\t\tflex: 1,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/steps/InputCodeStep.tsx",
    "content": "import { StyleSheet } from 'react-native'\nimport { Dialog, HelperText, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n\ninterface Props {\n\ttel: string\n\tsmsCode: string\n\tsetSmsCode: (v: string) => void\n\tcodeError: string\n\tsetCodeError: (v: string) => void\n\tisLoggingIn: boolean\n\tonPrev: () => void\n\tonLogin: () => void\n}\n\nexport default function InputCodeStep({\n\ttel,\n\tsmsCode,\n\tsetSmsCode,\n\tcodeError,\n\tsetCodeError,\n\tisLoggingIn,\n\tonPrev,\n\tonLogin,\n}: Props) {\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>输入验证码</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\tstyle={styles.description}\n\t\t\t\t>\n\t\t\t\t\t验证码已发送至 +86 {tel}\n\t\t\t\t</Text>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='短信验证码'\n\t\t\t\t\tvalue={smsCode}\n\t\t\t\t\tonChangeText={(v) => {\n\t\t\t\t\t\tsetSmsCode(v)\n\t\t\t\t\t\tsetCodeError('')\n\t\t\t\t\t}}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tkeyboardType='number-pad'\n\t\t\t\t\tautoComplete='one-time-code'\n\t\t\t\t\tstyle={styles.input}\n\t\t\t\t\terror={!!codeError}\n\t\t\t\t/>\n\t\t\t\t{codeError ? (\n\t\t\t\t\t<HelperText\n\t\t\t\t\t\ttype='error'\n\t\t\t\t\t\tvisible={!!codeError}\n\t\t\t\t\t>\n\t\t\t\t\t\t{codeError}\n\t\t\t\t\t</HelperText>\n\t\t\t\t) : null}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={onPrev}>上一步</Button>\n\t\t\t\t<Button\n\t\t\t\t\tmode='contained'\n\t\t\t\t\tonPress={onLogin}\n\t\t\t\t\tloading={isLoggingIn}\n\t\t\t\t\tdisabled={isLoggingIn}\n\t\t\t\t>\n\t\t\t\t\t登录\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tinput: {\n\t\tmarginTop: 8,\n\t},\n\tdescription: {\n\t\tmarginBottom: 8,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/steps/InputPhoneStep.tsx",
    "content": "import { StyleSheet } from 'react-native'\nimport { Dialog, HelperText, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n\ninterface Props {\n\ttel: string\n\tsetTel: (v: string) => void\n\tphoneError: string\n\tsetPhoneError: (v: string) => void\n\tisSendingCode: boolean\n\tonBack: () => void\n\tonRequestCode: () => void\n}\n\nexport default function InputPhoneStep({\n\ttel,\n\tsetTel,\n\tphoneError,\n\tsetPhoneError,\n\tisSendingCode,\n\tonBack,\n\tonRequestCode,\n}: Props) {\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>手机号登录</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='手机号'\n\t\t\t\t\tvalue={tel}\n\t\t\t\t\tonChangeText={(v) => {\n\t\t\t\t\t\tsetTel(v)\n\t\t\t\t\t\tsetPhoneError('')\n\t\t\t\t\t}}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tkeyboardType='phone-pad'\n\t\t\t\t\tautoComplete='tel'\n\t\t\t\t\tstyle={styles.input}\n\t\t\t\t\terror={!!phoneError}\n\t\t\t\t\tleft={<TextInput.Affix text='+86' />}\n\t\t\t\t/>\n\t\t\t\t{phoneError ? (\n\t\t\t\t\t<HelperText\n\t\t\t\t\t\ttype='error'\n\t\t\t\t\t\tvisible={!!phoneError}\n\t\t\t\t\t>\n\t\t\t\t\t\t{phoneError}\n\t\t\t\t\t</HelperText>\n\t\t\t\t) : null}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={onBack}>取消</Button>\n\t\t\t\t<Button\n\t\t\t\t\tmode='contained'\n\t\t\t\t\tonPress={onRequestCode}\n\t\t\t\t\tloading={isSendingCode}\n\t\t\t\t\tdisabled={isSendingCode}\n\t\t\t\t>\n\t\t\t\t\t获取验证码\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tinput: {\n\t\tmarginTop: 8,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/login/steps/SuccessStep.tsx",
    "content": "import { StyleSheet } from 'react-native'\nimport { Dialog, Text } from 'react-native-paper'\n\nexport default function SuccessStep() {\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>登录成功</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\tstyle={styles.description}\n\t\t\t\t>\n\t\t\t\t\t已成功登录 Bilibili 账号 🎉\n\t\t\t\t</Text>\n\t\t\t</Dialog.Content>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tdescription: {\n\t\tmarginBottom: 8,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/lyrics/EditLyrics.tsx",
    "content": "import { verify } from '@bbplayer/splash'\nimport * as WebBrowser from 'expo-web-browser'\nimport { useState } from 'react'\nimport { StyleSheet, Text, View, useWindowDimensions } from 'react-native'\nimport { Dialog, TextInput, useTheme } from 'react-native-paper'\nimport { TabBar, TabView } from 'react-native-tab-view'\n\nimport Button from '@/components/common/Button'\nimport { alert } from '@/components/modals/AlertModal'\nimport { lyricsQueryKeys } from '@/hooks/queries/lyrics'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { queryClient } from '@/lib/config/queryClient'\nimport lyricService from '@/lib/services/lyricService'\nimport type { LyricFileData } from '@/types/player/lyrics'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nexport default function EditLyricsModal({\n\tuniqueKey,\n\tlyrics,\n}: {\n\tuniqueKey: string\n\tlyrics: LyricFileData\n}) {\n\tconst close = useModalStore((state) => state.close)\n\tconst theme = useTheme()\n\tconst layout = useWindowDimensions()\n\n\tconst [lrc, setLrc] = useState(lyrics.lrc ?? '')\n\tconst [tlyric, setTlyric] = useState(lyrics.tlyric ?? '')\n\tconst [romalrc, setRomalrc] = useState(lyrics.romalrc ?? '')\n\n\tconst [index, setIndex] = useState(0)\n\tconst [routes] = useState([\n\t\t{ key: 'lrc', title: '主歌词' },\n\t\t{ key: 'tlyric', title: '翻译' },\n\t\t{ key: 'romalrc', title: '罗马音' },\n\t])\n\n\tconst renderScene = ({ route }: { route: { key: string } }) => {\n\t\tswitch (route.key) {\n\t\t\tcase 'lrc':\n\t\t\t\treturn (\n\t\t\t\t\t<View style={styles.inputContainer}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tlabel='主歌词'\n\t\t\t\t\t\t\tvalue={lrc}\n\t\t\t\t\t\t\tonChangeText={setLrc}\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tmultiline\n\t\t\t\t\t\t\tstyle={styles.textInput}\n\t\t\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\t\t\tplaceholder='在此输入 LRC 格式歌词'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t)\n\t\t\tcase 'tlyric':\n\t\t\t\treturn (\n\t\t\t\t\t<View style={styles.inputContainer}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tlabel='翻译'\n\t\t\t\t\t\t\tvalue={tlyric}\n\t\t\t\t\t\t\tonChangeText={setTlyric}\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tmultiline\n\t\t\t\t\t\t\tstyle={styles.textInput}\n\t\t\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\t\t\tplaceholder='在此输入翻译歌词'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t)\n\t\t\tcase 'romalrc':\n\t\t\t\treturn (\n\t\t\t\t\t<View style={styles.inputContainer}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tlabel='罗马音'\n\t\t\t\t\t\t\tvalue={romalrc}\n\t\t\t\t\t\t\tonChangeText={setRomalrc}\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tmultiline\n\t\t\t\t\t\t\tstyle={styles.textInput}\n\t\t\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\t\t\tplaceholder='在此输入罗马音歌词'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t)\n\t\t\tdefault:\n\t\t\t\treturn null\n\t\t}\n\t}\n\n\tconst saveLyrics = async () => {\n\t\tconst newLyricData: LyricFileData = {\n\t\t\t...lyrics,\n\t\t\tlrc,\n\t\t\ttlyric: tlyric || undefined,\n\t\t\tromalrc: romalrc || undefined,\n\t\t\tupdateTime: Date.now(),\n\t\t}\n\n\t\tconst result = await lyricService.saveLyricsToFile(newLyricData, uniqueKey)\n\n\t\tif (result.isErr()) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'保存歌词失败',\n\t\t\t\tresult.error,\n\t\t\t\t'Components.EditLyricsModal',\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tqueryClient.setQueryData(\n\t\t\tlyricsQueryKeys.smartFetchLyrics(uniqueKey),\n\t\t\tresult.value,\n\t\t)\n\t\ttoast.success('歌词保存成功')\n\t\tclose('EditLyrics')\n\t}\n\n\tconst handleConfirm = async () => {\n\t\tconst result = verify(lrc)\n\t\tif (result.isValid) {\n\t\t\tawait saveLyrics()\n\t\t} else {\n\t\t\talert(\n\t\t\t\t'歌词格式错误',\n\t\t\t\t`第 ${result.error.line} 行存在错误: ${result.error.message}`,\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\ttext: '取消',\n\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t// do nothing\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ttext: '仍要保存',\n\t\t\t\t\t\tonPress: saveLyrics,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t)\n\t\t}\n\t}\n\n\t// oxlint-disable-next-line @typescript-eslint/no-explicit-any\n\tconst renderTabBar = (props: any) => (\n\t\t<TabBar\n\t\t\t{...props}\n\t\t\tindicatorStyle={{ backgroundColor: theme.colors.onSecondaryContainer }}\n\t\t\tstyle={[styles.tabBar, { backgroundColor: theme.colors.surface }]}\n\t\t\tlabelStyle={{ fontWeight: 'bold' }}\n\t\t\tactiveColor={theme.colors.onSecondaryContainer}\n\t\t\tinactiveColor={theme.colors.onSurface}\n\t\t/>\n\t)\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>编辑歌词</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t<View style={styles.header}>\n\t\t\t\t\t<Text style={{ color: theme.colors.onSurfaceVariant }}>\n\t\t\t\t\t\t我们的歌词遵循 SPL(LRC) 规范，\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tcolor: theme.colors.primary,\n\t\t\t\t\t\t\ttextDecorationLine: 'underline',\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\tWebBrowser.openBrowserAsync(\n\t\t\t\t\t\t\t\t'https://moriafly.com/standards/spl.html',\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t点击查看规范详情\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t\t<TabView\n\t\t\t\t\tnavigationState={{ index, routes }}\n\t\t\t\t\trenderScene={renderScene}\n\t\t\t\t\tonIndexChange={setIndex}\n\t\t\t\t\tinitialLayout={{ width: layout.width }}\n\t\t\t\t\trenderTabBar={renderTabBar}\n\t\t\t\t\tstyle={styles.tabView}\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => close('EditLyrics')}>取消</Button>\n\t\t\t\t<Button onPress={handleConfirm}>确定</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tpaddingHorizontal: 0,\n\t\tpaddingBottom: 0,\n\t\theight: 350,\n\t},\n\theader: {\n\t\tpaddingHorizontal: 24,\n\t\tpaddingBottom: 12,\n\t\tflexDirection: 'row',\n\t\tflexWrap: 'wrap',\n\t},\n\ttabView: {\n\t\tflex: 1,\n\t},\n\ttabBar: {\n\t\toverflow: 'hidden',\n\t\tjustifyContent: 'center',\n\t\tmaxHeight: 70,\n\t\tmarginBottom: 0,\n\t\tmarginTop: 10,\n\t\televation: 0,\n\t},\n\tinputContainer: {\n\t\tflex: 1,\n\t\tpaddingHorizontal: 16,\n\t\tpaddingTop: 10,\n\t},\n\ttextInput: {\n\t\tflex: 1,\n\t\tfontSize: 14,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/lyrics/ManualSearchLyrics.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useMemo, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tDialog,\n\tSearchbar,\n\tText,\n\tTouchableRipple,\n} from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useFetchLyrics } from '@/hooks/mutations/lyrics'\nimport { useManualSearchLyrics } from '@/hooks/queries/lyrics'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport type { LyricSearchResult } from '@/types/player/lyrics'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nconst SOURCE_MAP = {\n\tnetease: '网易云',\n\tqqmusic: 'QQ 音乐',\n\tkuwo: '酷我',\n\tkugou: '酷狗',\n\tbaidu: '百度',\n}\n\nconst renderItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tLyricSearchResult[0],\n\t{\n\t\tisFetchingLyrics: boolean\n\t\thandlePressItem: (item: LyricSearchResult[0]) => void\n\t}\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\treturn (\n\t\t<SearchItem\n\t\t\titem={item}\n\t\t\tonPress={extraData.handlePressItem}\n\t\t\tdisabled={extraData.isFetchingLyrics}\n\t\t/>\n\t)\n}\n\nconst SearchItem = memo(function SearchItem({\n\titem,\n\tonPress,\n\tdisabled,\n}: {\n\titem: LyricSearchResult[0]\n\tonPress: (item: LyricSearchResult[0]) => void\n\tdisabled: boolean\n}) {\n\treturn (\n\t\t<TouchableRipple\n\t\t\tstyle={styles.searchItem}\n\t\t\tonPress={() => onPress(item)}\n\t\t\tdisabled={disabled}\n\t\t>\n\t\t\t<View style={styles.searchItemContent}>\n\t\t\t\t<Text variant='bodyMedium'>{item.title}</Text>\n\t\t\t\t<Text variant='bodySmall'>{`${item.artist} - ${formatDurationToHHMMSS(\n\t\t\t\t\tMath.round(item.duration),\n\t\t\t\t)} - ${SOURCE_MAP[item.source]}`}</Text>\n\t\t\t</View>\n\t\t</TouchableRipple>\n\t)\n})\n\nconst ManualSearchLyricsModal = ({\n\tuniqueKey,\n\tinitialQuery,\n}: {\n\tuniqueKey: string\n\tinitialQuery: string\n}) => {\n\tconst [query, setQuery] = useState(initialQuery)\n\tconst close = useModalStore((state) => state.close)\n\n\tconst {\n\t\tresults: searchResult,\n\t\tsearch: searchIt,\n\t\tisLoading: isSearching,\n\t} = useManualSearchLyrics(uniqueKey)\n\tconst { mutate: fetchLyrics, isPending: isFetchingLyrics } = useFetchLyrics()\n\tconst handlePressItem = useCallback(\n\t\t(item: LyricSearchResult[0]) => {\n\t\t\tfetchLyrics(\n\t\t\t\t{\n\t\t\t\t\tuniqueKey,\n\t\t\t\t\titem,\n\t\t\t\t},\n\t\t\t\t{ onSuccess: () => close('ManualSearchLyrics') },\n\t\t\t)\n\t\t},\n\t\t[close, fetchLyrics, uniqueKey],\n\t)\n\tconst extraData = useMemo(\n\t\t() => ({ isFetchingLyrics, handlePressItem }),\n\t\t[handlePressItem, isFetchingLyrics],\n\t)\n\n\tconst keyExtractor = useCallback(\n\t\t(item: LyricSearchResult[0]) => item.remoteId.toString(),\n\t\t[],\n\t)\n\n\tconst renderContent = () => {\n\t\tif (!searchResult) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centerContainer}>\n\t\t\t\t\t<Text style={styles.centerText}>请修改搜索关键词并回车搜索</Text>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\t// When loading initially (no results yet)\n\t\tif (isSearching && searchResult.length === 0) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centerContainer}>\n\t\t\t\t\t<ActivityIndicator size={'large'} />\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\tif (searchResult.length > 0) {\n\t\t\treturn (\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={searchResult}\n\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\textraData={extraData}\n\t\t\t\t/>\n\t\t\t)\n\t\t}\n\n\t\t// Search finished but nothing found\n\t\tif (!isSearching && searchResult.length === 0) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centerContainer}>\n\t\t\t\t\t<Text style={styles.centerText}>没有找到匹配的歌词</Text>\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\t// Fallback for edge cases\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title style={styles.dialogTitle}>\n\t\t\t\t<View style={styles.titleContainer}>\n\t\t\t\t\t<Text variant='headlineSmall'>手动搜索歌词</Text>\n\t\t\t\t\t{isSearching && (\n\t\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\t\tsize='small'\n\t\t\t\t\t\t\tstyle={styles.loadingIndicator}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<Searchbar\n\t\t\t\t\tvalue={query}\n\t\t\t\t\tonChangeText={setQuery}\n\t\t\t\t\tplaceholder='输入歌曲名'\n\t\t\t\t\tonSubmitEditing={() => searchIt(query)}\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.ScrollArea style={styles.scrollArea}>\n\t\t\t\t{renderContent()}\n\t\t\t</Dialog.ScrollArea>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={() => close('ManualSearchLyrics')}\n\t\t\t\t\tdisabled={isFetchingLyrics}\n\t\t\t\t>\n\t\t\t\t\t取消\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tsearchItem: {\n\t\tflexDirection: 'column',\n\t\tpaddingVertical: 8,\n\t},\n\tsearchItemContent: {\n\t\tflexDirection: 'column',\n\t},\n\tcenterContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tcenterText: {\n\t\ttextAlign: 'center',\n\t},\n\tscrollArea: {\n\t\theight: 300,\n\t},\n\tloadingOverlay: {\n\t\tpaddingVertical: 10,\n\t\talignItems: 'center',\n\t},\n\tdialogTitle: {\n\t\talignItems: 'center',\n\t},\n\ttitleContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tgap: 8,\n\t},\n\tloadingIndicator: {\n\t\tmarginLeft: 8,\n\t},\n})\n\nexport default ManualSearchLyricsModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/player/DanmakuSettingsModal.tsx",
    "content": "import Slider from '@react-native-community/slider'\nimport { useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, Switch, Text } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useAppStore } from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nconst DanmakuSettingsModal = () => {\n\tconst close = useModalStore((state) => state.close)\n\tconst enableDanmaku = useAppStore((state) => state.settings.enableDanmaku)\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\tconst danmakuFilterLevel = useAppStore(\n\t\t(state) => state.settings.danmakuFilterLevel,\n\t)\n\n\tconst [tempFilterLevel, setTempFilterLevel] = useState(danmakuFilterLevel)\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>弹幕设置</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<View style={styles.row}>\n\t\t\t\t\t<Text variant='bodyLarge'>启用弹幕</Text>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tvalue={enableDanmaku}\n\t\t\t\t\t\tonValueChange={(value) => setSettings({ enableDanmaku: value })}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.divider} />\n\n\t\t\t\t<Text variant='bodyLarge'>屏蔽等级: {tempFilterLevel}</Text>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\tstyle={styles.description}\n\t\t\t\t>\n\t\t\t\t\t等级越高，屏蔽的弹幕越多（与 B 站的根据弹幕质量过滤相同）\n\t\t\t\t</Text>\n\t\t\t\t<Slider\n\t\t\t\t\tstyle={styles.slider}\n\t\t\t\t\tminimumValue={0}\n\t\t\t\t\tmaximumValue={10}\n\t\t\t\t\tstep={1}\n\t\t\t\t\tvalue={tempFilterLevel}\n\t\t\t\t\tonValueChange={setTempFilterLevel}\n\t\t\t\t\tonSlidingComplete={(value) =>\n\t\t\t\t\t\tsetSettings({ danmakuFilterLevel: value })\n\t\t\t\t\t}\n\t\t\t\t\tminimumTrackTintColor='#6200ee'\n\t\t\t\t\tmaximumTrackTintColor='#000000'\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => close('DanmakuSettings')}>确定</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\trow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\talignItems: 'center',\n\t\tmarginBottom: 16,\n\t},\n\tdivider: {\n\t\theight: 1,\n\t\tbackgroundColor: '#e0e0e0',\n\t\tmarginBottom: 16,\n\t},\n\tdescription: {\n\t\tcolor: '#666',\n\t\tmarginBottom: 8,\n\t},\n\tslider: {\n\t\twidth: '100%',\n\t\theight: 40,\n\t},\n})\n\nexport default DanmakuSettingsModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/player/LyricsSelectionModal.tsx",
    "content": "import ImageThemeColors from '@bbplayer/image-theme-colors'\nimport { parseSpl, type LyricLine } from '@bbplayer/splash'\nimport { FlashList } from '@shopify/flash-list'\nimport { Image, useImage } from 'expo-image'\nimport * as MediaLibrary from 'expo-media-library'\nimport * as Sharing from 'expo-sharing'\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tCheckbox,\n\tDialog,\n\tText,\n\tTouchableRipple,\n\tuseTheme,\n} from 'react-native-paper'\nimport type ViewShot from 'react-native-view-shot'\nimport { captureRef } from 'react-native-view-shot'\n\nimport Button from '@/components/common/Button'\nimport { LyricsShareCard } from '@/features/player/components/sharing/LyricsShareCard'\nimport { useCurrentTrack } from '@/hooks/player/useCurrentTrack'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport { useGetMultiPageList } from '@/hooks/queries/bilibili/video'\nimport { useSmartFetchLyrics } from '@/hooks/queries/lyrics'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { ModalPropsMap } from '@/types/navigation'\nimport toast from '@/utils/toast'\n\nconst LyricItem = memo(function LyricItem({\n\titem,\n\tindex,\n\tisSelected,\n\tonToggle,\n\tprimaryColor,\n\tonSurfaceColor,\n\tonSurfaceVariantColor,\n}: {\n\titem: LyricLine\n\tindex: number\n\tisSelected: boolean\n\tonToggle: (index: number) => void\n\tprimaryColor: string\n\tonSurfaceColor: string\n\tonSurfaceVariantColor: string\n}) {\n\treturn (\n\t\t<TouchableRipple onPress={() => onToggle(index)}>\n\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t<View style={{ flex: 1 }}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyLarge'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tfontWeight: isSelected ? 'bold' : 'normal',\n\t\t\t\t\t\t\tcolor: isSelected ? primaryColor : onSurfaceColor,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.content}\n\t\t\t\t\t</Text>\n\t\t\t\t\t{item.translations?.[0] && (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tcolor: isSelected ? primaryColor : onSurfaceVariantColor,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{item.translations[0]}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t\t<Checkbox\n\t\t\t\t\tstatus={isSelected ? 'checked' : 'unchecked'}\n\t\t\t\t\tonPress={() => onToggle(index)}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</TouchableRipple>\n\t)\n})\n\nconst sanitizeFileName = (name: string) => name.replace(/[/\\\\?%*:|\"<>]/g, '-')\n\nasync function performShare(\n\taction: 'save' | 'share',\n\tpreviewUri: string | null,\n\tviewShotRef: { current: ViewShot | null },\n\tpermissionStatus: MediaLibrary.PermissionStatus | undefined,\n\trequestPermission: () => Promise<{ status: MediaLibrary.PermissionStatus }>,\n\tsetIsSharing: (value: boolean) => void,\n\tisSharingRef: { current: boolean },\n\tclose: (name: keyof ModalPropsMap) => void,\n) {\n\tisSharingRef.current = true\n\tsetIsSharing(true)\n\n\ttry {\n\t\tlet uri = previewUri\n\t\tconst needsCapture = !uri && viewShotRef.current !== null\n\t\tif (needsCapture) {\n\t\t\ttry {\n\t\t\t\tconst fileName = `bbplayer-share-lyrics-${Date.now()}`\n\t\t\t\turi = await captureRef(viewShotRef, {\n\t\t\t\t\tformat: 'png',\n\t\t\t\t\tquality: 1,\n\t\t\t\t\tresult: 'tmpfile',\n\t\t\t\t\tfileName,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\ttoast.error('生成图片失败')\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif (!uri) {\n\t\t\ttoast.error('生成图片失败')\n\t\t\treturn\n\t\t}\n\n\t\tif (\n\t\t\taction === 'save' &&\n\t\t\tpermissionStatus !== MediaLibrary.PermissionStatus.GRANTED\n\t\t) {\n\t\t\tconst { status } = await requestPermission()\n\t\t\tif (status !== MediaLibrary.PermissionStatus.GRANTED) {\n\t\t\t\ttoast.error('无法保存图片', { description: '请允许访问相册' })\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif (action === 'save') {\n\t\t\tawait MediaLibrary.saveToLibraryAsync(uri)\n\t\t\ttoast.success('已保存到相册')\n\t\t} else {\n\t\t\tconst sharingAvailable = await Sharing.isAvailableAsync()\n\t\t\tif (sharingAvailable) {\n\t\t\t\tawait Sharing.shareAsync(uri)\n\t\t\t} else {\n\t\t\t\ttoast.error('分享不可用')\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tclose('LyricsSelection')\n\t} catch {\n\t\ttoast.error('操作失败')\n\t} finally {\n\t\tsetIsSharing(false)\n\t\tisSharingRef.current = false\n\t}\n}\n\nconst LyricsSelectionModal = () => {\n\tconst theme = useTheme()\n\tconst currentTrack = useCurrentTrack()\n\tconst close = useModalStore((state) => state.close)\n\n\tconst {\n\t\tdata: lyricsData,\n\t\tisPending,\n\t\tisError,\n\t\terror,\n\t} = useSmartFetchLyrics(true, currentTrack ?? undefined)\n\n\tconst lyrics = useMemo(() => {\n\t\tif (!lyricsData?.lrc) return []\n\t\ttry {\n\t\t\tconst { lines: parsedLines } = parseSpl(lyricsData.lrc)\n\t\t\tconst translationMap = new Map<number, string>()\n\t\t\tif (lyricsData.tlyric) {\n\t\t\t\ttry {\n\t\t\t\t\tconst { lines: transLines } = parseSpl(lyricsData.tlyric)\n\t\t\t\t\ttransLines.forEach((l) => {\n\t\t\t\t\t\ttranslationMap.set(l.startTime, l.content)\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (translationMap.size > 0) {\n\t\t\t\tparsedLines.forEach((l) => {\n\t\t\t\t\tconst trans = translationMap.get(l.startTime)\n\t\t\t\t\tif (trans) {\n\t\t\t\t\t\tif (!l.translations) l.translations = []\n\t\t\t\t\t\tl.translations.push(trans)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn parsedLines\n\t\t} catch {\n\t\t\treturn []\n\t\t}\n\t}, [lyricsData])\n\n\tconst [selectedIndices, setSelectedIndices] = useState<Set<number>>(\n\t\t() => new Set(),\n\t)\n\tconst [permissionResponse, requestPermission] = MediaLibrary.usePermissions()\n\tconst [isSharing, setIsSharing] = useState(false)\n\tconst [showPreview, setShowPreview] = useState(false)\n\tconst [previewUri, setPreviewUri] = useState<string | null>(null)\n\tconst [isGenerating, setIsGenerating] = useState(false)\n\tconst [cardColor, setCardColor] = useState(theme.colors.elevation.level3)\n\tconst imageRef = useImage(\n\t\t{\n\t\t\turi:\n\t\t\t\tresolveTrackCover(currentTrack?.uniqueKey, currentTrack?.coverUrl) ??\n\t\t\t\t'',\n\t\t},\n\t\t{\n\t\t\tonError: () => void 0,\n\t\t},\n\t)\n\n\tconst isBilibili = currentTrack?.source === 'bilibili'\n\tconst bvid = isBilibili ? currentTrack.bilibiliMetadata.bvid : undefined\n\tconst cid = isBilibili ? currentTrack.bilibiliMetadata.cid : undefined\n\n\tconst { data: pageList, isPending: isPageListQueryPending } =\n\t\tuseGetMultiPageList(bvid)\n\tconst isPageListPending = !!cid && isPageListQueryPending\n\n\t// 计算 shareUrl\n\tlet shareUrl = `https://bbplayer.roitium.com/share/track?id=${encodeURIComponent(currentTrack?.uniqueKey ?? '')}&title=${encodeURIComponent(currentTrack?.title ?? '')}&cover=${encodeURIComponent(currentTrack?.coverUrl ?? '')}`\n\tif (cid && pageList) {\n\t\tconst page = pageList.find((p) => p.cid === cid)\n\t\tif (page) {\n\t\t\tshareUrl += `&p=${page.page}`\n\t\t}\n\t}\n\n\tconst viewShotRef = useRef<ViewShot>(null)\n\n\tuseEffect(() => {\n\t\tif (imageRef) {\n\t\t\tImageThemeColors.extractThemeColorAsync(imageRef)\n\t\t\t\t.then((palette) => {\n\t\t\t\t\tif (!palette) {\n\t\t\t\t\t\tsetCardColor(theme.colors.elevation.level3)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tconst bgColor = theme.dark\n\t\t\t\t\t\t? (palette.darkMuted?.hex ?? palette.muted?.hex)\n\t\t\t\t\t\t: (palette.lightMuted?.hex ?? palette.muted?.hex)\n\n\t\t\t\t\tif (bgColor) {\n\t\t\t\t\t\tsetCardColor(bgColor)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsetCardColor(theme.colors.elevation.level3)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.catch(() => {\n\t\t\t\t\tsetCardColor(theme.colors.elevation.level3)\n\t\t\t\t})\n\t\t} else {\n\t\t\tsetCardColor(theme.colors.elevation.level3)\n\t\t}\n\t}, [imageRef, theme.colors.elevation.level3, theme.dark])\n\n\tconst toggleSelection = useCallback((index: number) => {\n\t\tsetSelectedIndices((prev) => {\n\t\t\tconst newSelected = new Set(prev)\n\t\t\tif (newSelected.has(index)) {\n\t\t\t\tnewSelected.delete(index)\n\t\t\t} else {\n\t\t\t\tif (newSelected.size >= 5) {\n\t\t\t\t\ttoast.error('最多选择 5 句歌词')\n\t\t\t\t\treturn prev\n\t\t\t\t}\n\t\t\t\tnewSelected.add(index)\n\t\t\t}\n\t\t\treturn newSelected\n\t\t})\n\t\t// 选择变化后清除旧预览\n\t\tsetPreviewUri(null)\n\t}, [])\n\n\tconst keyExtractor = useCallback(\n\t\t(item: LyricLine, index: number) => `${index}-${item.startTime}`,\n\t\t[],\n\t)\n\n\tconst generatePreview = async () => {\n\t\tif (!viewShotRef.current) {\n\t\t\ttoast.error('无法生成预览')\n\t\t\treturn\n\t\t}\n\n\t\tif (isPageListPending) {\n\t\t\ttoast.info('正在获取分享链接，请稍候')\n\t\t\treturn\n\t\t}\n\n\t\tsetIsGenerating(true)\n\t\tconst fileName = `bbplayer-share-lyrics-${sanitizeFileName(currentTrack?.uniqueKey ?? '')}-${Date.now()}`\n\t\ttry {\n\t\t\tconst uri = await captureRef(viewShotRef, {\n\t\t\t\tformat: 'png',\n\t\t\t\tquality: 1,\n\t\t\t\tresult: 'tmpfile',\n\t\t\t\tfileName,\n\t\t\t})\n\t\t\tsetPreviewUri(uri)\n\t\t\tsetShowPreview(true)\n\t\t\tsetIsGenerating(false)\n\t\t} catch {\n\t\t\ttoast.error('生成预览失败')\n\t\t\tsetIsGenerating(false)\n\t\t}\n\t}\n\n\tconst isSharingRef = useRef(false)\n\n\tconst handleShare = (action: 'save' | 'share') => {\n\t\tif (selectedIndices.size === 0) {\n\t\t\ttoast.error('请先选择歌词')\n\t\t\treturn\n\t\t}\n\n\t\tif (isSharingRef.current) return\n\n\t\tvoid performShare(\n\t\t\taction,\n\t\t\tpreviewUri,\n\t\t\tviewShotRef,\n\t\t\tpermissionResponse?.status,\n\t\t\trequestPermission,\n\t\t\tsetIsSharing,\n\t\t\tisSharingRef,\n\t\t\tclose,\n\t\t)\n\t}\n\n\tconst renderItem = useCallback(\n\t\t({ item, index }: { item: LyricLine; index: number }) => (\n\t\t\t<LyricItem\n\t\t\t\titem={item}\n\t\t\t\tindex={index}\n\t\t\t\tisSelected={selectedIndices.has(index)}\n\t\t\t\tonToggle={toggleSelection}\n\t\t\t\tprimaryColor={theme.colors.primary}\n\t\t\t\tonSurfaceColor={theme.colors.onSurface}\n\t\t\t\tonSurfaceVariantColor={theme.colors.onSurfaceVariant}\n\t\t\t/>\n\t\t),\n\t\t[\n\t\t\tselectedIndices,\n\t\t\ttheme.colors.onSurface,\n\t\t\ttheme.colors.onSurfaceVariant,\n\t\t\ttheme.colors.primary,\n\t\t\ttoggleSelection,\n\t\t],\n\t)\n\n\tif (!currentTrack) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>选择歌词分享</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.errorContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t当前没有正在播放的歌曲\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('LyricsSelection')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (currentTrack.source !== 'bilibili') {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>选择歌词分享</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.errorContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t当前仅支持分享 Bilibili 来源的歌曲\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('LyricsSelection')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (isPending) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>选择歌词分享</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.loadingContainer}>\n\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.loadingText}\n\t\t\t\t\t>\n\t\t\t\t\t\t正在加载歌词...\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('LyricsSelection')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (isError) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>选择歌词分享</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.errorContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t歌词加载失败：{error?.message ?? '未知错误'}\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('LyricsSelection')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (!lyrics || lyrics.length === 0) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>选择歌词分享</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.errorContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t暂无歌词\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('LyricsSelection')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\t// 预览模式：显示生成的预览图\n\tif (showPreview && previewUri) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>预览分享卡片</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.previewContentArea}>\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsource={{ uri: previewUri }}\n\t\t\t\t\t\tstyle={styles.previewImage}\n\t\t\t\t\t\tcontentFit='contain'\n\t\t\t\t\t/>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions style={styles.actions}>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='text'\n\t\t\t\t\t\tonPress={() => setShowPreview(false)}\n\t\t\t\t\t\ticon='arrow-left'\n\t\t\t\t\t\tcompact\n\t\t\t\t\t>\n\t\t\t\t\t\t返回选择\n\t\t\t\t\t</Button>\n\t\t\t\t\t<View style={{ flex: 1 }} />\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tonPress={() => handleShare('save')}\n\t\t\t\t\t\tloading={isSharing}\n\t\t\t\t\t\tdisabled={isSharing}\n\t\t\t\t\t\ticon='download'\n\t\t\t\t\t>\n\t\t\t\t\t\t保存\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\tonPress={() => handleShare('share')}\n\t\t\t\t\t\tloading={isSharing}\n\t\t\t\t\t\tdisabled={isSharing}\n\t\t\t\t\t\ticon='share-variant'\n\t\t\t\t\t>\n\t\t\t\t\t\t分享\n\t\t\t\t\t</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>选择歌词分享 ({selectedIndices.size}/5)</Dialog.Title>\n\t\t\t<Dialog.ScrollArea style={styles.scrollArea}>\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={lyrics}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\t/>\n\t\t\t</Dialog.ScrollArea>\n\t\t\t<Dialog.Actions style={styles.actions}>\n\t\t\t\t<Button\n\t\t\t\t\tmode='text'\n\t\t\t\t\tonPress={generatePreview}\n\t\t\t\t\ticon='eye'\n\t\t\t\t\tcompact\n\t\t\t\t\tdisabled={selectedIndices.size === 0 || isGenerating}\n\t\t\t\t\tloading={isGenerating}\n\t\t\t\t>\n\t\t\t\t\t预览\n\t\t\t\t</Button>\n\t\t\t\t<View style={{ flex: 1 }} />\n\t\t\t\t<Button\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tonPress={() => handleShare('save')}\n\t\t\t\t\tloading={isSharing}\n\t\t\t\t\tdisabled={isSharing || selectedIndices.size === 0}\n\t\t\t\t\ticon='download'\n\t\t\t\t>\n\t\t\t\t\t保存\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tmode='contained'\n\t\t\t\t\tonPress={() => handleShare('share')}\n\t\t\t\t\tloading={isSharing}\n\t\t\t\t\tdisabled={isSharing || selectedIndices.size === 0}\n\t\t\t\t\ticon='share-variant'\n\t\t\t\t>\n\t\t\t\t\t分享\n\t\t\t\t</Button>\n\t\t\t\t<Button onPress={() => close('LyricsSelection')}>关闭</Button>\n\t\t\t</Dialog.Actions>\n\n\t\t\t{/* Hidden Capture View - 始终渲染以确保 viewShotRef 可用 */}\n\t\t\t<View\n\t\t\t\tstyle={styles.hiddenCapture}\n\t\t\t\tpointerEvents='none'\n\t\t\t>\n\t\t\t\t<LyricsShareCard\n\t\t\t\t\ttitle={currentTrack.title}\n\t\t\t\t\tartistName={currentTrack.artist?.name ?? 'Unknown Artist'}\n\t\t\t\t\timageRef={imageRef}\n\t\t\t\t\tshareUrl={shareUrl}\n\t\t\t\t\tselectedLyrics={lyrics.filter((_, i) => selectedIndices.has(i))}\n\t\t\t\t\tviewShotRef={viewShotRef}\n\t\t\t\t\tbackgroundColor={cardColor}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tscrollArea: {\n\t\theight: 350,\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 10,\n\t},\n\tactions: {\n\t\tflexWrap: 'wrap',\n\t\tgap: 4,\n\t},\n\tpreviewContentArea: {\n\t\tpaddingHorizontal: 16,\n\t\tminHeight: 200,\n\t},\n\tpreviewImage: {\n\t\twidth: '100%',\n\t\taspectRatio: 0.8,\n\t\tborderRadius: 12,\n\t},\n\tloadingContainer: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpaddingVertical: 40,\n\t},\n\tloadingText: {\n\t\tmarginTop: 16,\n\t\topacity: 0.7,\n\t},\n\terrorContainer: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpaddingVertical: 40,\n\t},\n\terrorText: {\n\t\topacity: 0.7,\n\t\ttextAlign: 'center',\n\t},\n\thiddenCapture: {\n\t\tposition: 'absolute',\n\t\ttop: 99999,\n\t\tleft: 0,\n\t\topacity: 0,\n\t},\n})\n\nexport default LyricsSelectionModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/player/PlaybackSpeedModal.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useEffect, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nconst PRESET_SPEEDS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]\n\nconst PlaybackSpeedModal = () => {\n\tconst close = useModalStore((state) => state.close)\n\tconst [speed, setSpeed] = useState<number>(1.0)\n\tconst [customInputVisible, setCustomInputVisible] = useState(false)\n\tconst [customSpeed, setCustomSpeed] = useState('')\n\n\tuseEffect(() => {\n\t\tvoid Orpheus.getPlaybackSpeed().then(setSpeed)\n\n\t\tconst subscription = Orpheus.addListener(\n\t\t\t'onPlaybackSpeedChanged',\n\t\t\t(event: { speed: number }) => {\n\t\t\t\tsetSpeed(event.speed)\n\t\t\t},\n\t\t)\n\t\treturn () => subscription.remove()\n\t}, [])\n\n\tconst handleSpeedChange = async (newSpeed: number) => {\n\t\ttry {\n\t\t\tconst clampedSpeed = Math.max(0.1, Math.min(5.0, newSpeed))\n\t\t\tawait Orpheus.setPlaybackSpeed(clampedSpeed)\n\t\t\tsetSpeed(clampedSpeed)\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('设置播放速度失败', e, 'Modal.PlaybackSpeed')\n\t\t}\n\t}\n\n\tconst handleCustomSpeedSubmit = async () => {\n\t\tconst parsedSpeed = parseFloat(customSpeed)\n\t\tif (!isNaN(parsedSpeed) && parsedSpeed > 0) {\n\t\t\tawait handleSpeedChange(parsedSpeed)\n\t\t\tsetCustomInputVisible(false)\n\t\t} else {\n\t\t\ttoast.error('请输入有效的播放速度')\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>播放速度</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='headlineMedium'\n\t\t\t\t\t\tstyle={styles.speedDisplay}\n\t\t\t\t\t>\n\t\t\t\t\t\t当前: {speed.toFixed(2)}x\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.presetContainer}>\n\t\t\t\t\t{PRESET_SPEEDS.map((preset) => (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tkey={preset}\n\t\t\t\t\t\t\tmode={\n\t\t\t\t\t\t\t\tMath.abs(speed - preset) < 0.01\n\t\t\t\t\t\t\t\t\t? 'contained'\n\t\t\t\t\t\t\t\t\t: 'contained-tonal'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tonPress={() => handleSpeedChange(preset)}\n\t\t\t\t\t\t\tstyle={styles.presetButton}\n\t\t\t\t\t\t\tcompact\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{preset}x\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t))}\n\t\t\t\t</View>\n\n\t\t\t\t{customInputVisible ? (\n\t\t\t\t\t<View style={styles.customInputContainer}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tlabel='自定义速度 (0.1 - 5.0)'\n\t\t\t\t\t\t\tvalue={customSpeed}\n\t\t\t\t\t\t\tonChangeText={setCustomSpeed}\n\t\t\t\t\t\t\tkeyboardType='numeric'\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tstyle={styles.customInput}\n\t\t\t\t\t\t\tonSubmitEditing={handleCustomSpeedSubmit}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\tonPress={handleCustomSpeedSubmit}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t设置\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='text'\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\tsetCustomSpeed(speed.toString())\n\t\t\t\t\t\t\tsetCustomInputVisible(true)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t自定义...\n\t\t\t\t\t</Button>\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => handleSpeedChange(1.0)}>重置</Button>\n\t\t\t\t<Button onPress={() => close('PlaybackSpeed')}>关闭</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\theaderContainer: {\n\t\talignItems: 'center',\n\t\tmarginBottom: 16,\n\t},\n\tspeedDisplay: {\n\t\tfontWeight: 'bold',\n\t},\n\tpresetContainer: {\n\t\tflexDirection: 'row',\n\t\tflexWrap: 'wrap',\n\t\tjustifyContent: 'center',\n\t\tgap: 8,\n\t\tmarginBottom: 8,\n\t},\n\tpresetButton: {\n\t\tminWidth: '30%',\n\t\tflexGrow: 1,\n\t},\n\tcustomInputContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginTop: 8,\n\t},\n\tcustomInput: {\n\t\tflex: 1,\n\t\tmarginRight: 8,\n\t},\n})\n\nexport default PlaybackSpeedModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/player/SleepTimerModal.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useEffect, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useSleepTimerEndTime } from '@/hooks/queries/orpheus'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { formatDurationToHHMMSS } from '@/utils/time'\nimport toast from '@/utils/toast'\n\nconst PRESET_DURATIONS = [15, 30, 45, 60] // in minutes\n\nconst SleepTimerModal = () => {\n\tconst close = useModalStore((state) => state.close)\n\tconst { data: sleepTimerEndAt } = useSleepTimerEndTime()\n\tconst [remainingTime, setRemainingTime] = useState<number | null>(null)\n\tconst [customInputVisible, setCustomInputVisible] = useState(false)\n\tconst [customMinutes, setCustomMinutes] = useState('')\n\n\tuseEffect(() => {\n\t\tif (sleepTimerEndAt) {\n\t\t\tconst interval = setInterval(() => {\n\t\t\t\tconst remaining = Math.round((sleepTimerEndAt - Date.now()) / 1000)\n\t\t\t\tif (remaining > 0) {\n\t\t\t\t\tsetRemainingTime(remaining)\n\t\t\t\t} else {\n\t\t\t\t\tsetRemainingTime(null)\n\t\t\t\t\tclearInterval(interval)\n\t\t\t\t}\n\t\t\t}, 1000)\n\t\t\tconst remaining = Math.round((sleepTimerEndAt - Date.now()) / 1000)\n\t\t\tsetRemainingTime(remaining > 0 ? remaining : null)\n\n\t\t\treturn () => clearInterval(interval)\n\t\t} else {\n\t\t\tsetRemainingTime(null)\n\t\t}\n\t}, [sleepTimerEndAt])\n\n\tconst handleSetTimer = async (minutes: number) => {\n\t\ttry {\n\t\t\tawait Orpheus.setSleepTimer(minutes * 60 * 1000)\n\t\t\ttoast.success('设置定时器成功')\n\t\t\tclose('SleepTimer')\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('设置定时器失败', e, 'Modal.SleepTimer')\n\t\t}\n\t}\n\n\tconst handleCancelTimer = async () => {\n\t\ttry {\n\t\t\tawait Orpheus.cancelSleepTimer()\n\t\t\ttoast.success('取消定时器成功')\n\t\t\tclose('SleepTimer')\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('取消定时器失败', e, 'Modal.SleepTimer')\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>定时关闭</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t{remainingTime ? (\n\t\t\t\t\t<View style={styles.remainingTimeContainer}>\n\t\t\t\t\t\t<Text variant='headlineMedium'>\n\t\t\t\t\t\t\t剩余 {formatDurationToHHMMSS(remainingTime)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<Text style={styles.promptText}>选择一个预设时间或自定义</Text>\n\t\t\t\t)}\n\t\t\t\t<View style={styles.presetContainer}>\n\t\t\t\t\t{PRESET_DURATIONS.map((minutes) => (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tkey={minutes}\n\t\t\t\t\t\t\tmode='contained-tonal'\n\t\t\t\t\t\t\tonPress={() => handleSetTimer(minutes)}\n\t\t\t\t\t\t\tstyle={styles.presetButton}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{minutes}\n\t\t\t\t\t\t\t{'\\u2009'}分钟\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t))}\n\t\t\t\t</View>\n\t\t\t\t{customInputVisible ? (\n\t\t\t\t\t<View style={styles.customInputContainer}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tlabel='分钟'\n\t\t\t\t\t\t\tvalue={customMinutes}\n\t\t\t\t\t\t\tonChangeText={setCustomMinutes}\n\t\t\t\t\t\t\tkeyboardType='numeric'\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tstyle={styles.customInput}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\t\t\tconst minutes = parseInt(customMinutes, 10)\n\t\t\t\t\t\t\t\tif (!isNaN(minutes) && minutes > 0) {\n\t\t\t\t\t\t\t\t\tawait handleSetTimer(minutes)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t设置\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='text'\n\t\t\t\t\t\tonPress={() => setCustomInputVisible(true)}\n\t\t\t\t\t>\n\t\t\t\t\t\t自定义\n\t\t\t\t\t</Button>\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t{sleepTimerEndAt && (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={handleCancelTimer}\n\t\t\t\t\t\ttextColor='red'\n\t\t\t\t\t>\n\t\t\t\t\t\t取消定时器\n\t\t\t\t\t</Button>\n\t\t\t\t)}\n\t\t\t\t<Button onPress={() => close('SleepTimer')}>关闭</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tremainingTimeContainer: {\n\t\talignItems: 'center',\n\t\tmarginBottom: 16,\n\t},\n\tpromptText: {\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 16,\n\t},\n\tpresetContainer: {\n\t\tflexDirection: 'row',\n\t\tflexWrap: 'wrap',\n\t\tjustifyContent: 'center',\n\t\tgap: 8,\n\t\tmarginBottom: 8,\n\t},\n\tpresetButton: {\n\t\tflexBasis: '45%',\n\t\tflexGrow: 1,\n\t},\n\tcustomInputContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tcustomInput: {\n\t\tflex: 1,\n\t\tmarginRight: 8,\n\t},\n})\n\nexport default SleepTimerModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/player/SongShareModal.tsx",
    "content": "import ImageThemeColors from '@bbplayer/image-theme-colors'\nimport { Image, useImage } from 'expo-image'\nimport * as MediaLibrary from 'expo-media-library'\nimport * as Sharing from 'expo-sharing'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { ActivityIndicator, Dialog, Text, useTheme } from 'react-native-paper'\nimport type ViewShot from 'react-native-view-shot'\nimport { captureRef } from 'react-native-view-shot'\n\nimport Button from '@/components/common/Button'\nimport { SongShareCard } from '@/features/player/components/sharing/SongShareCard'\nimport { useCurrentTrack } from '@/hooks/player/useCurrentTrack'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport { useGetMultiPageList } from '@/hooks/queries/bilibili/video'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport toast from '@/utils/toast'\n\nconst sanitizeFileName = (name: string) => name.replace(/[/\\\\?%*:|\"<>]/g, '-')\n\nasync function performShare(\n\taction: 'save' | 'share',\n\tpreviewUri: string | null,\n\tviewShotRef: { current: ViewShot | null },\n\tuniqueKey: string,\n\tpermissionResponse: MediaLibrary.PermissionResponse | null,\n\trequestPermission: () => Promise<MediaLibrary.PermissionResponse>,\n\tcloseModal: () => void,\n\tsetIsSharing: (v: boolean) => void,\n\tisSharingRef: { current: boolean },\n) {\n\tisSharingRef.current = true\n\tsetIsSharing(true)\n\n\ttry {\n\t\tlet uri = previewUri\n\t\tconst needsCapture = !uri && viewShotRef.current !== null\n\t\tif (needsCapture) {\n\t\t\tconst fileName = `bbplayer-share-song-${sanitizeFileName(uniqueKey)}-${Date.now()}`\n\t\t\ttry {\n\t\t\t\turi = await captureRef(viewShotRef, {\n\t\t\t\t\tformat: 'png',\n\t\t\t\t\tquality: 1,\n\t\t\t\t\tresult: 'tmpfile',\n\t\t\t\t\tfileName,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\ttoast.error('生成图片失败')\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif (!uri) {\n\t\t\ttoast.error('生成图片失败')\n\t\t\treturn\n\t\t}\n\n\t\tconst permissionStatus = permissionResponse?.status\n\t\tif (\n\t\t\taction === 'save' &&\n\t\t\tpermissionStatus !== MediaLibrary.PermissionStatus.GRANTED\n\t\t) {\n\t\t\tconst { status } = await requestPermission()\n\t\t\tif (status !== MediaLibrary.PermissionStatus.GRANTED) {\n\t\t\t\ttoast.error('无法保存图片', { description: '请允许访问相册' })\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif (action === 'save') {\n\t\t\tawait MediaLibrary.saveToLibraryAsync(uri)\n\t\t\ttoast.success('已保存到相册')\n\t\t} else {\n\t\t\tconst sharingAvailable = await Sharing.isAvailableAsync()\n\t\t\tif (sharingAvailable) {\n\t\t\t\tawait Sharing.shareAsync(uri)\n\t\t\t} else {\n\t\t\t\ttoast.error('分享不可用')\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tcloseModal()\n\t} catch {\n\t\ttoast.error('操作失败')\n\t} finally {\n\t\tsetIsSharing(false)\n\t\tisSharingRef.current = false\n\t}\n}\n\nconst SongShareModal = () => {\n\tconst currentTrack = useCurrentTrack()\n\tconst close = useModalStore((state) => state.close)\n\n\tconst theme = useTheme()\n\tconst [permissionResponse, requestPermission] = MediaLibrary.usePermissions()\n\tconst [isSharing, setIsSharing] = useState(false)\n\tconst [previewUri, setPreviewUri] = useState<string | null>(null)\n\tconst [isGenerating, setIsGenerating] = useState(true)\n\tconst [cardColor, setCardColor] = useState(theme.colors.elevation.level3)\n\n\tconst isBilibili = currentTrack?.source === 'bilibili'\n\tconst bvid = isBilibili ? currentTrack.bilibiliMetadata.bvid : undefined\n\tconst cid = isBilibili ? currentTrack.bilibiliMetadata.cid : undefined\n\n\t// 只有在有 cid 的情况下才请求分 P 列表，否则没意义\n\tconst { data: pageList, isPending: isPageListQueryPending } =\n\t\tuseGetMultiPageList(cid ? bvid : undefined)\n\n\tconst isPageListPending = !!cid && isPageListQueryPending\n\n\tconst resolvedCoverUrl = resolveTrackCover(\n\t\tcurrentTrack?.uniqueKey,\n\t\tcurrentTrack?.coverUrl,\n\t)\n\n\tconst imageRef = useImage(\n\t\t{ uri: resolvedCoverUrl ?? '' },\n\t\t{\n\t\t\tonError: () => void 0,\n\t\t},\n\t)\n\n\tconst viewShotRef = useRef<ViewShot>(null)\n\n\tuseEffect(() => {\n\t\tif (imageRef) {\n\t\t\tImageThemeColors.extractThemeColorAsync(imageRef)\n\t\t\t\t.then((palette) => {\n\t\t\t\t\tif (!palette) return\n\n\t\t\t\t\tconst bgColor = theme.dark\n\t\t\t\t\t\t? (palette.darkMuted?.hex ?? palette.muted?.hex)\n\t\t\t\t\t\t: (palette.lightMuted?.hex ?? palette.muted?.hex)\n\n\t\t\t\t\tif (bgColor) {\n\t\t\t\t\t\tsetCardColor(bgColor)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.catch(() => undefined)\n\t\t}\n\t}, [imageRef, theme.dark])\n\n\tconst generatePreview = useCallback(async () => {\n\t\tlet retryCount = 0\n\t\twhile (!viewShotRef.current && retryCount < 5) {\n\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 200))\n\t\t\tretryCount++\n\t\t}\n\n\t\tif (!viewShotRef.current) {\n\t\t\tsetIsGenerating(false)\n\t\t\treturn\n\t\t}\n\n\t\t// 等待图片加载完成\n\t\tif (!imageRef && resolvedCoverUrl) {\n\t\t\t// 如果图片还没好，就继续等待，不设置 false\n\t\t\treturn\n\t\t}\n\t\t// 等待分 P 列表加载完成\n\t\tif (isPageListPending) {\n\t\t\treturn\n\t\t}\n\n\t\tsetIsGenerating(true)\n\t\ttry {\n\t\t\tconst fileName = `bbplayer-share-song-${Date.now()}`\n\t\t\tconst uri = await captureRef(viewShotRef, {\n\t\t\t\tformat: 'png',\n\t\t\t\tquality: 1,\n\t\t\t\tresult: 'tmpfile',\n\t\t\t\tfileName,\n\t\t\t})\n\t\t\tsetPreviewUri(uri)\n\t\t\tsetIsGenerating(false)\n\t\t} catch {\n\t\t\ttoast.error('生成预览失败')\n\t\t\tsetIsGenerating(false)\n\t\t}\n\t}, [imageRef, resolvedCoverUrl, isPageListPending])\n\n\t// 当 imageRef 准备好时，尝试生成预览\n\tuseEffect(() => {\n\t\tif (imageRef) {\n\t\t\t// 给一点时间让组件渲染\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tvoid generatePreview()\n\t\t\t}, 100)\n\t\t\treturn () => clearTimeout(timer)\n\t\t} else if (!resolvedCoverUrl) {\n\t\t\t// 没有封面，直接生成\n\t\t\tvoid generatePreview()\n\t\t}\n\t}, [imageRef, generatePreview, resolvedCoverUrl, isPageListPending, pageList])\n\n\tconst isSharingRef = useRef(false)\n\n\tconst handleShare = (action: 'save' | 'share') => {\n\t\tif (isSharingRef.current) return\n\t\tvoid performShare(\n\t\t\taction,\n\t\t\tpreviewUri,\n\t\t\tviewShotRef,\n\t\t\tcurrentTrack?.uniqueKey ?? '',\n\t\t\tpermissionResponse,\n\t\t\trequestPermission,\n\t\t\t() => close('SongShare'),\n\t\t\tsetIsSharing,\n\t\t\tisSharingRef,\n\t\t)\n\t}\n\n\tif (!currentTrack) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>分享歌曲</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.errorContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t当前没有正在播放的歌曲\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('SongShare')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (currentTrack.source !== 'bilibili') {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>分享歌曲</Dialog.Title>\n\t\t\t\t<Dialog.Content style={styles.errorContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t当前仅支持分享 Bilibili 来源的歌曲\n\t\t\t\t\t</Text>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button onPress={() => close('SongShare')}>关闭</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\t// 计算 shareUrl\n\tlet shareUrl = `https://bbplayer.roitium.com/share/track?id=${encodeURIComponent(currentTrack.uniqueKey)}&title=${encodeURIComponent(currentTrack.title)}&cover=${encodeURIComponent(currentTrack.coverUrl ?? '')}`\n\tif (cid && pageList) {\n\t\tconst page = pageList.find((p) => p.cid === cid)\n\t\tif (page) {\n\t\t\tshareUrl += `&p=${page.page}`\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>分享歌曲</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.contentArea}>\n\t\t\t\t{isGenerating ? (\n\t\t\t\t\t<View style={styles.loadingContainer}>\n\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={styles.loadingText}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t正在生成预览...\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t) : previewUri ? (\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsource={{ uri: previewUri }}\n\t\t\t\t\t\tstyle={styles.previewImage}\n\t\t\t\t\t\tcontentFit='contain'\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<View style={styles.loadingContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={styles.loadingText}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t预览加载失败\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tonPress={() => generatePreview()}\n\t\t\t\t\t\t\ticon='refresh'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t重试\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</View>\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tonPress={() => handleShare('save')}\n\t\t\t\t\tloading={isSharing}\n\t\t\t\t\tdisabled={isSharing || isGenerating}\n\t\t\t\t\ticon='download'\n\t\t\t\t>\n\t\t\t\t\t保存\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tmode='contained'\n\t\t\t\t\tonPress={() => handleShare('share')}\n\t\t\t\t\tloading={isSharing}\n\t\t\t\t\tdisabled={isSharing || isGenerating}\n\t\t\t\t\ticon='share-variant'\n\t\t\t\t>\n\t\t\t\t\t分享\n\t\t\t\t</Button>\n\t\t\t\t<Button onPress={() => close('SongShare')}>关闭</Button>\n\t\t\t</Dialog.Actions>\n\n\t\t\t{/* Hidden Capture View */}\n\t\t\t<View\n\t\t\t\tstyle={styles.hiddenCapture}\n\t\t\t\tpointerEvents='none'\n\t\t\t>\n\t\t\t\t<SongShareCard\n\t\t\t\t\ttitle={currentTrack.title}\n\t\t\t\t\tartistName={currentTrack.artist?.name ?? 'Unknown Artist'}\n\t\t\t\t\timageRef={imageRef}\n\t\t\t\t\tshareUrl={shareUrl}\n\t\t\t\t\tviewShotRef={viewShotRef}\n\t\t\t\t\tbackgroundColor={cardColor}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontentArea: {\n\t\tpaddingHorizontal: 16,\n\t\tminHeight: 280,\n\t},\n\tpreviewImage: {\n\t\twidth: '100%',\n\t\taspectRatio: 0.7,\n\t\tborderRadius: 12,\n\t},\n\tloadingContainer: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpaddingVertical: 80,\n\t},\n\tloadingText: {\n\t\tmarginTop: 16,\n\t\tmarginBottom: 16,\n\t\topacity: 0.7,\n\t},\n\terrorContainer: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpaddingVertical: 40,\n\t},\n\terrorText: {\n\t\topacity: 0.7,\n\t\ttextAlign: 'center',\n\t},\n\thiddenCapture: {\n\t\tposition: 'absolute',\n\t\ttop: 99999,\n\t\tleft: 0,\n\t\topacity: 0,\n\t},\n})\n\nexport default SongShareModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/BatchAddTracksToLocalPlaylist.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useMemo, useState } from 'react'\nimport { ActivityIndicator, StyleSheet, View } from 'react-native'\nimport { Dialog, RadioButton, Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useBatchAddTracksToLocalPlaylist } from '@/hooks/mutations/db/playlist'\nimport { usePlaylistLists } from '@/hooks/queries/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { Playlist } from '@/types/core/media'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport type { CreateArtistPayload } from '@/types/services/artist'\nimport type { CreateTrackPayload } from '@/types/services/track'\n\nconst renderPlaylistItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tPlaylist,\n\t{ selectedPlaylistId: number; setSelectedPlaylistId: (id: number) => void }\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst isChecked = extraData.selectedPlaylistId === item.id\n\tconst isDisabled = item.type !== 'local'\n\tconst setSelectedPlaylistId = extraData.setSelectedPlaylistId\n\n\treturn (\n\t\t<RadioButton.Item\n\t\t\tlabel={item.title}\n\t\t\tvalue={String(item.id)}\n\t\t\tstatus={isChecked ? 'checked' : 'unchecked'}\n\t\t\tonPress={() => !isDisabled && setSelectedPlaylistId(item.id)}\n\t\t\tdisabled={isDisabled}\n\t\t/>\n\t)\n}\n\nconst BatchAddTracksToLocalPlaylistModal = memo(\n\tfunction AddTracksToLocalPlaylistModal({\n\t\tpayloads,\n\t}: {\n\t\tpayloads: { track: CreateTrackPayload; artist: CreateArtistPayload }[]\n\t}) {\n\t\tconst { colors } = useTheme()\n\t\tconst _close = useModalStore((state) => state.close)\n\t\tconst close = useCallback(\n\t\t\t() => _close('BatchAddTracksToLocalPlaylist'),\n\t\t\t[_close],\n\t\t)\n\t\tconst openModal = useModalStore((state) => state.open)\n\n\t\tconst {\n\t\t\tdata: allPlaylists,\n\t\t\tisPending: isPlaylistsPending,\n\t\t\tisError: isPlaylistsError,\n\t\t\trefetch: refetchPlaylists,\n\t\t} = usePlaylistLists()\n\t\tconst filteredPlaylists = useMemo(\n\t\t\t() =>\n\t\t\t\tallPlaylists?.filter(\n\t\t\t\t\t(p) => p.type === 'local' && p.shareRole !== 'subscriber',\n\t\t\t\t),\n\t\t\t[allPlaylists],\n\t\t)\n\n\t\tconst { mutate: batchAdd, isPending: isMutating } =\n\t\t\tuseBatchAddTracksToLocalPlaylist()\n\n\t\tconst [selectedPlaylistId, setSelectedPlaylistId] = useState<number | null>(\n\t\t\tnull,\n\t\t)\n\n\t\tconst isLoading = isPlaylistsPending\n\t\tconst isError = isPlaylistsError\n\n\t\tconst handleDismiss = useCallback(() => {\n\t\t\tif (isMutating) return\n\t\t\tclose()\n\t\t}, [close, isMutating])\n\n\t\tconst handleRetry = useCallback(() => {\n\t\t\tif (isPlaylistsError) void refetchPlaylists()\n\t\t}, [isPlaylistsError, refetchPlaylists])\n\n\t\tconst handleConfirm = useCallback(() => {\n\t\t\tif (isMutating || selectedPlaylistId == null) return\n\n\t\t\tbatchAdd(\n\t\t\t\t{\n\t\t\t\t\tplaylistId: selectedPlaylistId,\n\t\t\t\t\tpayloads,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonSettled: () => close(),\n\t\t\t\t},\n\t\t\t)\n\t\t}, [batchAdd, close, isMutating, payloads, selectedPlaylistId])\n\n\t\tconst keyExtractor = useCallback((item: Playlist) => item.id.toString(), [])\n\n\t\tconst extraData = useMemo(\n\t\t\t() => ({\n\t\t\t\tselectedPlaylistId,\n\t\t\t\tsetSelectedPlaylistId,\n\t\t\t}),\n\t\t\t[selectedPlaylistId, setSelectedPlaylistId],\n\t\t)\n\n\t\tconst renderContent = () => {\n\t\t\tif (isLoading) {\n\t\t\t\treturn (\n\t\t\t\t\t<Dialog.Content style={styles.loadingContainer}>\n\t\t\t\t\t\t<ActivityIndicator size={'large'} />\n\t\t\t\t\t</Dialog.Content>\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tif (isError) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<Text style={[styles.errorText, { color: colors.error }]}>\n\t\t\t\t\t\t\t\t加载歌单列表失败\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t<Button onPress={handleDismiss}>关闭</Button>\n\t\t\t\t\t\t\t<Button onPress={handleRetry}>重试</Button>\n\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Dialog.ScrollArea style={styles.listContainer}>\n\t\t\t\t\t\t<FlashList\n\t\t\t\t\t\t\tdata={filteredPlaylists ?? []}\n\t\t\t\t\t\t\trenderItem={renderPlaylistItem}\n\t\t\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\t\t\textraData={extraData}\n\t\t\t\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t\t\t<View style={styles.emptyListContainer}>\n\t\t\t\t\t\t\t\t\t<Text>你还没有创建任何歌单</Text>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Dialog.ScrollArea>\n\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t*{'\\u2009'}与远程同步或订阅的共享歌单不会显示\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t<Dialog.Actions style={styles.actionsContainer}>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\topenModal('CreatePlaylist', { redirectToNewPlaylist: false })\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t创建歌单\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<View style={styles.rightActionsContainer}>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tonPress={handleDismiss}\n\t\t\t\t\t\t\t\tdisabled={isMutating}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t取消\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\t\t\t\tloading={isMutating}\n\t\t\t\t\t\t\t\tdisabled={isMutating || selectedPlaylistId == null}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t确认\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>添加到歌单</Dialog.Title>\n\t\t\t\t{renderContent()}\n\t\t\t</>\n\t\t)\n\t},\n)\n\nconst styles = StyleSheet.create({\n\tloadingContainer: {\n\t\talignItems: 'center',\n\t\tpaddingVertical: 20,\n\t},\n\terrorText: {\n\t\ttextAlign: 'center',\n\t},\n\tlistContainer: {\n\t\tminHeight: 300,\n\t},\n\temptyListContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tactionsContainer: {\n\t\tjustifyContent: 'space-between',\n\t},\n\trightActionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n})\n\nBatchAddTracksToLocalPlaylistModal.displayName = 'AddTracksToLocalPlaylistModal'\n\nexport default BatchAddTracksToLocalPlaylistModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/CreatePlaylistModal.tsx",
    "content": "import * as DocumentPicker from 'expo-document-picker'\nimport * as FileSystem from 'expo-file-system'\nimport { useRouter } from 'expo-router'\nimport { useCallback, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport IconButton from '@/components/common/IconButton'\nimport { useCreateNewLocalPlaylist } from '@/hooks/mutations/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport toast from '@/utils/toast'\n\nexport default function CreatePlaylistModal({\n\tredirectToNewPlaylist,\n}: {\n\tredirectToNewPlaylist?: boolean\n}) {\n\tconst { mutate: createNewPlaylist } = useCreateNewLocalPlaylist()\n\tconst [title, setTitle] = useState('')\n\tconst [description, setDescription] = useState('')\n\tconst [coverUrl, setCoverUrl] = useState('')\n\tconst _close = useModalStore((state) => state.close)\n\tconst closeAll = useModalStore((state) => state.closeAll)\n\tconst close = useCallback(() => _close('CreatePlaylist'), [_close])\n\tconst router = useRouter()\n\n\tconst handleConfirm = useCallback(() => {\n\t\tif (title.trim().length === 0) {\n\t\t\ttoast.error('标题不能为空')\n\t\t\treturn\n\t\t}\n\t\tcreateNewPlaylist(\n\t\t\t{\n\t\t\t\ttitle,\n\t\t\t\tdescription,\n\t\t\t\tcoverUrl,\n\t\t\t},\n\t\t\t{\n\t\t\t\tonSuccess: (playlist) => {\n\t\t\t\t\tif (redirectToNewPlaylist) {\n\t\t\t\t\t\tcloseAll()\n\t\t\t\t\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\tpathname: '/playlist/local/[id]',\n\t\t\t\t\t\t\t\tparams: { id: String(playlist.id) },\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t})\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcloseAll()\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}, [\n\t\tcloseAll,\n\t\tcoverUrl,\n\t\tcreateNewPlaylist,\n\t\tdescription,\n\t\trouter,\n\t\tredirectToNewPlaylist,\n\t\ttitle,\n\t])\n\n\tconst handleImagePicker = useCallback(async () => {\n\t\tconst result = await DocumentPicker.getDocumentAsync({\n\t\t\ttype: 'image/*',\n\t\t\tcopyToCacheDirectory: true,\n\t\t\tmultiple: false,\n\t\t})\n\t\tif (result.canceled || result.assets.length === 0) return\n\t\tconst assetFile = new FileSystem.File(result.assets[0].uri)\n\t\tconst coverDir = new FileSystem.Directory(\n\t\t\tFileSystem.Paths.document,\n\t\t\t'covers',\n\t\t)\n\t\tif (!coverDir.exists) {\n\t\t\tcoverDir.create({ intermediates: true, idempotent: true })\n\t\t}\n\t\tconst coverFile = new FileSystem.File(coverDir, assetFile.name)\n\t\tif (coverFile.exists) {\n\t\t\tcoverFile.delete()\n\t\t}\n\t\tassetFile.copy(coverFile)\n\t\tsetCoverUrl(coverFile.uri)\n\t}, [])\n\n\tconst handleDismiss = useCallback(() => {\n\t\tclose()\n\t\tsetTitle('')\n\t\tsetDescription('')\n\t\tsetCoverUrl('')\n\t}, [close])\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>创建播放列表</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='标题'\n\t\t\t\t\tvalue={title}\n\t\t\t\t\tonChangeText={setTitle}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\ttestID='create-playlist-title-input'\n\t\t\t\t/>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='描述'\n\t\t\t\t\tonChangeText={setDescription}\n\t\t\t\t\tvalue={description ?? undefined}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tmultiline\n\t\t\t\t\tstyle={styles.descriptionInput}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t/>\n\t\t\t\t<View style={styles.coverUrlContainer}>\n\t\t\t\t\t<TextInput\n\t\t\t\t\t\tlabel='封面'\n\t\t\t\t\t\tonChangeText={setCoverUrl}\n\t\t\t\t\t\tvalue={coverUrl ?? undefined}\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t\t\tstyle={styles.coverUrlInput}\n\t\t\t\t\t/>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='image-plus'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tstyle={styles.imagePickerButton}\n\t\t\t\t\t\tonPress={handleImagePicker}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={handleDismiss}>取消</Button>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\ttestID='create-playlist-confirm-button'\n\t\t\t\t>\n\t\t\t\t\t确定\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tgap: 5,\n\t},\n\tdescriptionInput: {\n\t\tmaxHeight: 150,\n\t},\n\tcoverUrlContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tcoverUrlInput: {\n\t\tflex: 1,\n\t},\n\timagePickerButton: {\n\t\tmarginTop: 13,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/DuplicateLocalPlaylistModal.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { useCallback, useState } from 'react'\nimport { StyleSheet } from 'react-native'\nimport { Dialog, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useDuplicatePlaylist } from '@/hooks/mutations/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nexport default function DuplicateLocalPlaylistModal({\n\tsourcePlaylistId,\n\trawName,\n}: {\n\tsourcePlaylistId: number\n\trawName: string\n}) {\n\tconst [duplicatePlaylistName, setDuplicatePlaylistName] = useState(\n\t\t`${rawName}-副本`,\n\t)\n\tconst { mutate: duplicatePlaylist } = useDuplicatePlaylist()\n\tconst close = useModalStore((state) => state.close)\n\tconst closeAll = useModalStore((state) => state.closeAll)\n\tconst router = useRouter()\n\n\tconst handleDuplicatePlaylist = useCallback(() => {\n\t\tif (!duplicatePlaylistName) return\n\t\tduplicatePlaylist(\n\t\t\t{\n\t\t\t\tplaylistId: Number(sourcePlaylistId),\n\t\t\t\tname: duplicatePlaylistName,\n\t\t\t},\n\t\t\t{\n\t\t\t\tonSuccess: (id) => {\n\t\t\t\t\tcloseAll()\n\t\t\t\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\tpathname: '/playlist/local/[id]',\n\t\t\t\t\t\t\tparams: { id: String(id) },\n\t\t\t\t\t\t})\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}, [\n\t\tduplicatePlaylistName,\n\t\tduplicatePlaylist,\n\t\tsourcePlaylistId,\n\t\tcloseAll,\n\t\trouter,\n\t])\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>复制播放列表</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='新播放列表名称'\n\t\t\t\t\tvalue={duplicatePlaylistName}\n\t\t\t\t\tonChangeText={setDuplicatePlaylistName}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\tstyle={styles.textInput}\n\t\t\t\t\ttextAlignVertical='top'\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => close('DuplicateLocalPlaylist')}>取消</Button>\n\t\t\t\t<Button onPress={handleDuplicatePlaylist}>确定</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\ttextInput: {\n\t\tmaxHeight: 200,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/EnableSharingModal.tsx",
    "content": "import Icon from '@react-native-vector-icons/material-design-icons'\nimport * as Clipboard from 'expo-clipboard'\nimport { useEffect, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport {\n\tuseEnableSharing,\n\tuseRotateEditorInviteCode,\n} from '@/hooks/mutations/db/playlist'\nimport { useEditorInviteCode } from '@/hooks/queries/db/playlist'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport toast from '@/utils/toast'\n\nconst SHARE_BASE_URL = 'https://bbplayer.roitium.com/share/playlist'\n\nexport default function EnableSharingModal({\n\tplaylistId,\n\tshareId: initialShareId,\n\tshareRole,\n}: {\n\tplaylistId: number\n\tshareId?: string | null\n\tshareRole?: 'owner' | 'editor' | 'subscriber' | null\n}) {\n\tconst close = useModalStore((state) => state.close)\n\tconst { mutate: enableSharing, isPending } = useEnableSharing()\n\tconst { mutateAsync: rotateInvite, isPending: isRotating } =\n\t\tuseRotateEditorInviteCode()\n\tconst [shareId, setShareId] = useState<string | null>(initialShareId ?? null)\n\tconst [inviteCode, setInviteCode] = useState<string | null>(null)\n\tconst hasToken = useAppStore((state) => !!state.bbplayerToken)\n\n\tconst { data: fetchedInviteCode, isFetching: inviteFetching } =\n\t\tuseEditorInviteCode(shareId)\n\n\tconst subscribeUrl = shareId\n\t\t? `${SHARE_BASE_URL}?shareId=${encodeURIComponent(shareId)}`\n\t\t: ''\n\tconst editorUrl = shareId\n\t\t? `${subscribeUrl}${inviteCode ? `&inviteCode=${encodeURIComponent(inviteCode)}` : ''}`\n\t\t: ''\n\n\tuseEffect(() => {\n\t\tif (fetchedInviteCode) setInviteCode(fetchedInviteCode)\n\t}, [fetchedInviteCode])\n\n\tconst handleConfirm = () => {\n\t\tenableSharing(\n\t\t\t{ playlistId },\n\t\t\t{ onSuccess: ({ shareId: id }) => setShareId(id) },\n\t\t)\n\t}\n\n\tconst handleCopySubscribe = async () => {\n\t\tif (!subscribeUrl) return\n\t\tawait Clipboard.setStringAsync(subscribeUrl)\n\t\ttoast.success('已复制订阅链接')\n\t}\n\n\tconst handleCopyEditorLink = async () => {\n\t\tif (!editorUrl || !inviteCode) return\n\t\tawait Clipboard.setStringAsync(editorUrl)\n\t\ttoast.success('已复制协作编辑链接')\n\t}\n\n\tconst handleRotateInvite = async () => {\n\t\tif (!shareId) return\n\t\tconst result = await rotateInvite({ shareId })\n\t\tsetInviteCode(result.editorInviteCode)\n\t\ttoast.success('已生成新的编辑者邀请码')\n\t}\n\n\tconst handleCopyInvite = async () => {\n\t\tif (!inviteCode) return\n\t\tawait Clipboard.setStringAsync(inviteCode)\n\t\ttoast.success('已复制邀请码')\n\t}\n\n\t// ---- 成功状态：显示可复制的链接 ----\n\tif (shareId) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>共享已开启 🎉</Dialog.Title>\n\t\t\t\t<Dialog.Content>\n\t\t\t\t\t<View style={styles.body}>\n\t\t\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t\t\t把下方链接发给朋友，对方即可订阅此歌单。\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.linkSection}>\n\t\t\t\t\t\t\t<Text variant='bodySmall'>订阅链接（只读）</Text>\n\t\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\t\tvalue={subscribeUrl}\n\t\t\t\t\t\t\t\teditable={false}\n\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\tdense\n\t\t\t\t\t\t\t\tstyle={styles.linkInput}\n\t\t\t\t\t\t\t\tright={\n\t\t\t\t\t\t\t\t\t<TextInput.Icon\n\t\t\t\t\t\t\t\t\t\ticon='content-copy'\n\t\t\t\t\t\t\t\t\t\tonPress={handleCopySubscribe}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t{(!shareRole || shareRole === 'owner') && (\n\t\t\t\t\t\t\t<View style={styles.inviteSection}>\n\t\t\t\t\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t\t\t\t\t需要协作者编辑此歌单？使用下面的邀请链接。\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t{inviteCode && (\n\t\t\t\t\t\t\t\t\t<View style={styles.linkSection}>\n\t\t\t\t\t\t\t\t\t\t<Text variant='bodySmall'>协作编辑邀请链接</Text>\n\t\t\t\t\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\t\t\t\t\tvalue={editorUrl}\n\t\t\t\t\t\t\t\t\t\t\teditable={false}\n\t\t\t\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\t\t\t\tdense\n\t\t\t\t\t\t\t\t\t\t\tstyle={styles.linkInput}\n\t\t\t\t\t\t\t\t\t\t\tright={\n\t\t\t\t\t\t\t\t\t\t\t\t<TextInput.Icon\n\t\t\t\t\t\t\t\t\t\t\t\t\ticon='content-copy'\n\t\t\t\t\t\t\t\t\t\t\t\t\tonPress={handleCopyEditorLink}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{!inviteCode && inviteFetching && (\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\tstyle={{ textAlign: 'center' }}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t邀请码加载中...\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tonPress={handleRotateInvite}\n\t\t\t\t\t\t\t\t\tloading={isRotating}\n\t\t\t\t\t\t\t\t\tdisabled={isRotating || inviteFetching}\n\t\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{inviteCode ? '重置协作编辑邀请链接' : '生成协作编辑邀请链接'}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</View>\n\t\t\t\t</Dialog.Content>\n\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tonPress={() => close('EnableSharing')}\n\t\t\t\t\t\tmode='text'\n\t\t\t\t\t>\n\t\t\t\t\t\t完成\n\t\t\t\t\t</Button>\n\t\t\t\t</Dialog.Actions>\n\t\t\t</>\n\t\t)\n\t}\n\n\t// ---- 确认状态 ----\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>开启歌单共享</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<View style={styles.body}>\n\t\t\t\t\t{!hasToken && (\n\t\t\t\t\t\t<View style={styles.warningBox}>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tname='alert-circle-outline'\n\t\t\t\t\t\t\t\tsize={16}\n\t\t\t\t\t\t\t\tstyle={styles.warningIcon}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\tstyle={styles.warningText}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t开启共享需要验证身份。点击确认后，你的 Bilibili Cookie\n\t\t\t\t\t\t\t\t将被上传至服务器以确认你是真实用户。BBPlayer\n\t\t\t\t\t\t\t\t完全开源，你可以随时审计相关代码。\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t)}\n\t\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t\t{inviteCode && (\n\t\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\t\tvalue={inviteCode}\n\t\t\t\t\t\t\t\teditable={false}\n\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\tdense\n\t\t\t\t\t\t\t\tstyle={styles.linkInput}\n\t\t\t\t\t\t\t\tright={\n\t\t\t\t\t\t\t\t\t<TextInput.Icon\n\t\t\t\t\t\t\t\t\t\ticon='content-copy'\n\t\t\t\t\t\t\t\t\t\tonPress={handleCopyInvite}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t共享后，其他用户可通过链接订阅此歌单。\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tstyle={styles.irreversible}\n\t\t\t\t\t>\n\t\t\t\t\t\t⚠️ 目前版本共享后无法撤销共享，请谨慎操作。\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={() => close('EnableSharing')}\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\tmode='text'\n\t\t\t\t>\n\t\t\t\t\t取消\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\tloading={isPending}\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\tmode='text'\n\t\t\t\t>\n\t\t\t\t\t开启共享\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tbody: {\n\t\tgap: 12,\n\t},\n\tlinkRow: {\n\t\tmarginTop: 4,\n\t},\n\tlinkSection: {\n\t\tmarginTop: 4,\n\t\tgap: 4,\n\t},\n\tlinkInput: {\n\t\tfontSize: 12,\n\t},\n\tinviteSection: {\n\t\tmarginTop: 8,\n\t\tgap: 8,\n\t},\n\twarningBox: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'flex-start',\n\t\tgap: 6,\n\t\tborderRadius: 8,\n\t\tbackgroundColor: 'rgba(255, 180, 0, 0.12)',\n\t\tpadding: 10,\n\t},\n\twarningIcon: {\n\t\tmarginTop: 1,\n\t\tcolor: '#c58c00',\n\t},\n\twarningText: {\n\t\tflex: 1,\n\t\tcolor: '#c58c00',\n\t\tlineHeight: 18,\n\t},\n\tirreversible: {\n\t\topacity: 0.6,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/FavoriteSyncProgressModal.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { memo, useCallback, useEffect, useRef, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, ProgressBar, Text } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { usePlaylistSync } from '@/hooks/mutations/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { FavoriteSyncProgress } from '@/lib/facades/syncBilibiliPlaylist'\n\nconst FavoriteSyncProgressModal = memo(function FavoriteSyncProgressModal({\n\tfavoriteId,\n\tshouldRedirectToLocalPlaylist,\n}: {\n\tfavoriteId: number\n\tshouldRedirectToLocalPlaylist?: boolean\n}) {\n\tconst _close = useModalStore((state) => state.close)\n\tconst router = useRouter()\n\tconst syncedPlaylistId = useRef<number | undefined>(undefined)\n\n\tconst close = useCallback(() => {\n\t\t_close('FavoriteSyncProgress')\n\t\tif (shouldRedirectToLocalPlaylist && syncedPlaylistId.current) {\n\t\t\tconst targetId = syncedPlaylistId.current\n\t\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\t\trouter.push(`/playlist/local/${targetId}`)\n\t\t\t})\n\t\t}\n\t}, [_close, shouldRedirectToLocalPlaylist, router])\n\n\tconst [progress, setProgress] = useState<FavoriteSyncProgress | null>(null)\n\tconst { mutate: syncFavorite, isPending } = usePlaylistSync()\n\tconst hasSyncStarted = useRef(false)\n\n\t// Auto-start sync on mount\n\tuseEffect(() => {\n\t\tif (hasSyncStarted.current) return\n\t\thasSyncStarted.current = true\n\n\t\tsyncFavorite(\n\t\t\t{\n\t\t\t\tremoteSyncId: favoriteId,\n\t\t\t\ttype: 'favorite',\n\t\t\t\tonProgress: setProgress,\n\t\t\t},\n\t\t\t{\n\t\t\t\tonSuccess: (id) => {\n\t\t\t\t\tsyncedPlaylistId.current = id\n\t\t\t\t\tsetProgress((prev) =>\n\t\t\t\t\t\tprev ? { ...prev, stage: 'completed', message: '同步完成' } : null,\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\tonError: (error) => {\n\t\t\t\t\tsetProgress((prev) =>\n\t\t\t\t\t\tprev\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t...prev,\n\t\t\t\t\t\t\t\t\tstage: 'error',\n\t\t\t\t\t\t\t\t\tmessage: `同步失败: ${error.message}`,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: null,\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}, [favoriteId, syncFavorite])\n\n\tlet localProgress: number | undefined\n\tif (\n\t\tprogress?.current !== undefined &&\n\t\tprogress?.total !== undefined &&\n\t\tprogress.total > 0\n\t) {\n\t\tlocalProgress = progress.current / progress.total\n\t} else if (progress?.stage === 'completed') {\n\t\tlocalProgress = 1\n\t} else {\n\t\tlocalProgress = undefined\n\t}\n\n\tconst isFinished =\n\t\tprogress?.stage === 'completed' || progress?.stage === 'error'\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>\n\t\t\t\t{progress?.stage === 'completed'\n\t\t\t\t\t? '同步完成'\n\t\t\t\t\t: progress?.stage === 'error'\n\t\t\t\t\t\t? '同步失败'\n\t\t\t\t\t\t: '正在同步收藏夹'}\n\t\t\t</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<View style={styles.content}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.message}\n\t\t\t\t\t>\n\t\t\t\t\t\t{progress?.message ?? '准备中...'}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<ProgressBar\n\t\t\t\t\t\tprogress={localProgress}\n\t\t\t\t\t\tindeterminate={localProgress === undefined}\n\t\t\t\t\t\tstyle={styles.progressBar}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={close}\n\t\t\t\t\tdisabled={!isFinished && isPending}\n\t\t\t\t>\n\t\t\t\t\t{isFinished ? '关闭' : '请稍候'}\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n})\n\nFavoriteSyncProgressModal.displayName = 'FavoriteSyncProgressModal'\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tgap: 15,\n\t\tpaddingVertical: 10,\n\t},\n\tmessage: {\n\t\ttextAlign: 'center',\n\t},\n\tprogressBar: {\n\t\theight: 8,\n\t\tborderRadius: 4,\n\t},\n})\n\nexport default FavoriteSyncProgressModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/InputExternalPlaylistInfo.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, SegmentedButtons, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { parseExternalPlaylistInfo } from '@/lib/utils/playlistUrlParser'\n\nconst InputExternalPlaylistInfoModal = () => {\n\tconst [input, setInput] = useState('')\n\tconst [source, setSource] = useState<'netease' | 'qq'>('netease')\n\tconst router = useRouter()\n\tconst close = useModalStore((state) => state.close)\n\n\tconst handleConfirm = () => {\n\t\tif (!input.trim()) return\n\t\tconst parsed = parseExternalPlaylistInfo(input)\n\t\tconst finalId = parsed?.id ?? input.trim()\n\t\tconst finalSource = parsed?.source ?? source\n\n\t\tclose('InputExternalPlaylistInfo')\n\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/external-sync',\n\t\t\t\tparams: { id: finalId, source: finalSource },\n\t\t\t})\n\t\t})\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>输入外部歌单信息</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='歌单 ID / 链接'\n\t\t\t\t\tvalue={input}\n\t\t\t\t\tonChangeText={(text) => {\n\t\t\t\t\t\tsetInput(text)\n\t\t\t\t\t\tconst result = parseExternalPlaylistInfo(text)\n\t\t\t\t\t\tif (result) {\n\t\t\t\t\t\t\tsetSource(result.source)\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tstyle={styles.input}\n\t\t\t\t/>\n\t\t\t\t<View style={styles.segmentedContainer}>\n\t\t\t\t\t<Text style={styles.label}>来源：</Text>\n\t\t\t\t\t<SegmentedButtons\n\t\t\t\t\t\tvalue={source}\n\t\t\t\t\t\tonValueChange={(value) => setSource(value)}\n\t\t\t\t\t\tbuttons={[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvalue: 'netease',\n\t\t\t\t\t\t\t\tlabel: '网易云音乐',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tvalue: 'qq',\n\t\t\t\t\t\t\t\tlabel: 'QQ音乐',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tstyle={styles.segmentedButtons}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => close('InputExternalPlaylistInfo')}>取消</Button>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\tdisabled={!input.trim()}\n\t\t\t\t>\n\t\t\t\t\t确定\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tinput: {\n\t\tmarginBottom: 16,\n\t},\n\tsegmentedContainer: {\n\t\tmarginTop: 8,\n\t},\n\tlabel: {\n\t\tmarginBottom: 8,\n\t},\n\tsegmentedButtons: {\n\t\tmarginTop: 4,\n\t},\n})\n\nexport default InputExternalPlaylistInfoModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { decode } from 'he'\nimport { memo, useCallback, useMemo, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tDialog,\n\tSearchbar,\n\tText,\n\tTouchableRipple,\n} from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport { useSearchResults } from '@/hooks/queries/bilibili/search'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { MatchResult } from '@/lib/services/externalPlaylistService'\nimport type { BilibiliSearchVideo } from '@/types/apis/bilibili'\nimport type { GenericTrack } from '@/types/external_playlist'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nconst renderItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tBilibiliSearchVideo,\n\t{\n\t\thandlePressItem: (item: BilibiliSearchVideo) => void\n\t}\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\treturn (\n\t\t<SearchItem\n\t\t\titem={item}\n\t\t\tonPress={extraData.handlePressItem}\n\t\t/>\n\t)\n}\n\nconst SearchItem = memo(function SearchItem({\n\titem,\n\tonPress,\n}: {\n\titem: BilibiliSearchVideo\n\tonPress: (item: BilibiliSearchVideo) => void\n}) {\n\tconst coverUrl = item.pic.startsWith('//') ? `https:${item.pic}` : item.pic\n\treturn (\n\t\t<TouchableRipple\n\t\t\tstyle={styles.searchItem}\n\t\t\tonPress={() => onPress(item)}\n\t\t>\n\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\tid={item.bvid}\n\t\t\t\t\tcover={coverUrl}\n\t\t\t\t\tsize={40}\n\t\t\t\t\ttitle={item.title}\n\t\t\t\t/>\n\t\t\t\t<View style={styles.searchItemContent}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.title\n\t\t\t\t\t\t\t.replace(/<em class=\"keyword\">/g, '')\n\t\t\t\t\t\t\t.replace(/<\\/em>/g, '')}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.author} -{' '}\n\t\t\t\t\t\t{formatDurationToHHMMSS(\n\t\t\t\t\t\t\tMath.round(\n\t\t\t\t\t\t\t\tparseInt(item.duration.split(':')[0]) * 60 +\n\t\t\t\t\t\t\t\t\tparseInt(item.duration.split(':')[1]),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</TouchableRipple>\n\t)\n})\n\nexport default function ManualMatchExternalSync({\n\ttrack,\n\tinitialQuery,\n\tonMatch,\n}: {\n\ttrack: GenericTrack\n\tinitialQuery: string\n\tonMatch: (result: MatchResult) => void\n}) {\n\tconst [query, setQuery] = useState(initialQuery)\n\tconst [finalQuery, setFinalQuery] = useState(initialQuery)\n\tconst close = useModalStore((state) => state.close)\n\n\tconst { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =\n\t\tuseSearchResults(finalQuery)\n\n\tconst allVideos = useMemo(() => {\n\t\tif (!data?.pages) {\n\t\t\treturn []\n\t\t}\n\n\t\tconst allTracks = data.pages.flatMap((page) => page.result)\n\t\tconst uniqueMap = new Map(\n\t\t\tallTracks.map((track) => [\n\t\t\t\ttrack.bvid,\n\t\t\t\t{\n\t\t\t\t\t...track,\n\t\t\t\t\ttitle: decode(track.title),\n\t\t\t\t},\n\t\t\t]),\n\t\t)\n\t\treturn [...uniqueMap.values()]\n\t}, [data])\n\n\tconst handlePressItem = useCallback(\n\t\t(video: BilibiliSearchVideo) => {\n\t\t\tonMatch({\n\t\t\t\ttrack,\n\t\t\t\tmatchedVideo: video,\n\t\t\t})\n\t\t\tclose('ManualMatchExternalSync')\n\t\t},\n\t\t[close, onMatch, track],\n\t)\n\n\tconst extraData = useMemo(() => ({ handlePressItem }), [handlePressItem])\n\n\tconst keyExtractor = useCallback((item: BilibiliSearchVideo) => item.bvid, [])\n\n\tconst renderContent = () => {\n\t\tif (isLoading) {\n\t\t\treturn (\n\t\t\t\t<View style={styles.centerContainer}>\n\t\t\t\t\t<ActivityIndicator size={'large'} />\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\t\tif (allVideos.length > 0) {\n\t\t\treturn (\n\t\t\t\t<FlashList\n\t\t\t\t\tdata={allVideos}\n\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\textraData={extraData}\n\t\t\t\t\tonEndReached={() => {\n\t\t\t\t\t\tif (hasNextPage && !isFetchingNextPage) {\n\t\t\t\t\t\t\tvoid fetchNextPage()\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tonEndReachedThreshold={0.5}\n\t\t\t\t\tListFooterComponent={\n\t\t\t\t\t\tisFetchingNextPage ? (\n\t\t\t\t\t\t\t<View style={{ padding: 16 }}>\n\t\t\t\t\t\t\t\t<ActivityIndicator />\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : null\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t)\n\t\t}\n\t\treturn (\n\t\t\t<View style={styles.centerContainer}>\n\t\t\t\t<Text style={styles.centerText}>没有找到匹配的视频</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>手动匹配视频</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<Searchbar\n\t\t\t\t\tvalue={query}\n\t\t\t\t\tonChangeText={setQuery}\n\t\t\t\t\tplaceholder='输入关键词搜索'\n\t\t\t\t\tonSubmitEditing={() => setFinalQuery(query)}\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.ScrollArea style={styles.scrollArea}>\n\t\t\t\t{renderContent()}\n\t\t\t</Dialog.ScrollArea>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button onPress={() => close('ManualMatchExternalSync')}>取消</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tsearchItem: {\n\t\tpaddingVertical: 8,\n\t\tpaddingHorizontal: 16,\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tsearchItemContent: {\n\t\tflexDirection: 'column',\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t},\n\tcenterContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tcenterText: {\n\t\ttextAlign: 'center',\n\t},\n\tscrollArea: {\n\t\theight: 300,\n\t\tpaddingHorizontal: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/MergePlaylistsModal.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useMemo, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tCheckbox,\n\tDialog,\n\tText,\n\tTextInput,\n\tTouchableRipple,\n} from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useMergePlaylists } from '@/hooks/mutations/db/playlist'\nimport { usePlaylistLists } from '@/hooks/queries/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { Playlist } from '@/types/core/media'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\n\nconst SelectablePlaylistItem = memo(function SelectablePlaylistItem({\n\titem,\n\tisSelected,\n\tonToggle,\n}: {\n\titem: Playlist\n\tisSelected: boolean\n\tonToggle: (id: number) => void\n}) {\n\treturn (\n\t\t<TouchableRipple onPress={() => onToggle(item.id)}>\n\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t<View style={{ flex: 1 }}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyLarge'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.title}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tstyle={{ opacity: 0.7 }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.itemCount} 首歌曲\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t\t<Checkbox\n\t\t\t\t\tstatus={isSelected ? 'checked' : 'unchecked'}\n\t\t\t\t\tonPress={() => onToggle(item.id)}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</TouchableRipple>\n\t)\n})\n\ntype RenderExtraData = {\n\tselectedIds: Set<number>\n\tonToggle: (id: number) => void\n}\n\nconst renderPlaylistItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<Playlist, RenderExtraData>) => {\n\tif (!extraData) return null\n\treturn (\n\t\t<SelectablePlaylistItem\n\t\t\titem={item}\n\t\t\tisSelected={extraData.selectedIds.has(item.id)}\n\t\t\tonToggle={extraData.onToggle}\n\t\t/>\n\t)\n}\n\nexport default function MergePlaylistsModal() {\n\tconst close = useModalStore((state) => state.close)\n\tconst [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())\n\tconst [newTitle, setNewTitle] = useState('')\n\n\tconst { data: playlists, isPending, isError } = usePlaylistLists()\n\tconst { mutateAsync: mergePlaylists, isPending: isMerging } =\n\t\tuseMergePlaylists()\n\tconst availablePlaylists = useMemo(\n\t\t() => playlists?.filter((playlist) => playlist.type !== 'dynamic') ?? [],\n\t\t[playlists],\n\t)\n\n\tconst toggleSelection = useCallback((id: number) => {\n\t\tsetSelectedIds((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(id)) {\n\t\t\t\tnext.delete(id)\n\t\t\t} else {\n\t\t\t\tnext.add(id)\n\t\t\t}\n\t\t\treturn next\n\t\t})\n\t}, [])\n\n\tconst handleConfirm = async () => {\n\t\tif (selectedIds.size < 2) return\n\t\tif (!newTitle.trim()) return\n\n\t\ttry {\n\t\t\tawait mergePlaylists({\n\t\t\t\tsourcePlaylistIds: Array.from(selectedIds),\n\t\t\t\ttitle: newTitle.trim(),\n\t\t\t})\n\t\t\tclose('MergePlaylists')\n\t\t} catch {\n\t\t\t// error handled in mutation\n\t\t}\n\t}\n\n\tconst extraData = useMemo(\n\t\t() => ({ selectedIds, onToggle: toggleSelection }),\n\t\t[selectedIds, toggleSelection],\n\t)\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>动态合并歌单</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t{isPending ? (\n\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t</View>\n\t\t\t\t) : isError ? (\n\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t<Text style={{ opacity: 0.7 }}>加载本地歌单失败</Text>\n\t\t\t\t\t</View>\n\t\t\t\t) : availablePlaylists.length === 0 ? (\n\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t<Text style={{ opacity: 0.7 }}>没有本地歌单</Text>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<View style={{ flex: 1 }}>\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tlabel='新歌单名称'\n\t\t\t\t\t\t\tvalue={newTitle}\n\t\t\t\t\t\t\tonChangeText={setNewTitle}\n\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\tstyle={styles.input}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\t\tstyle={styles.subtitle}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t选择至少两个源歌单（显示时动态合并并自动去重）：\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.listContainer}>\n\t\t\t\t\t\t\t<FlashList\n\t\t\t\t\t\t\t\tdata={availablePlaylists}\n\t\t\t\t\t\t\t\trenderItem={renderPlaylistItem}\n\t\t\t\t\t\t\t\textraData={extraData}\n\t\t\t\t\t\t\t\tkeyExtractor={(item) => item.id.toString()}\n\t\t\t\t\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={() => close('MergePlaylists')}\n\t\t\t\t\tdisabled={isMerging}\n\t\t\t\t>\n\t\t\t\t\t取消\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tmode='contained'\n\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\tdisabled={isMerging || selectedIds.size < 2 || newTitle.trim() === ''}\n\t\t\t\t\tloading={isMerging}\n\t\t\t\t>\n\t\t\t\t\t创建\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\theight: 400,\n\t\tpaddingHorizontal: 0,\n\t},\n\tcenter: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 24,\n\t\tpaddingVertical: 12,\n\t},\n\tinput: {\n\t\tmarginHorizontal: 24,\n\t\tmarginBottom: 16,\n\t},\n\tsubtitle: {\n\t\tmarginHorizontal: 24,\n\t\tmarginBottom: 8,\n\t\topacity: 0.7,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/SaveQueueToPlaylistModal.tsx",
    "content": "import { useState } from 'react'\nimport { StyleSheet } from 'react-native'\nimport { Dialog, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { playlistKeys } from '@/hooks/queries/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { queryClient } from '@/lib/config/queryClient'\nimport { playlistFacade } from '@/lib/facades/playlist'\nimport type { ModalPropsMap } from '@/types/navigation'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport Log from '@/utils/log'\nimport toast from '@/utils/toast'\n\nconst logger = Log.extend('SaveQueueToPlaylistModal')\n\nexport default function SaveQueueToPlaylistModal({\n\ttrackIds,\n}: ModalPropsMap['SaveQueueToPlaylist']) {\n\tconst [name, setName] = useState('')\n\tconst [loading, setLoading] = useState(false)\n\tconst close = useModalStore((state) => state.close)\n\n\tconst handleSave = async () => {\n\t\tif (!name.trim()) return\n\t\tsetLoading(true)\n\n\t\tconst res = await playlistFacade.saveQueueAsPlaylist(name, trackIds)\n\n\t\tif (res.isErr()) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'保存播放列表失败',\n\t\t\t\tres.error,\n\t\t\t\t'SaveQueueToPlaylistModal',\n\t\t\t)\n\t\t\tsetLoading(false)\n\t\t\treturn\n\t\t}\n\n\t\tlogger.info('保存队列到播放列表成功', res.value)\n\t\ttoast.success('保存队列到播放列表成功')\n\t\tawait Promise.all([\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t}),\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistContents(res.value),\n\t\t\t}),\n\t\t\tqueryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistMetadata(res.value),\n\t\t\t}),\n\t\t])\n\t\tsetLoading(false)\n\t\tclose('SaveQueueToPlaylist')\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>保存队列到播放列表</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='播放列表名称'\n\t\t\t\t\tvalue={name}\n\t\t\t\t\tonChangeText={setName}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tstyle={styles.textInput}\n\t\t\t\t/>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={() => close('SaveQueueToPlaylist')}\n\t\t\t\t\tdisabled={loading}\n\t\t\t\t>\n\t\t\t\t\t取消\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={handleSave}\n\t\t\t\t\tloading={loading}\n\t\t\t\t\tdisabled={loading}\n\t\t\t\t>\n\t\t\t\t\t保存\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\ttextInput: {\n\t\tbackgroundColor: 'transparent',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/SubscribeToSharedPlaylistModal.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { useMemo, useState } from 'react'\nimport { StyleSheet } from 'react-native'\nimport { Dialog, Text, TextInput } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nconst UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i\n\n/** 从任意输入中提取 shareId + inviteCode（优先 query params） */\nfunction parseShareLink(input: string): {\n\tshareId?: string\n\tinviteCode?: string\n} {\n\tconst trimmed = input.trim()\n\tif (!trimmed) return {}\n\n\ttry {\n\t\tconst url = new URL(trimmed)\n\t\tconst qpShareId = url.searchParams.get('shareId') ?? undefined\n\t\tconst qpInvite = url.searchParams.get('inviteCode') ?? undefined\n\t\tconst pathUuid = url.pathname.match(UUID_RE)?.[0]\n\t\treturn {\n\t\t\tshareId: qpShareId ?? pathUuid ?? undefined,\n\t\t\tinviteCode: qpInvite ?? undefined,\n\t\t}\n\t} catch (_e) {\n\t\t// fallback to plain text / raw UUID\n\t\tconst uuid = trimmed.match(UUID_RE)?.[0]\n\t\treturn { shareId: uuid ?? undefined, inviteCode: undefined }\n\t}\n}\n\nexport default function SubscribeToSharedPlaylistModal() {\n\tconst [input, setInput] = useState('')\n\tconst [inviteCode, setInviteCode] = useState('')\n\tconst close = useModalStore((state) => state.close)\n\tconst router = useRouter()\n\n\tconst parsed = useMemo(() => parseShareLink(input), [input])\n\tconst shareId = parsed.shareId ?? ''\n\tconst isValidId = UUID_RE.test(shareId)\n\n\tconst handleSubscribe = () => {\n\t\tif (!isValidId) return\n\t\tclose('SubscribeToSharedPlaylist')\n\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\trouter.push({\n\t\t\t\tpathname: '/share/playlist',\n\t\t\t\tparams: {\n\t\t\t\t\tshareId: shareId,\n\t\t\t\t\tinviteCode:\n\t\t\t\t\t\t(inviteCode || parsed.inviteCode || '').trim() || undefined,\n\t\t\t\t},\n\t\t\t})\n\t\t})\n\t}\n\n\tconst handleChangeInput = (text: string) => {\n\t\tsetInput(text)\n\t\tconst next = parseShareLink(text)\n\t\tif (next.inviteCode) {\n\t\t\tsetInviteCode(next.inviteCode)\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>订阅共享歌单</Dialog.Title>\n\t\t\t<Dialog.Content style={styles.content}>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\tstyle={styles.hint}\n\t\t\t\t>\n\t\t\t\t\t粘贴对方分享的链接或歌单 ID（UUID 格式）即可订阅。\n\t\t\t\t</Text>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='分享链接 / 歌单 ID'\n\t\t\t\t\tvalue={input}\n\t\t\t\t\tonChangeText={handleChangeInput}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tautoCapitalize='none'\n\t\t\t\t\tautoCorrect={false}\n\t\t\t\t\tstyle={styles.input}\n\t\t\t\t\terror={input.trim().length > 0 && !isValidId}\n\t\t\t\t/>\n\t\t\t\t<TextInput\n\t\t\t\t\tlabel='编辑者邀请码（可选）'\n\t\t\t\t\tvalue={inviteCode}\n\t\t\t\t\tonChangeText={setInviteCode}\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tautoCapitalize='characters'\n\t\t\t\t\tautoCorrect={false}\n\t\t\t\t\tstyle={styles.input}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tparsed.inviteCode ? `已从链接填充：${parsed.inviteCode}` : ''\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{input.trim().length > 0 && !isValidId && (\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t未能识别有效的歌单 ID，请检查链接是否完整。\n\t\t\t\t\t</Text>\n\t\t\t\t)}\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={() => close('SubscribeToSharedPlaylist')}\n\t\t\t\t\tmode='text'\n\t\t\t\t>\n\t\t\t\t\t取消\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={handleSubscribe}\n\t\t\t\t\tdisabled={!isValidId}\n\t\t\t\t\tmode='text'\n\t\t\t\t>\n\t\t\t\t\t订阅\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tgap: 8,\n\t},\n\thint: {\n\t\topacity: 0.7,\n\t\tmarginBottom: 4,\n\t},\n\tinput: {\n\t\tmarginTop: 4,\n\t},\n\terrorText: {\n\t\tcolor: '#cf6679',\n\t\tmarginTop: 2,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/SyncLocalToBilibiliModal.tsx",
    "content": "import { useEffect, useReducer } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tDialog,\n\tDivider,\n\tProgressBar,\n\tText,\n} from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport { usePlaylistMetadata } from '@/hooks/queries/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { playlistService } from '@/lib/services/playlistService'\nimport { syncLocalToBilibiliService } from '@/lib/services/syncLocalToBilibiliService'\nimport toast from '@/utils/toast'\n\ninterface SyncLocalToBilibiliModalProps {\n\tplaylistId: number\n}\n\ntype Step =\n\t| 'checking'\n\t| 'confirm_create'\n\t| 'diffing'\n\t| 'confirm_sync'\n\t| 'syncing'\n\t| 'success'\n\t| 'error'\n\ninterface RemoteFolder {\n\tid: number\n\ttitle: string\n}\n\ninterface DiffResult {\n\ttoAdd: string[]\n\ttoRemove: string[]\n}\n\ninterface State {\n\tstep: Step\n\tremoteFolder: RemoteFolder | null\n\tdiffResult: DiffResult | null\n\tprogress: number\n\ttotalOps: number\n\tfailCount: number\n\terrorMsg: string\n}\n\ntype Action =\n\t| { type: 'SET_STEP'; payload: Step }\n\t| { type: 'SET_REMOTE_FOLDER'; payload: RemoteFolder }\n\t| { type: 'SET_DIFF_RESULT'; payload: DiffResult }\n\t| { type: 'SET_PROGRESS'; payload: number }\n\t| { type: 'SET_TOTAL_OPS'; payload: number }\n\t| { type: 'SET_FAIL_COUNT'; payload: number }\n\t| { type: 'SET_ERROR'; payload: string }\n\t| { type: 'RESET' }\n\nconst initialState: State = {\n\tstep: 'checking',\n\tremoteFolder: null,\n\tdiffResult: null,\n\tprogress: 0,\n\ttotalOps: 0,\n\tfailCount: 0,\n\terrorMsg: '',\n}\n\nfunction reducer(state: State, action: Action): State {\n\tswitch (action.type) {\n\t\tcase 'SET_STEP':\n\t\t\treturn { ...state, step: action.payload }\n\t\tcase 'SET_REMOTE_FOLDER':\n\t\t\treturn { ...state, remoteFolder: action.payload }\n\t\tcase 'SET_DIFF_RESULT':\n\t\t\treturn { ...state, diffResult: action.payload }\n\t\tcase 'SET_PROGRESS':\n\t\t\treturn { ...state, progress: action.payload }\n\t\tcase 'SET_TOTAL_OPS':\n\t\t\treturn { ...state, totalOps: action.payload }\n\t\tcase 'SET_FAIL_COUNT':\n\t\t\treturn { ...state, failCount: action.payload }\n\t\tcase 'SET_ERROR':\n\t\t\treturn { ...state, errorMsg: action.payload, step: 'error' }\n\t\tcase 'RESET':\n\t\t\treturn initialState\n\t\tdefault:\n\t\t\treturn state\n\t}\n}\n\nexport default function SyncLocalToBilibiliModal({\n\tplaylistId,\n}: SyncLocalToBilibiliModalProps) {\n\tconst close = useModalStore((state) => state.close)\n\tconst [state, dispatch] = useReducer(reducer, initialState)\n\tconst {\n\t\tstep,\n\t\tremoteFolder,\n\t\tdiffResult,\n\t\tprogress,\n\t\ttotalOps,\n\t\tfailCount,\n\t\terrorMsg,\n\t} = state\n\n\tconst { data: playlist } = usePlaylistMetadata(playlistId)\n\tconst { data: userInfo } = usePersonalInformation()\n\n\t// 检查远程收藏夹\n\tuseEffect(() => {\n\t\tif (step !== 'checking') return\n\n\t\tif (!userInfo?.mid) {\n\t\t\tdispatch({ type: 'SET_ERROR', payload: '未登录 B 站，请先登录' })\n\t\t\treturn\n\t\t}\n\t\tif (!playlist) return // 等待加载\n\n\t\tconst check = async () => {\n\t\t\tconst res = await syncLocalToBilibiliService.findRemotePlaylistByName(\n\t\t\t\tNumber(userInfo.mid),\n\t\t\t\tplaylist.title,\n\t\t\t)\n\t\t\tif (res.isErr()) {\n\t\t\t\tdispatch({ type: 'SET_ERROR', payload: res.error.message })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (res.value) {\n\t\t\t\tdispatch({ type: 'SET_REMOTE_FOLDER', payload: res.value })\n\t\t\t\tdispatch({ type: 'SET_STEP', payload: 'diffing' })\n\t\t\t} else {\n\t\t\t\tdispatch({ type: 'SET_STEP', payload: 'confirm_create' })\n\t\t\t}\n\t\t}\n\t\tvoid check()\n\t}, [step, playlist, userInfo?.mid])\n\n\t// 创建远程收藏夹\n\tconst handleCreate = async () => {\n\t\tif (!playlist) return\n\t\tconst res = await syncLocalToBilibiliService.createRemotePlaylist(\n\t\t\tplaylist.title,\n\t\t)\n\t\tif (res.isErr()) {\n\t\t\tdispatch({ type: 'SET_ERROR', payload: res.error.message })\n\t\t\treturn\n\t\t}\n\t\tdispatch({\n\t\t\ttype: 'SET_REMOTE_FOLDER',\n\t\t\tpayload: { id: res.value.id, title: playlist.title },\n\t\t})\n\t\tdispatch({ type: 'SET_STEP', payload: 'diffing' })\n\t}\n\n\t// 计算差异\n\tuseEffect(() => {\n\t\tif (step !== 'diffing' || !remoteFolder) return\n\n\t\tconst diff = async () => {\n\t\t\t// 获取本地歌单\n\t\t\tconst tracksRes = await playlistService.getPlaylistTracks(playlistId)\n\t\t\tif (tracksRes.isErr()) {\n\t\t\t\tdispatch({ type: 'SET_ERROR', payload: '获取本地歌单失败' })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst res = await syncLocalToBilibiliService.calculateSyncDiff(\n\t\t\t\ttracksRes.value,\n\t\t\t\tremoteFolder.id,\n\t\t\t)\n\t\t\tif (res.isErr()) {\n\t\t\t\tdispatch({ type: 'SET_ERROR', payload: res.error.message })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdispatch({ type: 'SET_DIFF_RESULT', payload: res.value })\n\t\t\tdispatch({ type: 'SET_STEP', payload: 'confirm_sync' })\n\t\t}\n\t\tvoid diff()\n\t}, [step, remoteFolder, playlistId])\n\n\t// 执行同步\n\tconst handleSync = async () => {\n\t\tif (!remoteFolder || !diffResult) return\n\t\tdispatch({ type: 'SET_STEP', payload: 'syncing' })\n\n\t\tconst total = diffResult.toAdd.length + diffResult.toRemove.length\n\t\tdispatch({ type: 'SET_TOTAL_OPS', payload: total })\n\t\tdispatch({ type: 'SET_PROGRESS', payload: 0 })\n\n\t\tif (total === 0) {\n\t\t\tdispatch({ type: 'SET_STEP', payload: 'success' })\n\t\t\treturn\n\t\t}\n\n\t\t// 添加\n\t\tlet addsFailed = 0\n\t\tif (diffResult.toAdd.length > 0) {\n\t\t\tconst res = await syncLocalToBilibiliService.executeBatchAdd(\n\t\t\t\tremoteFolder.id,\n\t\t\t\tdiffResult.toAdd,\n\t\t\t\t(p) => dispatch({ type: 'SET_PROGRESS', payload: p }),\n\t\t\t)\n\t\t\tif (res.isErr()) {\n\t\t\t\ttoast.error('部分歌曲添加失败，请查看日志')\n\t\t\t} else {\n\t\t\t\taddsFailed = res.value\n\t\t\t}\n\t\t}\n\n\t\t// 删除\n\t\tif (diffResult.toRemove.length > 0) {\n\t\t\tconst res = await syncLocalToBilibiliService.executeBatchRemove(\n\t\t\t\tremoteFolder.id,\n\t\t\t\tdiffResult.toRemove,\n\t\t\t)\n\t\t\tif (res.isErr()) {\n\t\t\t\ttoast.error('部分歌曲删除失败')\n\t\t\t}\n\t\t}\n\n\t\tdispatch({ type: 'SET_FAIL_COUNT', payload: addsFailed })\n\t\tdispatch({ type: 'SET_STEP', payload: 'success' })\n\t}\n\n\tconst renderContent = () => {\n\t\tswitch (step) {\n\t\t\tcase 'checking':\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>同步到 B 站</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t\t\t<Text style={{ marginTop: 20 }}>正在查找远程收藏夹...</Text>\n\t\t\t\t\t\t\t\t<Text style={{ marginTop: 10, color: 'red', fontSize: 12 }}>\n\t\t\t\t\t\t\t\t\t警告：此功能由于 B 站 API 限制，极易触发风控导致 IP{' '}\n\t\t\t\t\t\t\t\t\t被暂时封禁，谨慎使用。\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\tcase 'confirm_create':\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>同步到 B 站</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<Text variant='bodyLarge'>\n\t\t\t\t\t\t\t\t未找到名为 &quot;{playlist?.title}&quot; 的 B 站收藏夹。\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\tstyle={{ marginTop: 8, color: 'gray' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t是否创建一个新的公开收藏夹？\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t<Button onPress={() => close('SyncLocalToBilibili')}>取消</Button>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\tonPress={handleCreate}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t创建并继续\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\tcase 'diffing':\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>同步到 B 站</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t\t\t<Text style={{ marginTop: 20 }}>正在对比列表差异...</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\tcase 'confirm_sync': {\n\t\t\t\tconst nothingToSync =\n\t\t\t\t\tdiffResult?.toAdd.length === 0 && diffResult.toRemove.length === 0\n\n\t\t\t\tif (nothingToSync) {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Dialog.Title>同步确认</Dialog.Title>\n\t\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t\t<Text variant='bodyLarge'>无需同步</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\t\tstyle={{ marginTop: 8, color: 'gray' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t当前本地列表与远程收藏夹已完全一致。\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t\t<Button onPress={() => close('SyncLocalToBilibili')}>\n\t\t\t\t\t\t\t\t\t关闭\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>同步确认</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<View style={styles.statRow}>\n\t\t\t\t\t\t\t\t<Text>新增歌曲</Text>\n\t\t\t\t\t\t\t\t<Text style={{ color: 'green', fontWeight: 'bold' }}>\n\t\t\t\t\t\t\t\t\t+{diffResult?.toAdd.length}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<Divider style={{ marginVertical: 4 }} />\n\t\t\t\t\t\t\t<View style={styles.statRow}>\n\t\t\t\t\t\t\t\t<Text>移除歌曲 (远端多余)</Text>\n\t\t\t\t\t\t\t\t<Text style={{ color: 'red', fontWeight: 'bold' }}>\n\t\t\t\t\t\t\t\t\t-{diffResult?.toRemove.length}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\tstyle={{ marginTop: 10, color: 'gray', fontWeight: 'bold' }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t注意：这是一个镜像同步操作。本地没有的歌曲将会从远端删除。\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t<Button onPress={() => close('SyncLocalToBilibili')}>取消</Button>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\tonPress={handleSync}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t开始同步\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\t}\n\t\t\tcase 'syncing':\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>同步到 B 站</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t\t\t<Text style={{ marginTop: 20, marginBottom: 10 }}>\n\t\t\t\t\t\t\t\t\t同步中...\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<ProgressBar\n\t\t\t\t\t\t\t\t\tprogress={totalOps > 0 ? progress / totalOps : 0}\n\t\t\t\t\t\t\t\t\tstyle={{ width: '100%' }}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Text style={{ marginTop: 5 }}>\n\t\t\t\t\t\t\t\t\t{progress} / {totalOps}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\tcase 'success':\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>同步完成</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tcolor: failCount > 0 ? 'orange' : 'green',\n\t\t\t\t\t\t\t\t\t\tmarginBottom: 10,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{failCount > 0 ? '同步部分成功' : '同步成功'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t{failCount > 0 && (\n\t\t\t\t\t\t\t\t\t<Text style={{ color: 'gray' }}>\n\t\t\t\t\t\t\t\t\t\t有 {failCount} 首歌曲未能同步成功，请稍后重试。（可能是你的\n\t\t\t\t\t\t\t\t\t\tIP 被风控了，R.I.P.）\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\tonPress={() => close('SyncLocalToBilibili')}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t我知道了\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\tcase 'error':\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Title>出错了</Dialog.Title>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<Text style={{ color: 'red', marginBottom: 10 }}>\n\t\t\t\t\t\t\t\t\t{errorMsg}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t<Button onPress={() => close('SyncLocalToBilibili')}>关闭</Button>\n\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t}\n\t}\n\n\treturn renderContent()\n}\n\nconst styles = StyleSheet.create({\n\tcenter: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\twidth: '100%',\n\t},\n\tstatRow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\tpaddingVertical: 8,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/components/modals/playlist/UpdateTrackLocalPlaylistsModal.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useMemo, useState } from 'react'\nimport { ActivityIndicator, StyleSheet, View } from 'react-native'\nimport { Checkbox, Dialog, Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useUpdateTrackLocalPlaylists } from '@/hooks/mutations/db/playlist'\nimport {\n\tusePlaylistLists,\n\tusePlaylistsContainingTrack,\n} from '@/hooks/queries/db/playlist'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport generateUniqueTrackKey from '@/lib/services/genKey'\nimport type { Playlist, Track } from '@/types/core/media'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport toast from '@/utils/toast'\n\nconst renderPlaylistItem = ({\n\titem,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tPlaylist,\n\t{\n\t\tcheckedPlaylistIds: number[]\n\t\thandleCheckboxPress: (id: number) => void\n\t}\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst { checkedPlaylistIds, handleCheckboxPress } = extraData\n\tconst isChecked = checkedPlaylistIds.includes(item.id)\n\tconst isDisabled = item.type !== 'local'\n\n\treturn (\n\t\t<PlaylistListItem\n\t\t\tid={item.id}\n\t\t\ttitle={item.title}\n\t\t\tonPress={handleCheckboxPress}\n\t\t\tisChecked={isChecked}\n\t\t\tisDisabled={isDisabled}\n\t\t/>\n\t)\n}\n\nconst PlaylistListItem = memo(function PlaylistListItem({\n\tid,\n\ttitle,\n\tisChecked,\n\tisDisabled,\n\tonPress,\n}: {\n\tid: number\n\ttitle: string\n\tonPress: (id: number) => void\n\tisChecked: boolean\n\tisDisabled: boolean\n}) {\n\tconst handlePress = useCallback(() => {\n\t\tonPress(id)\n\t}, [id, onPress])\n\n\treturn (\n\t\t<Checkbox.Item\n\t\t\tlabel={title}\n\t\t\tstatus={isChecked ? 'checked' : 'unchecked'}\n\t\t\tonPress={handlePress}\n\t\t\tdisabled={isDisabled}\n\t\t/>\n\t)\n})\nPlaylistListItem.displayName = 'PlaylistListItem'\n\nconst UpdateTrackLocalPlaylistsModal = memo(\n\tfunction UpdateTrackLocalPlaylistsModal({ track }: { track: Track }) {\n\t\tconst { colors } = useTheme()\n\t\tconst _close = useModalStore((state) => state.close)\n\t\tconst close = useCallback(\n\t\t\t() => _close('UpdateTrackLocalPlaylists'),\n\t\t\t[_close],\n\t\t)\n\t\tconst open = useModalStore((state) => state.open)\n\n\t\tconst {\n\t\t\tdata: allPlaylists,\n\t\t\tisPending: isPlaylistsPending,\n\t\t\tisError: isPlaylistsError,\n\t\t\trefetch: refetchPlaylists,\n\t\t} = usePlaylistLists()\n\t\tconst filteredPlaylists = useMemo(\n\t\t\t() =>\n\t\t\t\tallPlaylists?.filter(\n\t\t\t\t\t(p) => p.type === 'local' && p.shareRole !== 'subscriber',\n\t\t\t\t),\n\t\t\t[allPlaylists],\n\t\t)\n\n\t\tconst uniqueKey = generateUniqueTrackKey(track).unwrapOr(undefined)\n\t\tif (!uniqueKey) toast.error('无法生成 uniqueKey')\n\t\tconst {\n\t\t\tdata: playlistsContainingTrack,\n\t\t\tisPending: isContainingTrackPending,\n\t\t\tisError: isContainingTrackError,\n\t\t\trefetch: refetchContainingTrack,\n\t\t} = usePlaylistsContainingTrack(uniqueKey)\n\n\t\tconst { mutate: updateTracks, isPending: isMutating } =\n\t\t\tuseUpdateTrackLocalPlaylists()\n\n\t\tconst [checkedPlaylistIds, setCheckedPlaylistIds] = useState<number[]>([])\n\n\t\t// 组合加载和错误状态\n\t\tconst isLoading = isPlaylistsPending || isContainingTrackPending\n\t\tconst isError = isPlaylistsError || isContainingTrackError\n\n\t\tconst initialCheckedPlaylistIdSet = useMemo(() => {\n\t\t\tif (!playlistsContainingTrack) return new Set<number>()\n\t\t\treturn new Set(playlistsContainingTrack.map((p) => p.id))\n\t\t}, [playlistsContainingTrack])\n\t\tconst initialCheckedPlaylistIdList = useMemo(\n\t\t\t() => Array.from(initialCheckedPlaylistIdSet),\n\t\t\t[initialCheckedPlaylistIdSet],\n\t\t)\n\n\t\tconst [prevInitialIds, setPrevInitialIds] = useState(\n\t\t\tinitialCheckedPlaylistIdList,\n\t\t)\n\t\tif (prevInitialIds !== initialCheckedPlaylistIdList) {\n\t\t\tsetPrevInitialIds(initialCheckedPlaylistIdList)\n\t\t\tsetCheckedPlaylistIds(initialCheckedPlaylistIdList)\n\t\t}\n\n\t\tconst handleCheckboxPress = useCallback((playlistId: number) => {\n\t\t\tsetCheckedPlaylistIds((currentIds) => {\n\t\t\t\tconst isCurrentlyChecked = currentIds.includes(playlistId)\n\t\t\t\tif (isCurrentlyChecked) {\n\t\t\t\t\treturn currentIds.filter((id) => id !== playlistId)\n\t\t\t\t} else {\n\t\t\t\t\treturn [...currentIds, playlistId]\n\t\t\t\t}\n\t\t\t})\n\t\t}, [])\n\n\t\tconst handleConfirm = useCallback(() => {\n\t\t\tif (isMutating) return\n\n\t\t\tconst currentCheckedIds = new Set(checkedPlaylistIds)\n\n\t\t\tconst toAddPlaylistIds = [...currentCheckedIds].filter(\n\t\t\t\t(id) => !initialCheckedPlaylistIdSet.has(id),\n\t\t\t)\n\t\t\tconst toRemovePlaylistIds = [...initialCheckedPlaylistIdSet].filter(\n\t\t\t\t(id) => !currentCheckedIds.has(id),\n\t\t\t)\n\n\t\t\tif (toAddPlaylistIds.length === 0 && toRemovePlaylistIds.length === 0) {\n\t\t\t\tclose()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tupdateTracks({\n\t\t\t\ttoAddPlaylistIds,\n\t\t\t\ttoRemovePlaylistIds,\n\t\t\t\ttrackPayload: track,\n\t\t\t\tartistPayload: track.artist,\n\t\t\t})\n\n\t\t\tclose()\n\t\t}, [\n\t\t\tisMutating,\n\t\t\tcheckedPlaylistIds,\n\t\t\tinitialCheckedPlaylistIdSet,\n\t\t\tupdateTracks,\n\t\t\ttrack,\n\t\t\tclose,\n\t\t])\n\n\t\tconst extraData = useMemo(\n\t\t\t() => ({\n\t\t\t\tcheckedPlaylistIds,\n\t\t\t\thandleCheckboxPress,\n\t\t\t}),\n\t\t\t[checkedPlaylistIds, handleCheckboxPress],\n\t\t)\n\n\t\tconst handleDismiss = () => {\n\t\t\tif (isMutating) return\n\t\t\tclose()\n\t\t}\n\n\t\tconst handleRetry = () => {\n\t\t\tif (isPlaylistsError) void refetchPlaylists()\n\t\t\tif (isContainingTrackError) void refetchContainingTrack()\n\t\t}\n\n\t\tconst keyExtractor = useCallback((item: Playlist) => item.id.toString(), [])\n\n\t\tconst renderContent = () => {\n\t\t\tif (isLoading) {\n\t\t\t\treturn (\n\t\t\t\t\t<Dialog.Content style={styles.loadingContainer}>\n\t\t\t\t\t\t<ActivityIndicator size={'large'} />\n\t\t\t\t\t</Dialog.Content>\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tif (isError) {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t\t<Text style={[styles.errorText, { color: colors.error }]}>\n\t\t\t\t\t\t\t\t加载歌单列表失败\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t\t<Dialog.Actions>\n\t\t\t\t\t\t\t<Button onPress={handleDismiss}>关闭</Button>\n\t\t\t\t\t\t\t<Button onPress={handleRetry}>重试</Button>\n\t\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<Dialog.ScrollArea style={styles.listContainer}>\n\t\t\t\t\t\t<FlashList\n\t\t\t\t\t\t\tdata={filteredPlaylists ?? []}\n\t\t\t\t\t\t\trenderItem={renderPlaylistItem}\n\t\t\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\t\t\textraData={extraData}\n\t\t\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t\t\t<View style={styles.emptyListContainer}>\n\t\t\t\t\t\t\t\t\t<Text>你还没有创建任何歌单</Text>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Dialog.ScrollArea>\n\t\t\t\t\t<Dialog.Content>\n\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t*{'\\u2009'}与远程同步或订阅的共享歌单不会显示\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</Dialog.Content>\n\t\t\t\t\t<Dialog.Actions style={styles.actionsContainer}>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\topen('CreatePlaylist', { redirectToNewPlaylist: false })\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t创建歌单\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<View style={styles.rightActionsContainer}>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tonPress={handleDismiss}\n\t\t\t\t\t\t\t\tdisabled={isMutating}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t取消\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tonPress={handleConfirm}\n\t\t\t\t\t\t\t\tloading={isMutating}\n\t\t\t\t\t\t\t\tdisabled={isMutating}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t确认\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</Dialog.Actions>\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog.Title>添加到歌单</Dialog.Title>\n\t\t\t\t{renderContent()}\n\t\t\t</>\n\t\t)\n\t},\n)\n\nconst styles = StyleSheet.create({\n\tloadingContainer: {\n\t\talignItems: 'center',\n\t\tpaddingVertical: 20,\n\t},\n\terrorText: {\n\t\ttextAlign: 'center',\n\t},\n\tlistContainer: {\n\t\tminHeight: 300,\n\t},\n\temptyListContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tactionsContainer: {\n\t\tjustifyContent: 'space-between',\n\t},\n\trightActionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n})\n\nUpdateTrackLocalPlaylistsModal.displayName = 'UpdateTrackLocalPlaylistsModal'\n\nexport default UpdateTrackLocalPlaylistsModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/settings/CoverDownloadProgressModal.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { memo, useEffect, useRef, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Dialog, ProgressBar, Text } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { toastAndLogError } from '@/utils/error-handling'\n\ninterface ProgressState {\n\tcurrent: number\n\ttotal: number\n\tfailed: number\n\tstage: 'pending' | 'downloading' | 'completed' | 'error'\n\tmessage: string\n}\n\nconst CoverDownloadProgressModal = memo(function CoverDownloadProgressModal() {\n\tconst close = useModalStore((state) => state.close)\n\tconst [progress, setProgress] = useState<ProgressState>({\n\t\tcurrent: 0,\n\t\ttotal: 0,\n\t\tfailed: 0,\n\t\tstage: 'pending',\n\t\tmessage: '准备中...',\n\t})\n\tconst hasStarted = useRef(false)\n\n\tuseEffect(() => {\n\t\tif (hasStarted.current) return\n\t\thasStarted.current = true\n\n\t\tconst subscription = Orpheus.addListener(\n\t\t\t'onCoverDownloadProgress',\n\t\t\t(event) => {\n\t\t\t\tsetProgress((prev) => {\n\t\t\t\t\tconst failed = prev.failed + (event.status === 'failed' ? 1 : 0)\n\t\t\t\t\tconst isLast = event.current === event.total\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcurrent: event.current,\n\t\t\t\t\t\ttotal: event.total,\n\t\t\t\t\t\tfailed,\n\t\t\t\t\t\tstage: isLast ? 'completed' : 'downloading',\n\t\t\t\t\t\tmessage: isLast\n\t\t\t\t\t\t\t? failed > 0\n\t\t\t\t\t\t\t\t? `完成，${event.total - failed} 个成功，${failed} 个失败`\n\t\t\t\t\t\t\t\t: `全部 ${event.total} 个封面下载完成`\n\t\t\t\t\t\t\t: `正在下载 ${event.current}/${event.total}...`,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t},\n\t\t)\n\n\t\tOrpheus.downloadMissingCovers()\n\t\t\t.then((total) => {\n\t\t\t\tif (total === 0) {\n\t\t\t\t\tsetProgress({\n\t\t\t\t\t\tcurrent: 0,\n\t\t\t\t\t\ttotal: 0,\n\t\t\t\t\t\tfailed: 0,\n\t\t\t\t\t\tstage: 'completed',\n\t\t\t\t\t\tmessage: '所有封面已完整，无需下载',\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t})\n\t\t\t.catch((e: unknown) => {\n\t\t\t\ttoastAndLogError('下载缺失封面失败', e, 'Modal.CoverDownloadProgress')\n\t\t\t\tsetProgress((prev) => ({\n\t\t\t\t\t...prev,\n\t\t\t\t\tstage: 'error',\n\t\t\t\t\tmessage: '启动下载失败',\n\t\t\t\t}))\n\t\t\t})\n\n\t\treturn () => {\n\t\t\tsubscription.remove()\n\t\t}\n\t}, [])\n\n\tconst isFinished =\n\t\tprogress.stage === 'completed' || progress.stage === 'error'\n\tconst progressValue =\n\t\tprogress.total > 0 ? progress.current / progress.total : undefined\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog.Title>\n\t\t\t\t{progress.stage === 'completed'\n\t\t\t\t\t? '下载完成'\n\t\t\t\t\t: progress.stage === 'error'\n\t\t\t\t\t\t? '下载失败'\n\t\t\t\t\t\t: '正在下载缺失封面'}\n\t\t\t</Dialog.Title>\n\t\t\t<Dialog.Content>\n\t\t\t\t<View style={styles.content}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.message}\n\t\t\t\t\t>\n\t\t\t\t\t\t{progress.message}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<ProgressBar\n\t\t\t\t\t\tprogress={isFinished ? 1 : progressValue}\n\t\t\t\t\t\tindeterminate={!isFinished && progressValue === undefined}\n\t\t\t\t\t\tstyle={styles.progressBar}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Dialog.Content>\n\t\t\t<Dialog.Actions>\n\t\t\t\t<Button\n\t\t\t\t\tonPress={() => close('CoverDownloadProgress')}\n\t\t\t\t\tdisabled={!isFinished}\n\t\t\t\t>\n\t\t\t\t\t{isFinished ? '关闭' : '请稍候'}\n\t\t\t\t</Button>\n\t\t\t</Dialog.Actions>\n\t\t</>\n\t)\n})\n\nCoverDownloadProgressModal.displayName = 'CoverDownloadProgressModal'\n\nconst styles = StyleSheet.create({\n\tcontent: {\n\t\tgap: 15,\n\t\tpaddingVertical: 10,\n\t},\n\tmessage: {\n\t\ttextAlign: 'center',\n\t},\n\tprogressBar: {\n\t\theight: 8,\n\t\tborderRadius: 4,\n\t},\n})\n\nexport default CoverDownloadProgressModal\n"
  },
  {
    "path": "apps/mobile/src/components/modals/settings/ExportDownloadsProgressModal.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport type { TrueSheet as TrueSheetType } from '@lodev09/react-native-true-sheet'\nimport { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport type { RefObject } from 'react'\nimport { memo, useEffect, useRef, useState } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport { GestureHandlerRootView } from 'react-native-gesture-handler'\nimport {\n\tDivider,\n\tHelperText,\n\tProgressBar,\n\tSwitch,\n\tText,\n\tTextInput,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport Button from '@/components/common/Button'\nimport { toastAndLogError } from '@/utils/error-handling'\n\ntype Stage = 'config' | 'exporting' | 'completed' | 'error'\n\ninterface ProgressState {\n\tcurrent: number\n\ttotal: number\n\tfailed: number\n\tstage: Stage\n\tmessage: string\n}\n\nexport interface ExportDownloadsProgressModalProps {\n\tsheetRef: RefObject<TrueSheetType | null>\n\tids: string[]\n\tdestinationUri: string\n}\n\nconst PREVIEW_VALUES: Record<string, string> = {\n\tid: 'bilibili::BV114514::1919810',\n\tname: '春日影',\n\tartist: 'Crychic',\n\tbvid: 'BV114514',\n\tcid: '1919810',\n}\n\nconst VARIABLE_KEYS = Object.keys(PREVIEW_VALUES)\n\nfunction buildPreviewFilename(pattern: string): string {\n\tif (!pattern.trim()) return `${PREVIEW_VALUES.name}.m4a`\n\tlet result = pattern\n\tfor (const [key, val] of Object.entries(PREVIEW_VALUES)) {\n\t\tresult = result.replaceAll(`{${key}}`, val)\n\t}\n\tresult = result.replace(/[\\\\/:*?\"<>|]/g, '_').trim()\n\treturn result ? `${result}.m4a` : `${PREVIEW_VALUES.name}.m4a`\n}\n\nfunction patternHasVariable(pattern: string): boolean {\n\treturn VARIABLE_KEYS.some((k) => pattern.includes(`{${k}}`))\n}\n\nconst ExportDownloadsProgressModal = memo(\n\tfunction ExportDownloadsProgressModal({\n\t\tsheetRef,\n\t\tids,\n\t\tdestinationUri,\n\t}: ExportDownloadsProgressModalProps) {\n\t\tconst { colors } = useTheme()\n\t\tconst insets = useSafeAreaInsets()\n\n\t\tconst [filenamePattern, setFilenamePattern] = useState('{name}')\n\t\tconst [embedLyrics, setEmbedLyrics] = useState(false)\n\t\tconst [convertToLrc, setConvertToLrc] = useState(false)\n\t\tconst [cropCoverArt, setCropCoverArt] = useState(false)\n\n\t\tconst [progress, setProgress] = useState<ProgressState>({\n\t\t\tcurrent: 0,\n\t\t\ttotal: ids.length,\n\t\t\tfailed: 0,\n\t\t\tstage: 'config',\n\t\t\tmessage: '准备导出...',\n\t\t})\n\t\tconst hasStarted = useRef(false)\n\t\tconst subscriptionRef = useRef<{ remove(): void } | null>(null)\n\n\t\tconst stage = progress.stage\n\n\t\t// Reset internal state whenever a new export session begins (ids / destination changed)\n\t\tuseEffect(() => {\n\t\t\tsetFilenamePattern('{name}')\n\t\t\tsetEmbedLyrics(false)\n\t\t\tsetConvertToLrc(false)\n\t\t\tsetCropCoverArt(false)\n\t\t\tsetProgress({\n\t\t\t\tcurrent: 0,\n\t\t\t\ttotal: ids.length,\n\t\t\t\tfailed: 0,\n\t\t\t\tstage: 'config',\n\t\t\t\tmessage: '准备导出...',\n\t\t\t})\n\t\t\thasStarted.current = false\n\n\t\t\treturn () => {\n\t\t\t\tsubscriptionRef.current?.remove()\n\t\t\t\tsubscriptionRef.current = null\n\t\t\t}\n\t\t}, [ids, destinationUri])\n\n\t\tfunction startExport() {\n\t\t\tif (hasStarted.current) return\n\t\t\thasStarted.current = true\n\n\t\t\tsetProgress((prev) => ({ ...prev, stage: 'exporting' }))\n\n\t\t\tsubscriptionRef.current = Orpheus.addListener(\n\t\t\t\t'onExportProgress',\n\t\t\t\t(event) => {\n\t\t\t\t\tsetProgress((prev) => {\n\t\t\t\t\t\tconst failed = prev.failed + (event.status === 'error' ? 1 : 0)\n\t\t\t\t\t\tconst current = event.index ?? prev.current\n\t\t\t\t\t\tconst total = event.total ?? prev.total\n\t\t\t\t\t\tconst isLast = current === total\n\n\t\t\t\t\t\tlet message = `正在导出 ${current}/${total}...`\n\t\t\t\t\t\tif (event.status === 'error') {\n\t\t\t\t\t\t\tmessage = `导出 ${event.currentId} 失败: ${event.message ?? '未知错误'}`\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (isLast) {\n\t\t\t\t\t\t\tsubscriptionRef.current?.remove()\n\t\t\t\t\t\t\tsubscriptionRef.current = null\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcurrent,\n\t\t\t\t\t\t\ttotal,\n\t\t\t\t\t\t\tfailed,\n\t\t\t\t\t\t\tstage: isLast ? 'completed' : 'exporting',\n\t\t\t\t\t\t\tmessage: isLast\n\t\t\t\t\t\t\t\t? failed > 0\n\t\t\t\t\t\t\t\t\t? `导出完成，${total - failed} 个成功，${failed} 个失败`\n\t\t\t\t\t\t\t\t\t: `全部 ${total} 个曲目已成功导出`\n\t\t\t\t\t\t\t\t: message,\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tlet effectivePattern = filenamePattern.trim() || '{name}'\n\t\t\tif (!patternHasVariable(effectivePattern)) {\n\t\t\t\teffectivePattern = '{name}'\n\t\t\t}\n\n\t\t\tOrpheus.exportDownloads(\n\t\t\t\tids,\n\t\t\t\tdestinationUri,\n\t\t\t\teffectivePattern,\n\t\t\t\tembedLyrics,\n\t\t\t\tconvertToLrc,\n\t\t\t\tcropCoverArt,\n\t\t\t).catch((e: unknown) => {\n\t\t\t\ttoastAndLogError('启动批量导出失败', e, 'Modal.ExportDownloadsProgress')\n\t\t\t\tsetProgress((prev) => ({\n\t\t\t\t\t...prev,\n\t\t\t\t\tstage: 'error',\n\t\t\t\t\tmessage: '启动导出任务失败',\n\t\t\t\t}))\n\t\t\t\tsubscriptionRef.current?.remove()\n\t\t\t\tsubscriptionRef.current = null\n\t\t\t})\n\t\t}\n\n\t\tfunction dismiss() {\n\t\t\tsubscriptionRef.current?.remove()\n\t\t\tsubscriptionRef.current = null\n\t\t\tvoid sheetRef.current?.dismiss()\n\t\t}\n\n\t\tconst isFinished = stage === 'completed' || stage === 'error'\n\t\tconst progressValue =\n\t\t\tprogress.total > 0 ? progress.current / progress.total : undefined\n\n\t\tconst stageTitle =\n\t\t\tstage === 'config'\n\t\t\t\t? '导出设置'\n\t\t\t\t: stage === 'completed'\n\t\t\t\t\t? '导出完成'\n\t\t\t\t\t: stage === 'error'\n\t\t\t\t\t\t? '导出失败'\n\t\t\t\t\t\t: '正在批量导出歌曲'\n\n\t\treturn (\n\t\t\t<TrueSheet\n\t\t\t\tref={sheetRef}\n\t\t\t\tdetents={[0.75]}\n\t\t\t\tcornerRadius={24}\n\t\t\t\tbackgroundColor={colors.elevation.level1}\n\t\t\t\tscrollable\n\t\t\t\tdismissible={stage === 'config' || isFinished}\n\t\t\t\tonDidDismiss={() => {\n\t\t\t\t\tsetProgress({\n\t\t\t\t\t\tcurrent: 0,\n\t\t\t\t\t\ttotal: ids.length,\n\t\t\t\t\t\tfailed: 0,\n\t\t\t\t\t\tstage: 'config',\n\t\t\t\t\t\tmessage: '准备导出...',\n\t\t\t\t\t})\n\t\t\t\t\thasStarted.current = false\n\t\t\t\t\tsetFilenamePattern('{name}')\n\t\t\t\t\tsetEmbedLyrics(false)\n\t\t\t\t\tsetConvertToLrc(false)\n\t\t\t\t\tsetCropCoverArt(false)\n\t\t\t\t\tsubscriptionRef.current?.remove()\n\t\t\t\t\tsubscriptionRef.current = null\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<GestureHandlerRootView style={{ flex: 1 }}>\n\t\t\t\t\t<View style={styles.sheetHeader}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\tstyle={styles.sheetTitle}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{stageTitle}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t\t<ScrollView\n\t\t\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\t\t\tstyles.sheetContent,\n\t\t\t\t\t\t\t{ paddingBottom: insets.bottom + 16 },\n\t\t\t\t\t\t]}\n\t\t\t\t\t\tnestedScrollEnabled\n\t\t\t\t\t>\n\t\t\t\t\t\t{stage === 'config' ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{/* ── 文件名模板 ── */}\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='labelLarge'\n\t\t\t\t\t\t\t\t\tstyle={styles.sectionTitle}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t文件名模板\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\t\t\tlabel='文件名模板'\n\t\t\t\t\t\t\t\t\tvalue={filenamePattern}\n\t\t\t\t\t\t\t\t\tonChangeText={setFilenamePattern}\n\t\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\t\tplaceholder='{name}'\n\t\t\t\t\t\t\t\t\tautoCapitalize='none'\n\t\t\t\t\t\t\t\t\tautoCorrect={false}\n\t\t\t\t\t\t\t\t\tdense\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<HelperText\n\t\t\t\t\t\t\t\t\ttype={\n\t\t\t\t\t\t\t\t\t\t!filenamePattern.trim() ||\n\t\t\t\t\t\t\t\t\t\tpatternHasVariable(filenamePattern)\n\t\t\t\t\t\t\t\t\t\t\t? 'info'\n\t\t\t\t\t\t\t\t\t\t\t: 'error'\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tvisible\n\t\t\t\t\t\t\t\t\tstyle={styles.helperText}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{!filenamePattern.trim()\n\t\t\t\t\t\t\t\t\t\t? '为空时使用默认模板 {name}'\n\t\t\t\t\t\t\t\t\t\t: patternHasVariable(filenamePattern)\n\t\t\t\t\t\t\t\t\t\t\t? `预览：${buildPreviewFilename(filenamePattern)}`\n\t\t\t\t\t\t\t\t\t\t\t: '模板中未包含任何变量，将自动替换为 {name}'}\n\t\t\t\t\t\t\t\t</HelperText>\n\t\t\t\t\t\t\t\t{/* 可用变量说明 */}\n\t\t\t\t\t\t\t\t<View style={styles.variableBox}>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='labelSmall'\n\t\t\t\t\t\t\t\t\t\tstyle={styles.variableTitle}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t可用变量\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t{[\n\t\t\t\t\t\t\t\t\t\t['id', '曲目唯一 ID'],\n\t\t\t\t\t\t\t\t\t\t['name', '曲目标题'],\n\t\t\t\t\t\t\t\t\t\t['artist', '艺术家'],\n\t\t\t\t\t\t\t\t\t\t['bvid', 'B 站 BV 号'],\n\t\t\t\t\t\t\t\t\t\t['cid', 'B 站 CID（如果不是分 P 视频则为空）'],\n\t\t\t\t\t\t\t\t\t].map(([v, desc]) => (\n\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\tkey={v}\n\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\tstyle={styles.variableRow}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<Text style={styles.variableTag}>{`{${v}}`}</Text>\n\t\t\t\t\t\t\t\t\t\t\t{'  '}\n\t\t\t\t\t\t\t\t\t\t\t{desc}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t\t\t<Divider style={styles.divider} />\n\n\t\t\t\t\t\t\t\t{/* ── 内嵌歌词开关 ── */}\n\t\t\t\t\t\t\t\t<View style={styles.switchRow}>\n\t\t\t\t\t\t\t\t\t<View style={styles.switchLabel}>\n\t\t\t\t\t\t\t\t\t\t<Text variant='labelLarge'>内嵌歌词</Text>\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\tvalue={embedLyrics}\n\t\t\t\t\t\t\t\t\t\tonValueChange={setEmbedLyrics}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t<HelperText\n\t\t\t\t\t\t\t\t\ttype='info'\n\t\t\t\t\t\t\t\t\tvisible\n\t\t\t\t\t\t\t\t\tstyle={styles.helperText}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t只有在播放器「歌词」页面加载过歌词的曲目才会包含内嵌歌词——歌词在播放时加载并缓存到本地，未打开过歌词页面的曲目将不含内嵌歌词。\n\t\t\t\t\t\t\t\t</HelperText>\n\n\t\t\t\t\t\t\t\t{/* ── SPL → LRC 开关（仅 embedLyrics 开启时显示）── */}\n\t\t\t\t\t\t\t\t{embedLyrics && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<Divider style={styles.divider} />\n\t\t\t\t\t\t\t\t\t\t<View style={styles.switchRow}>\n\t\t\t\t\t\t\t\t\t\t\t<View style={styles.switchLabel}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text variant='labelLarge'>转换为标准 LRC</Text>\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={convertToLrc}\n\t\t\t\t\t\t\t\t\t\t\t\tonValueChange={setConvertToLrc}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t<HelperText\n\t\t\t\t\t\t\t\t\t\t\ttype='info'\n\t\t\t\t\t\t\t\t\t\t\tvisible\n\t\t\t\t\t\t\t\t\t\t\tstyle={styles.helperText}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\tBBPlayer 歌词遵循 SPL 规范（LRC\n\t\t\t\t\t\t\t\t\t\t\t超集），支持逐字时间戳（卡拉OK高亮效果）。但大多数播放器（除椒盐音乐外，因为这个规范就来自椒盐音乐）无法识别\n\t\t\t\t\t\t\t\t\t\t\tSPL 逐字语法，开启后将转换为所有播放器均可读取的标准\n\t\t\t\t\t\t\t\t\t\t\tLRC（逐字信息将被移除）。\n\t\t\t\t\t\t\t\t\t\t</HelperText>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t\t<Divider style={styles.divider} />\n\n\t\t\t\t\t\t\t\t{/* ── 裁剪封面开关 ── */}\n\t\t\t\t\t\t\t\t<View style={styles.switchRow}>\n\t\t\t\t\t\t\t\t\t<View style={styles.switchLabel}>\n\t\t\t\t\t\t\t\t\t\t<Text variant='labelLarge'>裁剪封面为正方形</Text>\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\tvalue={cropCoverArt}\n\t\t\t\t\t\t\t\t\t\tonValueChange={setCropCoverArt}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t<HelperText\n\t\t\t\t\t\t\t\t\ttype='info'\n\t\t\t\t\t\t\t\t\tvisible\n\t\t\t\t\t\t\t\t\tstyle={styles.helperText}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tBilibili 封面通常为 16:9，开启后将按中心裁剪为 1:1\n\t\t\t\t\t\t\t\t\t方形，符合主流音乐播放器的封面规范。\n\t\t\t\t\t\t\t\t</HelperText>\n\n\t\t\t\t\t\t\t\t<Divider style={styles.divider} />\n\n\t\t\t\t\t\t\t\t<View style={styles.actionRow}>\n\t\t\t\t\t\t\t\t\t<Button onPress={dismiss}>取消</Button>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\t\t\tonPress={startExport}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t开始导出\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<View style={styles.progressContent}>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\t\t\tstyle={styles.message}\n\t\t\t\t\t\t\t\t\t\tnumberOfLines={2}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{progress.message}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t<ProgressBar\n\t\t\t\t\t\t\t\t\t\tprogress={isFinished ? 1 : progressValue}\n\t\t\t\t\t\t\t\t\t\tindeterminate={!isFinished && progressValue === undefined}\n\t\t\t\t\t\t\t\t\t\tstyle={styles.progressBar}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t\t\t<View style={styles.actionRow}>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tonPress={dismiss}\n\t\t\t\t\t\t\t\t\t\tdisabled={!isFinished}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{isFinished ? '关闭' : '请稍候'}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</ScrollView>\n\t\t\t\t</GestureHandlerRootView>\n\t\t\t</TrueSheet>\n\t\t)\n\t},\n)\n\nExportDownloadsProgressModal.displayName = 'ExportDownloadsProgressModal'\n\nconst styles = StyleSheet.create({\n\tsheetContent: {\n\t\tpaddingHorizontal: 24,\n\t\tpaddingTop: 8,\n\t\tgap: 2,\n\t},\n\tsheetHeader: {\n\t\tpaddingHorizontal: 24,\n\t\tpaddingTop: 26,\n\t\tpaddingBottom: 16,\n\t},\n\tsheetTitle: {\n\t\tfontWeight: '700',\n\t},\n\tsectionTitle: {\n\t\tmarginBottom: 6,\n\t\tmarginTop: 4,\n\t},\n\thelperText: {\n\t\tmarginTop: 0,\n\t\tpaddingHorizontal: 0,\n\t},\n\tvariableBox: {\n\t\tmarginTop: 8,\n\t\tmarginBottom: 4,\n\t\tpaddingHorizontal: 12,\n\t\tpaddingVertical: 8,\n\t\tborderRadius: 6,\n\t\tgap: 4,\n\t\tbackgroundColor: 'rgba(128,128,128,0.08)',\n\t},\n\tvariableTitle: {\n\t\tmarginBottom: 4,\n\t\topacity: 0.6,\n\t},\n\tvariableRow: {\n\t\topacity: 0.8,\n\t},\n\tvariableTag: {\n\t\tfontFamily: 'monospace',\n\t\tfontWeight: '600',\n\t},\n\tdivider: {\n\t\tmarginVertical: 12,\n\t},\n\tswitchRow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\talignItems: 'center',\n\t},\n\tswitchLabel: {\n\t\tflex: 1,\n\t\tpaddingRight: 8,\n\t},\n\tactionRow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'flex-end',\n\t\tgap: 8,\n\t\tmarginTop: 8,\n\t},\n\tprogressContent: {\n\t\tgap: 15,\n\t\tpaddingVertical: 10,\n\t},\n\tmessage: {\n\t\ttextAlign: 'center',\n\t\theight: 40,\n\t},\n\tprogressBar: {\n\t\theight: 8,\n\t\tborderRadius: 4,\n\t},\n})\n\nexport default ExportDownloadsProgressModal\n"
  },
  {
    "path": "apps/mobile/src/components/providers.tsx",
    "content": "import { useMMKVDevTools } from '@rozenite/mmkv-plugin'\nimport { useRequireProfilerDevTools } from '@rozenite/require-profiler-plugin'\nimport { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin'\nimport * as Sentry from '@sentry/react-native'\nimport { QueryClientProvider } from '@tanstack/react-query'\nimport type { ReactNode } from 'react'\nimport { useMemo } from 'react'\nimport { StyleSheet, useColorScheme, View } from 'react-native'\nimport { SystemBars } from 'react-native-edge-to-edge'\nimport { ShimmerProvider } from 'react-native-fast-shimmer'\nimport { GestureHandlerRootView } from 'react-native-gesture-handler'\nimport { KeyboardProvider } from 'react-native-keyboard-controller'\nimport { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper'\nimport { SafeAreaProvider } from 'react-native-safe-area-context'\n\nimport GlobalErrorFallback from '@/components/ErrorBoundary'\nimport { queryClient } from '@/lib/config/queryClient'\nimport { buildMaterial3PaperColors } from '@/lib/theme/material3Colors'\nimport { storage } from '@/utils/mmkv'\n\nexport default function AppProviders({ children }: { children: ReactNode }) {\n\tconst colorScheme = useColorScheme()\n\tconst paperTheme = useMemo(\n\t\t() =>\n\t\t\tcolorScheme === 'dark'\n\t\t\t\t? {\n\t\t\t\t\t\t...MD3DarkTheme,\n\t\t\t\t\t\tcolors: buildMaterial3PaperColors(colorScheme),\n\t\t\t\t\t}\n\t\t\t\t: {\n\t\t\t\t\t\t...MD3LightTheme,\n\t\t\t\t\t\tcolors: buildMaterial3PaperColors(colorScheme),\n\t\t\t\t\t},\n\t\t[colorScheme],\n\t)\n\n\tuseTanStackQueryDevTools(queryClient)\n\tuseMMKVDevTools({\n\t\tstorages: {\n\t\t\t// @ts-expect-error\n\t\t\tapp: storage,\n\t\t},\n\t})\n\tuseRequireProfilerDevTools()\n\n\treturn (\n\t\t<SafeAreaProvider>\n\t\t\t<KeyboardProvider>\n\t\t\t\t<View style={styles.container}>\n\t\t\t\t\t<Sentry.ErrorBoundary\n\t\t\t\t\t\t// oxlint-disable-next-line @typescript-eslint/unbound-method\n\t\t\t\t\t\tfallback={({ error, resetError }) => (\n\t\t\t\t\t\t\t<GlobalErrorFallback\n\t\t\t\t\t\t\t\terror={error}\n\t\t\t\t\t\t\t\tresetError={resetError}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<GestureHandlerRootView style={styles.container}>\n\t\t\t\t\t\t\t<QueryClientProvider client={queryClient}>\n\t\t\t\t\t\t\t\t<PaperProvider theme={paperTheme}>\n\t\t\t\t\t\t\t\t\t<ShimmerProvider duration={1500}>{children}</ShimmerProvider>\n\t\t\t\t\t\t\t\t</PaperProvider>\n\t\t\t\t\t\t\t</QueryClientProvider>\n\t\t\t\t\t\t</GestureHandlerRootView>\n\t\t\t\t\t</Sentry.ErrorBoundary>\n\t\t\t\t\t<SystemBars style='auto' />\n\t\t\t\t</View>\n\t\t\t</KeyboardProvider>\n\t\t</SafeAreaProvider>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/comments/components/CommentItem.tsx",
    "content": "import { Galeria } from '@nandorojo/galeria'\nimport { Image } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { useEffect, useState } from 'react'\nimport { Appearance, StyleSheet, TouchableOpacity, View } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport IconButton from '@/components/common/IconButton'\nimport { useLikeComment } from '@/hooks/mutations/bilibili/comments'\nimport type { BilibiliCommentItem } from '@/types/apis/bilibili'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { formatRelativeTime } from '@/utils/time'\n\ninterface CommentItemProps {\n\titem: BilibiliCommentItem\n\tonReplyPress?: (item: BilibiliCommentItem) => void\n\tbvid: string\n}\n\nexport function CommentItem({ item, onReplyPress, bvid }: CommentItemProps) {\n\tconst theme = useTheme()\n\tconst [liked, setLiked] = useState(item.action === 1)\n\tconst [likeCount, setLikeCount] = useState(item.like || 0)\n\tconst router = useRouter()\n\tconst [darkMode, setDarkMode] = useState(\n\t\tAppearance.getColorScheme() === 'dark',\n\t)\n\n\tuseEffect(() => {\n\t\tconst subscription = Appearance.addChangeListener(({ colorScheme }) => {\n\t\t\tsetDarkMode(colorScheme === 'dark')\n\t\t})\n\t\treturn () => subscription.remove()\n\t}, [])\n\n\tconst { mutateAsync: likeComment } = useLikeComment()\n\n\tconst handleLike = async () => {\n\t\tsetLiked(!liked)\n\t\tsetLikeCount(liked ? likeCount - 1 : likeCount + 1)\n\t\tconst newAction = liked ? 0 : 1\n\t\ttry {\n\t\t\tawait likeComment({\n\t\t\t\tbvid,\n\t\t\t\trpid: item.rpid,\n\t\t\t\tnewAction: newAction,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('点赞失败', e, 'Comments.CommentItem')\n\t\t\tsetLiked(liked)\n\t\t\tsetLikeCount(likeCount)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst onClickUser = () => {\n\t\trouter.push(`/playlist/remote/uploader/${item.mid}`)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<View style={styles.container}>\n\t\t\t\t<View onTouchEnd={onClickUser}>\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsource={{ uri: item.member.avatar }}\n\t\t\t\t\t\tstyle={styles.avatar}\n\t\t\t\t\t\tcontentFit='cover'\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.contentContainer}>\n\t\t\t\t\t<View style={styles.header}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tstyle={[styles.username, { color: theme.colors.secondary }]}\n\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\tonPress={onClickUser}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{item.member.uname}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text style={[styles.time, { color: theme.colors.outline }]}>\n\t\t\t\t\t\t\t{formatRelativeTime(item.ctime * 1000)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t<Text\n\t\t\t\t\t\tstyle={[styles.message, { color: theme.colors.onSurface }]}\n\t\t\t\t\t\tselectable\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.content.message}\n\t\t\t\t\t</Text>\n\n\t\t\t\t\t{item.content.pictures && item.content.pictures.length > 0 && (\n\t\t\t\t\t\t<View style={styles.imagesContainer}>\n\t\t\t\t\t\t\t<Galeria\n\t\t\t\t\t\t\t\turls={item.content.pictures.map((pic) => pic.img_src ?? '')}\n\t\t\t\t\t\t\t\ttheme={darkMode ? 'dark' : 'light'}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{item.content.pictures.map((pic, index) => {\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t/* oxlint-disable-next-line @typescript-eslint/unbound-method */\n\t\t\t\t\t\t\t\t\t\t<Galeria.Image\n\t\t\t\t\t\t\t\t\t\t\tindex={index}\n\t\t\t\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={styles.commentImage}\n\t\t\t\t\t\t\t\t\t\t\t\ttestID='comment-image'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\t\t\t\t\t\tsource={{ uri: pic.img_src }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={styles.commentImageInner}\n\t\t\t\t\t\t\t\t\t\t\t\t\tcontentFit='contain'\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t\t{/* oxlint-disable-next-line @typescript-eslint/unbound-method */}\n\t\t\t\t\t\t\t\t\t\t</Galeria.Image>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</Galeria>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<View style={styles.actions}>\n\t\t\t\t\t\t<TouchableOpacity\n\t\t\t\t\t\t\tstyle={styles.actionButton}\n\t\t\t\t\t\t\tonPress={handleLike}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon={liked ? 'thumb-up' : 'thumb-up-outline'}\n\t\t\t\t\t\t\t\tsize={16}\n\t\t\t\t\t\t\t\ticonColor={liked ? theme.colors.primary : theme.colors.outline}\n\t\t\t\t\t\t\t\tstyle={styles.actionIcon}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Text style={{ color: theme.colors.outline, fontSize: 12 }}>\n\t\t\t\t\t\t\t\t{likeCount > 0 ? likeCount : '点赞'}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</TouchableOpacity>\n\n\t\t\t\t\t\t{item.rcount > 0 && (\n\t\t\t\t\t\t\t<TouchableOpacity\n\t\t\t\t\t\t\t\tstyle={styles.actionButton}\n\t\t\t\t\t\t\t\tonPress={() => onReplyPress?.(item)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='comment-outline'\n\t\t\t\t\t\t\t\t\tsize={16}\n\t\t\t\t\t\t\t\t\ticonColor={theme.colors.outline}\n\t\t\t\t\t\t\t\t\tstyle={styles.actionIcon}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Text style={{ color: theme.colors.outline, fontSize: 12 }}>\n\t\t\t\t\t\t\t\t\t{item.rcount}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</TouchableOpacity>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{item.replies && item.replies.length > 0 && (\n\t\t\t\t\t\t<TouchableOpacity onPress={() => onReplyPress?.(item)}>\n\t\t\t\t\t\t\t<SquircleView\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.repliesPreview,\n\t\t\t\t\t\t\t\t\t{ backgroundColor: theme.colors.surfaceVariant },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{item.replies.slice(0, 3).map((reply) => (\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tkey={reply.rpid}\n\t\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\tstyles.replyPreviewText,\n\t\t\t\t\t\t\t\t\t\t\t{ color: theme.colors.onSurfaceVariant },\n\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Text style={{ fontWeight: 'bold' }}>\n\t\t\t\t\t\t\t\t\t\t\t{reply.member.uname}:{' '}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t{reply.content.message}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t{item.rcount > 3 && (\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\tstyles.viewMoreText,\n\t\t\t\t\t\t\t\t\t\t\t{ color: theme.colors.primary },\n\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t查看全部 {item.rcount} 条回复\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</SquircleView>\n\t\t\t\t\t\t</TouchableOpacity>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflexDirection: 'row',\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 12,\n\t},\n\tavatar: {\n\t\twidth: 40,\n\t\theight: 40,\n\t\tborderRadius: 20,\n\t\tmarginRight: 12,\n\t},\n\tcontentContainer: {\n\t\tflex: 1,\n\t},\n\theader: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\talignItems: 'center',\n\t\tmarginBottom: 4,\n\t},\n\tusername: {\n\t\tfontSize: 14,\n\t\tfontWeight: 'bold',\n\t\tflex: 1,\n\t\tmarginRight: 8,\n\t},\n\ttime: {\n\t\tfontSize: 12,\n\t},\n\tmessage: {\n\t\tfontSize: 15,\n\t\tlineHeight: 22,\n\t\tmarginBottom: 8,\n\t},\n\timagesContainer: {\n\t\tflexDirection: 'row',\n\t\tflexWrap: 'wrap',\n\t\tgap: 8,\n\t\tmarginBottom: 8,\n\t},\n\tcommentImage: {\n\t\twidth: 100,\n\t\theight: 100,\n\t\tborderRadius: 0,\n\t\tbackgroundColor: '#f0f0f0',\n\t\toverflow: 'hidden',\n\t},\n\tcommentImageInner: {\n\t\twidth: 100,\n\t\theight: 100,\n\t},\n\tactions: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tgap: 16,\n\t},\n\tactionButton: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tactionIcon: {\n\t\tmargin: 0,\n\t\tmarginRight: 0,\n\t},\n\trepliesPreview: {\n\t\tmarginTop: 8,\n\t\tpadding: 8,\n\t\tborderRadius: 12,\n\t\toverflow: 'hidden',\n\t},\n\treplyPreviewText: {\n\t\tfontSize: 13,\n\t\tmarginBottom: 4,\n\t},\n\tviewMoreText: {\n\t\tfontSize: 13,\n\t\tmarginTop: 4,\n\t\tfontWeight: 'bold',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/downloads/DownloadHeader.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n\ninterface DownloadHeaderProps {\n\ttaskCount: number\n\tretryableCount: number\n\tonRetryAll: () => void\n\tonClearAll: () => void\n}\n\n/**\n * 下载页面的操作栏，显示任务总数、全部开始和清除按钮。\n */\nexport default function DownloadHeader({\n\ttaskCount,\n\tretryableCount,\n\tonRetryAll,\n\tonClearAll,\n}: DownloadHeaderProps) {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[styles.container, { borderBottomColor: colors.outlineVariant }]}\n\t\t>\n\t\t\t<Text\n\t\t\t\tvariant='bodyMedium'\n\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t>\n\t\t\t\t总共 {taskCount} 个任务\n\t\t\t</Text>\n\t\t\t<View style={styles.buttonContainer}>\n\t\t\t\t<Button\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tonPress={onRetryAll}\n\t\t\t\t\tdisabled={retryableCount === 0}\n\t\t\t\t>\n\t\t\t\t\t重试失败\n\t\t\t\t</Button>\n\t\t\t\t<Button\n\t\t\t\t\tmode='outlined'\n\t\t\t\t\tonPress={onClearAll}\n\t\t\t\t\tdisabled={taskCount === 0}\n\t\t\t\t>\n\t\t\t\t\t全部清除\n\t\t\t\t</Button>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 8,\n\t\tborderBottomWidth: 1,\n\t},\n\tbuttonContainer: {\n\t\tflexDirection: 'row',\n\t\tgap: 8,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/downloads/DownloadTaskItem.tsx",
    "content": "import { DownloadState, Orpheus, type DownloadTask } from '@bbplayer/orpheus'\nimport { useRecyclingState } from '@shopify/flash-list'\nimport { memo, useEffect, useLayoutEffect, useMemo, useRef } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Icon, Surface, Text, useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedStyle,\n\tuseSharedValue,\n} from 'react-native-reanimated'\n\nimport IconButton from '@/components/common/IconButton'\nimport {\n\teventListner,\n\ttype ProgressEvent,\n} from '@/hooks/stores/useDownloadManagerStore'\nimport { toastAndLogError } from '@/utils/error-handling'\n\nconst canRetryDownloadTask = (task: DownloadTask) =>\n\t!!task.track &&\n\t(task.state === DownloadState.FAILED || task.state === DownloadState.STOPPED)\n\nconst DownloadTaskItem = memo(function DownloadTaskItem({\n\tinitTask,\n}: {\n\tinitTask: DownloadTask\n}) {\n\tconst { colors } = useTheme()\n\tconst [task, setTask] = useRecyclingState<DownloadTask>(initTask, [\n\t\tinitTask.id,\n\t])\n\tconst sharedProgress = useSharedValue(0)\n\tconst progressBackgroundWidth = useSharedValue(0)\n\tconst containerRef = useRef<View>(null)\n\tconst retryable = canRetryDownloadTask(task)\n\tconst retryTrack = task.track\n\tconst retryState = task.state\n\n\tuseEffect(() => {\n\t\tconst handler = (e: ProgressEvent['progress:uniqueKey']) => {\n\t\t\tsharedProgress.value = e.percent\n\t\t\tif (e.state !== task.state) {\n\t\t\t\tsetTask((task) => ({ ...task, state: e.state }))\n\t\t\t}\n\t\t}\n\t\teventListner.on(`progress:${task.id}`, handler)\n\n\t\treturn () => {\n\t\t\teventListner.off(`progress:${task.id}`, handler)\n\t\t}\n\t}, [task.id, sharedProgress, task.state, setTask])\n\n\tuseLayoutEffect(() => {\n\t\tif (!containerRef.current) return\n\t\tcontainerRef.current.measure((_x, _y, width) => {\n\t\t\tprogressBackgroundWidth.value = width\n\t\t})\n\t}, [progressBackgroundWidth])\n\n\tuseEffect(() => {\n\t\t// 只清除当前任务的进度，而不清除 progressBackgroundWidth\n\t\tsharedProgress.set(0)\n\t}, [sharedProgress, task.id])\n\n\tconst progressBackgroundAnimatedStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\ttransform: [\n\t\t\t\t{\n\t\t\t\t\ttranslateX:\n\t\t\t\t\t\t(sharedProgress.value - 1) * progressBackgroundWidth.value,\n\t\t\t\t},\n\t\t\t],\n\t\t}\n\t})\n\n\tconst getStatusText = () => {\n\t\tswitch (task.state) {\n\t\t\tcase DownloadState.QUEUED:\n\t\t\t\treturn '等待下载...'\n\t\t\tcase DownloadState.DOWNLOADING:\n\t\t\t\treturn '正在下载...'\n\t\t\tcase DownloadState.FAILED:\n\t\t\t\treturn '下载失败'\n\t\t\tcase DownloadState.STOPPED:\n\t\t\t\treturn '已停止'\n\t\t\tcase DownloadState.REMOVING:\n\t\t\t\treturn '正在删除...'\n\t\t\tcase DownloadState.RESTARTING:\n\t\t\t\treturn '正在重试...'\n\t\t\tcase DownloadState.COMPLETED:\n\t\t\t\treturn '下载完成'\n\t\t\tdefault:\n\t\t\t\treturn '未知状态'\n\t\t}\n\t}\n\n\tconst icons = useMemo(() => {\n\t\tlet icon = null\n\t\tswitch (task.state) {\n\t\t\tcase DownloadState.QUEUED:\n\t\t\t\ticon = (\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource='human-queue'\n\t\t\t\t\t\tsize={24}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t\tcase DownloadState.DOWNLOADING:\n\t\t\t\ticon = (\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource='progress-download'\n\t\t\t\t\t\tsize={24}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t\tcase DownloadState.FAILED:\n\t\t\t\ticon = (\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource='close-circle-outline'\n\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\tcolor={colors.error}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t\tcase DownloadState.COMPLETED:\n\t\t\t\ticon = (\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource='check-circle-outline'\n\t\t\t\t\t\tsize={24}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t\tdefault:\n\t\t\t\ticon = (\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource='help-circle-outline'\n\t\t\t\t\t\tsize={24}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t}\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<View style={styles.iconsContainer}>\n\t\t\t\t\t{retryable && (\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon='reload'\n\t\t\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\t\t\tif (!retryTrack) return\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tif (retryState === DownloadState.STOPPED) {\n\t\t\t\t\t\t\t\t\t\tawait Orpheus.resumeDownload(task.id)\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\tawait Orpheus.retryDownload(retryTrack)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\t\ttoastAndLogError(\n\t\t\t\t\t\t\t\t\t\t'重新下载失败',\n\t\t\t\t\t\t\t\t\t\te,\n\t\t\t\t\t\t\t\t\t\t'Features.Downloads.DownloadTaskItem',\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<View>{icon}</View>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\ticon='close'\n\t\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tawait Orpheus.removeDownload(task.id)\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\ttoastAndLogError(\n\t\t\t\t\t\t\t\t\t'删除任务失败',\n\t\t\t\t\t\t\t\t\te,\n\t\t\t\t\t\t\t\t\t'Features.Downloads.DownloadTaskItem',\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</>\n\t\t)\n\t}, [colors.error, retryState, retryTrack, retryable, task.id, task.state])\n\n\treturn (\n\t\t<>\n\t\t\t<Surface\n\t\t\t\tref={containerRef}\n\t\t\t\tstyle={styles.surface}\n\t\t\t\televation={0}\n\t\t\t>\n\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t<View style={styles.textContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{task.track?.title ?? '未知任务'}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.statusContainer}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{getStatusText()}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t<View style={styles.iconsOuterContainer}>{icons}</View>\n\t\t\t\t</View>\n\t\t\t</Surface>\n\t\t\t<Animated.View\n\t\t\t\tstyle={[\n\t\t\t\t\tprogressBackgroundAnimatedStyle,\n\t\t\t\t\tstyles.progressBackground,\n\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t]}\n\t\t\t></Animated.View>\n\t\t</>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tsurface: {\n\t\tborderRadius: 8,\n\t\tbackgroundColor: 'transparent',\n\t\tmarginVertical: 4,\n\t\tmarginHorizontal: 8,\n\t\tposition: 'relative',\n\t\twidth: '100%',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 8,\n\t},\n\ttextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tmarginRight: 4,\n\t\tjustifyContent: 'center',\n\t},\n\tstatusContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginTop: 2,\n\t},\n\ticonsOuterContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'flex-end',\n\t},\n\ticonsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tprogressBackground: {\n\t\tposition: 'absolute',\n\t\ttop: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t\tbottom: 0,\n\t\tzIndex: -100,\n\t\twidth: '100%',\n\t},\n})\n\nexport default DownloadTaskItem\n"
  },
  {
    "path": "apps/mobile/src/features/history/HistoryListItem.tsx",
    "content": "import { memo } from 'react'\nimport { useColorScheme, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport useIsCurrentTrack from '@/hooks/player/useIsCurrentTrack'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport { LIST_ITEM_COVER_SIZE } from '@/theme/dimensions'\nimport type { Track } from '@/types/core/media'\nimport { addToQueue } from '@/utils/player'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\ninterface HistoryListItemProps {\n\titem: {\n\t\ttrack: Track\n\t\tplayCount: number\n\t}\n\tindex: number\n}\n\nexport const HistoryListItem = memo(function HistoryListItem({\n\titem,\n\tindex,\n}: HistoryListItemProps) {\n\tconst { colors } = useTheme()\n\tconst dark = useColorScheme() === 'dark'\n\tconst isCurrentTrack = useIsCurrentTrack(item.track.uniqueKey)\n\n\treturn (\n\t\t<RectButton\n\t\t\tstyle={{\n\t\t\t\tbackgroundColor: isCurrentTrack\n\t\t\t\t\t? dark\n\t\t\t\t\t\t? 'rgba(255, 255, 255, 0.12)'\n\t\t\t\t\t\t: 'rgba(0, 0, 0, 0.12)'\n\t\t\t\t\t: 'transparent',\n\t\t\t\tpaddingVertical: 4,\n\t\t\t\tpaddingHorizontal: 8,\n\t\t\t}}\n\t\t\tonPress={() => {\n\t\t\t\tif (isCurrentTrack) return\n\t\t\t\tvoid addToQueue({\n\t\t\t\t\ttracks: [item.track],\n\t\t\t\t\tclearQueue: false,\n\t\t\t\t\tplayNow: true,\n\t\t\t\t\tplayNext: false,\n\t\t\t\t\tstartFromKey: item.track.uniqueKey,\n\t\t\t\t})\n\t\t\t}}\n\t\t>\n\t\t\t<View\n\t\t\t\tstyle={{\n\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\talignItems: 'center',\n\t\t\t\t\tpaddingHorizontal: 8,\n\t\t\t\t\tpaddingVertical: 6,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<View\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\twidth: 28,\n\t\t\t\t\t\tmarginRight: 8,\n\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\n\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\tid={item.track.uniqueKey}\n\t\t\t\t\ttitle={item.track.title}\n\t\t\t\t\tcover={resolveTrackCover(item.track.uniqueKey, item.track.coverUrl)}\n\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t/>\n\n\t\t\t\t<View style={{ marginLeft: 12, flex: 1, marginRight: 4 }}>\n\t\t\t\t\t<Text variant='bodySmall'>{item.track.title}</Text>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\tmarginTop: 2,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.track.artist && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{item.track.artist.name ?? '未知'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tmarginHorizontal: 4,\n\t\t\t\t\t\t\t\t\t\tcolor: colors.onSurfaceVariant,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{formatDurationToHHMMSS(item.track.duration)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={{ alignItems: 'flex-end' }}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={{ color: colors.primary, fontWeight: 'bold' }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.playCount}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t>\n\t\t\t\t\t\t次播放\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</RectButton>\n\t)\n})\n"
  },
  {
    "path": "apps/mobile/src/features/home/SearchSuggestions.tsx",
    "content": "import { useCallback, useEffect, useMemo } from 'react'\nimport {\n\tFlatList,\n\tKeyboard,\n\tStyleSheet,\n\tuseWindowDimensions,\n\tView,\n} from 'react-native'\nimport { useBottomTabBarHeight } from 'react-native-bottom-tabs'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Chip, Divider, IconButton, Text, useTheme } from 'react-native-paper'\nimport type { AnimatedRef } from 'react-native-reanimated'\nimport Animated, {\n\tEasing,\n\tExtrapolation,\n\tinterpolate,\n\tmeasure,\n\tuseAnimatedStyle,\n\tuseDerivedValue,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\nimport { scheduleOnUI } from 'react-native-worklets'\n\nimport { useSearchSuggestions } from '@/hooks/queries/bilibili/search'\nimport type { BilibiliSearchSuggestionItem } from '@/types/apis/bilibili'\n\nexport interface SearchSuggestionsProps {\n\tquery: string\n\tvisible: boolean\n\tsearchBarRef: AnimatedRef<View>\n\tsearchHistory?: SearchHistoryItem[]\n\tonSuggestionPress: (q: string) => void\n\tonClearHistory?: () => void\n\tonRemoveHistoryItem?: (id: string) => void\n}\n\nexport interface SearchHistoryItem {\n\tid: string\n\ttext: string\n\ttimestamp: number\n}\n\n/**\n * 将带有 <em>...</em> 的字符串解析成若干段：\n * - 普通段 { text, emphasized: false }\n * - 强调段 { text, emphasized: true }\n */\nfunction parseEmTags(text: string | undefined) {\n\tconst s = String(text ?? '')\n\tconst regex = /<em[^>]*>(.*?)<\\/em>/gi\n\tconst segments: { text: string; emphasized: boolean }[] = []\n\tlet lastIndex = 0\n\tlet match: RegExpExecArray | null\n\twhile ((match = regex.exec(s)) !== null) {\n\t\tif (match.index > lastIndex) {\n\t\t\tsegments.push({\n\t\t\t\ttext: s.slice(lastIndex, match.index),\n\t\t\t\temphasized: false,\n\t\t\t})\n\t\t}\n\t\tsegments.push({ text: match[1], emphasized: true })\n\t\tlastIndex = regex.lastIndex\n\t}\n\tif (lastIndex < s.length) {\n\t\tsegments.push({ text: s.slice(lastIndex), emphasized: false })\n\t}\n\tif (segments.length === 0) return [{ text: s, emphasized: false }]\n\treturn segments\n}\n\n// 搜索建议组件的一些边距\nconst MARGIN_HORIZONTAL = 16\nconst MARGIN_TOP = 12\nconst MARGIN_BOTTOM = 12\n\nexport default function SearchSuggestions({\n\tquery,\n\tvisible,\n\tsearchBarRef,\n\tsearchHistory,\n\tonSuggestionPress,\n\tonClearHistory,\n\tonRemoveHistoryItem,\n}: SearchSuggestionsProps) {\n\tconst { colors } = useTheme()\n\tconst dimensions = useWindowDimensions()\n\tconst windowHeight = dimensions.height\n\tconst windowWidth = dimensions.width\n\tconst insets = useSafeAreaInsets()\n\tconst { data: items } = useSearchSuggestions(query)\n\tconst parsedItems = useMemo(() => {\n\t\treturn (\n\t\t\titems?.map((item) => ({\n\t\t\t\t...item,\n\t\t\t\t_segments: parseEmTags(item.name),\n\t\t\t})) ?? []\n\t\t)\n\t}, [items])\n\tconst tabBarHeight = useBottomTabBarHeight()\n\n\tconst visibleShared = useSharedValue(0)\n\tconst position = useDerivedValue(() => {\n\t\tconst layout = measure(searchBarRef)\n\t\tconst left = layout?.pageX ?? layout?.x ?? MARGIN_HORIZONTAL\n\t\tconst top = (layout?.y ?? 0) + (layout?.height ?? 0) + MARGIN_TOP\n\t\tconst width = layout?.width ?? windowWidth - MARGIN_HORIZONTAL * 2\n\n\t\treturn { left, top, width }\n\t})\n\tconst tabBarHeightShared = useSharedValue(tabBarHeight)\n\n\tuseEffect(() => {\n\t\tscheduleOnUI(\n\t\t\t(visible: boolean, tabBarHeight: number) => {\n\t\t\t\tvisibleShared.value = visible ? 1 : 0\n\t\t\t\ttabBarHeightShared.value = tabBarHeight\n\t\t\t},\n\t\t\tvisible,\n\t\t\ttabBarHeight,\n\t\t)\n\t}, [tabBarHeight, tabBarHeightShared, visible, visibleShared])\n\n\tconst targetHeight = useDerivedValue(() => {\n\t\tconst raw =\n\t\t\twindowHeight -\n\t\t\ttabBarHeightShared.value -\n\t\t\tMARGIN_BOTTOM -\n\t\t\tMARGIN_TOP -\n\t\t\tposition.value.top -\n\t\t\tinsets.bottom -\n\t\t\tinsets.top\n\n\t\tconst maxHeight = windowHeight * 0.4\n\t\tconst final = Math.max(0, Math.min(Math.round(raw), maxHeight))\n\t\treturn visibleShared.value ? final : 0\n\t})\n\n\tconst height = useDerivedValue(() => {\n\t\treturn withTiming(targetHeight.value, {\n\t\t\tduration: 200,\n\t\t\teasing: Easing.out(Easing.quad),\n\t\t})\n\t})\n\n\tconst aStyle = useAnimatedStyle(() => {\n\t\tconst h = height.value\n\t\tconst opacity =\n\t\t\th > 0 ? interpolate(h, [0, h], [0, 1], Extrapolation.CLAMP) : 0\n\t\tconst translateY = interpolate(h, [0, h], [-8, 0], Extrapolation.CLAMP)\n\t\treturn {\n\t\t\theight: h,\n\t\t\topacity,\n\t\t\ttransform: [{ translateY }],\n\t\t\tleft: position.value.left,\n\t\t\ttop: position.value.top,\n\t\t\twidth: position.value.width,\n\t\t}\n\t})\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliSearchSuggestionItem) => item.name,\n\t\t[],\n\t)\n\n\tconst renderItem = useCallback(\n\t\t({\n\t\t\titem,\n\t\t\tindex,\n\t\t}: {\n\t\t\titem: BilibiliSearchSuggestionItem & {\n\t\t\t\t_segments?: { text: string; emphasized: boolean }[]\n\t\t\t}\n\t\t\tindex: number\n\t\t}) => {\n\t\t\treturn (\n\t\t\t\t<RectButton\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tKeyboard.dismiss()\n\t\t\t\t\t\tonSuggestionPress(item.value)\n\t\t\t\t\t}}\n\t\t\t\t\tstyle={[styles.itemButton, { backgroundColor: colors.surface }]}\n\t\t\t\t\ttestID={`search-suggestion-${index}`}\n\t\t\t\t>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\tstyle={{ color: colors.onSurface }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{(item._segments ?? [{ text: item.value, emphasized: false }]).map(\n\t\t\t\t\t\t\t(seg, i) => (\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.itemText,\n\t\t\t\t\t\t\t\t\t\tseg.emphasized && { color: colors.primary },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{seg.text}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Text>\n\t\t\t\t</RectButton>\n\t\t\t)\n\t\t},\n\t\t[colors.onSurface, colors.primary, colors.surface, onSuggestionPress],\n\t)\n\n\treturn (\n\t\t<Animated.View\n\t\t\tpointerEvents={visible ? 'auto' : 'none'}\n\t\t\tstyle={[styles.container, { backgroundColor: colors.surface }, aStyle]}\n\t\t>\n\t\t\t<View style={styles.listContainer}>\n\t\t\t\t{query.trim().length === 0 ? (\n\t\t\t\t\t<View style={styles.historySection}>\n\t\t\t\t\t\t<View style={styles.historyHeader}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\tstyle={styles.historyTitle}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t最近搜索\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t{searchHistory && searchHistory.length > 0 && onClearHistory && (\n\t\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\t\ticon='trash-can-outline'\n\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\tonPress={onClearHistory}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={styles.historyChipsContainer}>\n\t\t\t\t\t\t\t{searchHistory && searchHistory.length > 0 ? (\n\t\t\t\t\t\t\t\tsearchHistory.map((item) => (\n\t\t\t\t\t\t\t\t\t<Chip\n\t\t\t\t\t\t\t\t\t\tkey={item.id}\n\t\t\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\t\t\tKeyboard.dismiss()\n\t\t\t\t\t\t\t\t\t\t\tonSuggestionPress(item.text)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tonLongPress={() => onRemoveHistoryItem?.(item.id)}\n\t\t\t\t\t\t\t\t\t\tstyle={styles.chip}\n\t\t\t\t\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{item.text}\n\t\t\t\t\t\t\t\t\t</Chip>\n\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.noHistoryText,\n\t\t\t\t\t\t\t\t\t\t{ color: colors.onSurfaceVariant },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t暂无搜索历史\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<FlatList\n\t\t\t\t\t\tdata={parsedItems ?? []}\n\t\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\t\tkeyboardShouldPersistTaps='handled'\n\t\t\t\t\t\trenderItem={renderItem}\n\t\t\t\t\t\tItemSeparatorComponent={() => <Divider />}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</View>\n\t\t</Animated.View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tposition: 'absolute',\n\t\tzIndex: 9999,\n\t\tborderRadius: 12,\n\t\toverflow: 'hidden',\n\t\tshadowColor: '#000',\n\t\tshadowOpacity: 0.08,\n\t\tshadowRadius: 10,\n\t\televation: 6,\n\t},\n\tlistContainer: {\n\t\tflex: 1,\n\t},\n\titemButton: {\n\t\tpaddingVertical: 12,\n\t\tpaddingHorizontal: 14,\n\t},\n\titemText: {\n\t\tfontWeight: 'bold',\n\t},\n\thistorySection: {\n\t\tflex: 1,\n\t\tpaddingHorizontal: 16,\n\t\tpaddingTop: 12,\n\t},\n\thistoryHeader: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginBottom: 8,\n\t},\n\thistoryTitle: {\n\t\tfontWeight: 'bold',\n\t},\n\thistoryChipsContainer: {\n\t\tflexDirection: 'row',\n\t\tflexWrap: 'wrap',\n\t},\n\tchip: {\n\t\tmarginRight: 8,\n\t\tmarginBottom: 8,\n\t},\n\tnoHistoryText: {\n\t\tpaddingVertical: 16,\n\t\ttextAlign: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/library/collection/CollectionList.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { ActivityIndicator, Text, useTheme } from 'react-native-paper'\n\nimport { DataFetchingError } from '@/features/library/shared/DataFetchingError'\nimport TabDisable from '@/features/library/shared/TabDisabled'\nimport { CollectionListSkeleton } from '@/features/library/skeletons/LibraryTabSkeleton'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useInfiniteCollectionsList } from '@/hooks/queries/bilibili/favorite'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport type { BilibiliCollection } from '@/types/apis/bilibili'\n\nimport CollectionListItem from './CollectionListItem'\n\nconst renderCollectionItem = ({ item }: { item: BilibiliCollection }) => (\n\t<CollectionListItem item={item} />\n)\n\nconst CollectionListComponent = memo(() => {\n\tconst { colors } = useTheme()\n\tconst haveTrack = useCurrentTrack()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst enable = useAppStore((state) => state.hasBilibiliCookie())\n\n\tconst { data: userInfo } = usePersonalInformation()\n\tconst {\n\t\tdata: collections,\n\t\tisPending: collectionsIsPending,\n\t\tisError: collectionsIsError,\n\t\tisRefetching: collectionsIsRefetching,\n\t\trefetch,\n\t\thasNextPage,\n\t\tfetchNextPage,\n\t} = useInfiniteCollectionsList(Number(userInfo?.mid))\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliCollection) => item.id.toString(),\n\t\t[],\n\t)\n\n\tconst onRefresh = async () => {\n\t\tsetRefreshing(true)\n\t\tawait refetch()\n\t\tsetRefreshing(false)\n\t}\n\n\tif (!enable) {\n\t\treturn <TabDisable />\n\t}\n\n\tif (collectionsIsPending) {\n\t\treturn <CollectionListSkeleton />\n\t}\n\n\tif (collectionsIsError) {\n\t\treturn (\n\t\t\t<DataFetchingError\n\t\t\t\ttext='加载失败'\n\t\t\t\tonRetry={() => onRefresh()}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\tstyle={styles.headerTitle}\n\t\t\t\t>\n\t\t\t\t\t我的合集/收藏夹追更\n\t\t\t\t</Text>\n\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t{collections.pages[0]?.count ?? 0}\n\t\t\t\t\t{'\\u2009'}个追更\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t\t<FlashList\n\t\t\t\tdata={collections.pages.flatMap((page) => page.list)}\n\t\t\t\trenderItem={renderCollectionItem}\n\t\t\t\trefreshControl={\n\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\trefreshing={refreshing || collectionsIsRefetching}\n\t\t\t\t\t\tonRefresh={onRefresh}\n\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tcontentContainerStyle={{ paddingBottom: haveTrack ? 90 : 10 }}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tonEndReached={hasNextPage ? () => fetchNextPage() : undefined}\n\t\t\t\tListFooterComponent={\n\t\t\t\t\thasNextPage ? (\n\t\t\t\t\t\t<View style={styles.footerLoadingContainer}>\n\t\t\t\t\t\t\t<ActivityIndicator size='small' />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.footerReachedEnd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t•\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t/>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\tmarginHorizontal: 16,\n\t},\n\theaderContainer: {\n\t\tmarginBottom: 8,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\theaderTitle: {\n\t\tfontWeight: 'bold',\n\t},\n\tfooterLoadingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tfooterReachedEnd: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n})\n\nCollectionListComponent.displayName = 'CollectionListComponent'\n\nexport default CollectionListComponent\n"
  },
  {
    "path": "apps/mobile/src/features/library/collection/CollectionListItem.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { memo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Divider, Icon, Text } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport { LIST_ITEM_COVER_SIZE } from '@/theme/dimensions'\nimport type { BilibiliCollection } from '@/types/apis/bilibili'\n\nconst CollectionListItem = memo(({ item }: { item: BilibiliCollection }) => {\n\tconst router = useRouter()\n\n\treturn (\n\t\t<View>\n\t\t\t<RectButton\n\t\t\t\tenabled={item.state !== 1}\n\t\t\t\tonPress={() => {\n\t\t\t\t\tif (item.attr === 0) {\n\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\tpathname: '/playlist/remote/collection/[id]',\n\t\t\t\t\t\t\tparams: { id: String(item.id) },\n\t\t\t\t\t\t})\n\t\t\t\t\t} else {\n\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\tpathname: '/playlist/remote/favorite/[id]',\n\t\t\t\t\t\t\tparams: { id: String(item.id) },\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tstyle={styles.rectButton}\n\t\t\t>\n\t\t\t\t<View>\n\t\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\tid={item.id}\n\t\t\t\t\t\t\tcover={item.cover}\n\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<View style={styles.textContainer}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{item.title}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t{item.state === 0 ? item.upper.name : '已失效'}\n\t\t\t\t\t\t\t\t{'\\u2009'}•{''}\n\t\t\t\t\t\t\t\t{item.media_count}\n\t\t\t\t\t\t\t\t{'\\u2009'}首歌曲\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\tsource='arrow-right'\n\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</RectButton>\n\t\t\t<Divider />\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 8,\n\t\toverflow: 'hidden',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpadding: 8,\n\t},\n\ttextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t},\n\ttitle: {\n\t\tpaddingRight: 8,\n\t},\n})\n\nCollectionListItem.displayName = 'CollectionListItem'\n\nexport default CollectionListItem\n"
  },
  {
    "path": "apps/mobile/src/features/library/favorite/FavoriteFolderList.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { useRouter } from 'expo-router'\nimport { memo, useCallback, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Searchbar, Text, useTheme } from 'react-native-paper'\n\nimport { DataFetchingError } from '@/features/library/shared/DataFetchingError'\nimport TabDisable from '@/features/library/shared/TabDisabled'\nimport { FavoriteFolderListSkeleton } from '@/features/library/skeletons/LibraryTabSkeleton'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useGetFavoritePlaylists } from '@/hooks/queries/bilibili/favorite'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport type { BilibiliPlaylist } from '@/types/apis/bilibili'\n\nimport FavoriteFolderListItem from './FavoriteFolderListItem'\n\nconst renderPlaylistItem = ({ item }: { item: BilibiliPlaylist }) => (\n\t<FavoriteFolderListItem item={item} />\n)\n\nconst FavoriteFolderListComponent = memo(() => {\n\tconst router = useRouter()\n\tconst { colors } = useTheme()\n\tconst haveTrack = useCurrentTrack()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst [query, setQuery] = useState('')\n\tconst enable = useAppStore((state) => state.hasBilibiliCookie())\n\n\tconst { data: userInfo } = usePersonalInformation()\n\tconst {\n\t\tdata: playlists,\n\t\tisPending: playlistsIsPending,\n\t\tisRefetching: playlistsIsRefetching,\n\t\trefetch,\n\t\tisError: playlistsIsError,\n\t} = useGetFavoritePlaylists(userInfo?.mid)\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliPlaylist) => item.id.toString(),\n\t\t[],\n\t)\n\n\tconst onRefresh = async () => {\n\t\tsetRefreshing(true)\n\t\tawait refetch()\n\t\tsetRefreshing(false)\n\t}\n\n\tif (!enable) {\n\t\treturn <TabDisable />\n\t}\n\n\tif (playlistsIsPending) {\n\t\treturn <FavoriteFolderListSkeleton />\n\t}\n\n\tif (playlistsIsError) {\n\t\treturn (\n\t\t\t<DataFetchingError\n\t\t\t\ttext='加载失败'\n\t\t\t\tonRetry={() => onRefresh()}\n\t\t\t/>\n\t\t)\n\t}\n\n\tconst filteredPlaylists = playlists.filter(\n\t\t(item) => !item.title.startsWith('[mp]'),\n\t)\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\tstyle={styles.headerTitle}\n\t\t\t\t>\n\t\t\t\t\t我的收藏夹\n\t\t\t\t</Text>\n\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t{playlists.length ?? 0}&thinsp;个收藏夹\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t\t<Searchbar\n\t\t\t\tplaceholder='搜索我的收藏夹内容'\n\t\t\t\tvalue={query}\n\t\t\t\tmode='bar'\n\t\t\t\tinputStyle={styles.searchInput}\n\t\t\t\tonChangeText={setQuery}\n\t\t\t\tstyle={styles.searchbar}\n\t\t\t\tonSubmitEditing={() => {\n\t\t\t\t\tsetQuery('')\n\t\t\t\t\trouter.push({\n\t\t\t\t\t\tpathname: '/playlist/remote/search-result/fav/[query]',\n\t\t\t\t\t\tparams: { query },\n\t\t\t\t\t})\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<FlashList\n\t\t\t\tcontentContainerStyle={{ paddingBottom: haveTrack ? 90 : 10 }}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tdata={filteredPlaylists}\n\t\t\t\trenderItem={renderPlaylistItem}\n\t\t\t\trefreshControl={\n\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\trefreshing={refreshing || playlistsIsRefetching}\n\t\t\t\t\t\tonRefresh={onRefresh}\n\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tListFooterComponent={\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\tstyle={styles.listFooter}\n\t\t\t\t\t>\n\t\t\t\t\t\t•\n\t\t\t\t\t</Text>\n\t\t\t\t}\n\t\t\t\tListEmptyComponent={<Text style={styles.emptyList}>没有收藏夹</Text>}\n\t\t\t/>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\tmarginHorizontal: 16,\n\t},\n\theaderContainer: {\n\t\tmarginBottom: 8,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\theaderTitle: {\n\t\tfontWeight: 'bold',\n\t},\n\tsearchInput: {\n\t\talignSelf: 'center',\n\t},\n\tsearchbar: {\n\t\tborderRadius: 9999,\n\t\ttextAlign: 'center',\n\t\theight: 45,\n\t\tmarginBottom: 20,\n\t\tmarginTop: 10,\n\t},\n\tlistFooter: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n\temptyList: {\n\t\ttextAlign: 'center',\n\t},\n})\n\nFavoriteFolderListComponent.displayName = 'FavoriteFolderListComponent'\n\nexport default FavoriteFolderListComponent\n"
  },
  {
    "path": "apps/mobile/src/features/library/favorite/FavoriteFolderListItem.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { memo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Divider, Icon, Text } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport { LIST_ITEM_COVER_SIZE } from '@/theme/dimensions'\nimport type { BilibiliPlaylist } from '@/types/apis/bilibili'\n\nconst FavoriteFolderListItem = memo(({ item }: { item: BilibiliPlaylist }) => {\n\tconst router = useRouter()\n\n\treturn (\n\t\t<View>\n\t\t\t<RectButton\n\t\t\t\tonPress={() => {\n\t\t\t\t\trouter.push({\n\t\t\t\t\t\tpathname: '/playlist/remote/favorite/[id]',\n\t\t\t\t\t\tparams: { id: String(item.id) },\n\t\t\t\t\t})\n\t\t\t\t}}\n\t\t\t\tstyle={styles.rectButton}\n\t\t\t\ttestID={`favorite-folder-${item.id}`}\n\t\t\t>\n\t\t\t\t<View>\n\t\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\tid={item.id}\n\t\t\t\t\t\t\tcover={undefined}\n\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<View style={styles.textContainer}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{item.title}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t<Text variant='bodySmall'>{item.media_count}&thinsp;首歌曲</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\tsource='arrow-right'\n\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</RectButton>\n\t\t\t<Divider />\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 8,\n\t\toverflow: 'hidden',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpadding: 8,\n\t},\n\ttextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t},\n})\n\nFavoriteFolderListItem.displayName = 'FavoriteFolderListItem'\n\nexport default FavoriteFolderListItem\n"
  },
  {
    "path": "apps/mobile/src/features/library/local/LocalPlaylistItem.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { memo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Divider, Icon, Text, useTheme } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport { LIST_ITEM_COVER_SIZE } from '@/theme/dimensions'\nimport type { Playlist } from '@/types/core/media'\n\nconst LocalPlaylistItem = memo(\n\t({ item }: { item: Playlist & { isToView?: boolean } }) => {\n\t\tconst router = useRouter()\n\t\tconst { colors } = useTheme()\n\t\tconst isShared = !!item.shareId\n\t\tconst isRemote = item.type !== 'local' && item.type !== 'dynamic'\n\n\t\treturn (\n\t\t\t<View>\n\t\t\t\t<RectButton\n\t\t\t\t\tstyle={styles.rectButton}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\tpathname: item.isToView\n\t\t\t\t\t\t\t\t? '/playlist/remote/toview'\n\t\t\t\t\t\t\t\t: '/playlist/local/[id]',\n\t\t\t\t\t\t\tparams: { id: String(item.id) },\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t\ttestID={`local-playlist-${item.id}`}\n\t\t\t\t>\n\t\t\t\t\t<View>\n\t\t\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\t\tid={item.id}\n\t\t\t\t\t\t\t\tcover={item.coverUrl}\n\t\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<View style={styles.textContainer}>\n\t\t\t\t\t\t\t\t<Text variant='titleMedium'>{item.title}</Text>\n\t\t\t\t\t\t\t\t<View style={styles.subtitleContainer}>\n\t\t\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t\t\t{item.isToView\n\t\t\t\t\t\t\t\t\t\t\t? '与\\u2009B\\u2009站「稍后再看」同步'\n\t\t\t\t\t\t\t\t\t\t\t: `${item.itemCount}\\u2009首歌曲`}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t{isShared && (\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource='account-group'\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\t\t\t\t\t\t\tsize={13}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{!isShared && isRemote && (\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource={'cloud'}\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\t\t\t\t\t\t\tsize={13}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t{item.type === 'dynamic' && (\n\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\tsource='merge'\n\t\t\t\t\t\t\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\t\t\t\t\t\t\tsize={13}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tsource='arrow-right'\n\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t</RectButton>\n\t\t\t\t<Divider />\n\t\t\t</View>\n\t\t)\n\t},\n)\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 8,\n\t\toverflow: 'hidden',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpadding: 8,\n\t},\n\ttextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t},\n\tsubtitleContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'flex-end',\n\t\tgap: 4,\n\t},\n})\n\nLocalPlaylistItem.displayName = 'LocalPlaylistItem'\n\nexport default LocalPlaylistItem\n"
  },
  {
    "path": "apps/mobile/src/features/library/local/LocalPlaylistList.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useDeferredValue, useMemo, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { Menu, Searchbar, Text, useTheme } from 'react-native-paper'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport IconButton from '@/components/common/IconButton'\nimport { DataFetchingError } from '@/features/library/shared/DataFetchingError'\nimport { LocalPlaylistListSkeleton } from '@/features/library/skeletons/LibraryTabSkeleton'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport {\n\tusePlaylistLists,\n\tuseSearchPlaylists,\n} from '@/hooks/queries/db/playlist'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { Playlist } from '@/types/core/media'\n\nimport LocalPlaylistItem from './LocalPlaylistItem'\n\nconst renderPlaylistItem = ({\n\titem,\n}: {\n\titem: Playlist & { isToView?: boolean }\n}) => <LocalPlaylistItem item={item} />\n\nconst LocalPlaylistListComponent = memo(() => {\n\tconst { colors } = useTheme()\n\tconst haveTrack = useCurrentTrack()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst [searchQuery, setSearchQuery] = useState('')\n\tconst [menuVisible, setMenuVisible] = useState(false)\n\tconst deferredSearchQuery = useDeferredValue(searchQuery)\n\tconst openModal = useModalStore((state) => state.open)\n\tconst hasBilibiliCookie = useAppStore((state) => state.hasBilibiliCookie)\n\n\tconst {\n\t\tdata: playlists,\n\t\tisPending: playlistsIsPending,\n\t\tisRefetching: playlistsIsRefetching,\n\t\trefetch,\n\t\tisError: playlistsIsError,\n\t} = usePlaylistLists()\n\n\tconst { data: searchResults } = useSearchPlaylists(deferredSearchQuery, true)\n\n\tconst finalPlaylists = useMemo(() => {\n\t\tif (deferredSearchQuery.trim()) {\n\t\t\treturn searchResults ?? []\n\t\t}\n\n\t\tif (!playlists) return []\n\n\t\tif (!hasBilibiliCookie()) return playlists\n\t\treturn [\n\t\t\t{\n\t\t\t\tid: 1145141919810,\n\t\t\t\ttitle: '稍后再看',\n\t\t\t\tauthor: null,\n\t\t\t\tdescription: null,\n\t\t\t\tcoverUrl: null,\n\t\t\t\titemCount: 0,\n\t\t\t\ttype: 'favorite',\n\t\t\t\tremoteSyncId: null,\n\t\t\t\tlastSyncedAt: null,\n\t\t\t\tcreatedAt: new Date(),\n\t\t\t\tupdatedAt: new Date(),\n\t\t\t\tisToView: true,\n\t\t\t},\n\t\t\t...playlists,\n\t\t] as (Playlist & { isToView?: boolean })[]\n\t}, [hasBilibiliCookie, playlists, deferredSearchQuery, searchResults])\n\n\tconst keyExtractor = useCallback((item: Playlist) => item.id.toString(), [])\n\n\tconst onRefresh = async () => {\n\t\tsetRefreshing(true)\n\t\tawait refetch()\n\t\tsetRefreshing(false)\n\t}\n\n\tif (playlistsIsPending) {\n\t\treturn <LocalPlaylistListSkeleton />\n\t}\n\n\tif (playlistsIsError) {\n\t\treturn (\n\t\t\t<DataFetchingError\n\t\t\t\ttext='加载失败'\n\t\t\t\tonRetry={() => onRefresh()}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\tstyle={styles.headerTitle}\n\t\t\t\t>\n\t\t\t\t\t播放列表\n\t\t\t\t</Text>\n\t\t\t\t<View style={styles.headerActionsContainer}>\n\t\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t\t{playlists.length ?? 0}&thinsp;个播放列表\n\t\t\t\t\t</Text>\n\t\t\t\t\t<FunctionalMenu\n\t\t\t\t\t\tvisible={menuVisible}\n\t\t\t\t\t\tonDismiss={() => setMenuVisible(false)}\n\t\t\t\t\t\tanchor={\n\t\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\t\ticon='plus'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\tonPress={() => setMenuVisible(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tleadingIcon='playlist-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('CreatePlaylist', { redirectToNewPlaylist: true })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='新建播放列表'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tleadingIcon='link-plus'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('InputExternalPlaylistInfo', undefined)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='导入外部歌单'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tleadingIcon='account-group'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('SubscribeToSharedPlaylist', undefined)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='订阅共享歌单'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tleadingIcon='merge'\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\tsetMenuVisible(false)\n\t\t\t\t\t\t\t\topenModal('MergePlaylists', undefined)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle='动态合并歌单'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</FunctionalMenu>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t\t<Searchbar\n\t\t\t\tplaceholder='搜索播放列表'\n\t\t\t\tonChangeText={setSearchQuery}\n\t\t\t\tvalue={searchQuery}\n\t\t\t\tmode='bar'\n\t\t\t\tstyle={styles.searchbar}\n\t\t\t\tinputStyle={styles.searchInput}\n\t\t\t/>\n\t\t\t<View\n\t\t\t\tstyle={{\n\t\t\t\t\tflex: 1,\n\t\t\t\t\topacity: searchQuery !== deferredSearchQuery ? 0.5 : 1,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<FlashList\n\t\t\t\t\tcontentContainerStyle={{ paddingBottom: haveTrack ? 90 : 10 }}\n\t\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\t\tdata={finalPlaylists ?? []}\n\t\t\t\t\trenderItem={renderPlaylistItem}\n\t\t\t\t\trefreshControl={\n\t\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\t\trefreshing={refreshing || playlistsIsRefetching}\n\t\t\t\t\t\t\tonRefresh={onRefresh}\n\t\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\t\tListFooterComponent={\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.listFooter}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t•\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t}\n\t\t\t\t\tListEmptyComponent={\n\t\t\t\t\t\t<Text style={styles.emptyList}>没有播放列表</Text>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\tmarginHorizontal: 16,\n\t},\n\theaderContainer: {\n\t\tmarginBottom: 8,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\theaderTitle: {\n\t\tfontWeight: 'bold',\n\t},\n\theaderActionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tsearchInput: {\n\t\talignSelf: 'center',\n\t},\n\tsearchbar: {\n\t\tborderRadius: 9999,\n\t\ttextAlign: 'center',\n\t\theight: 45,\n\t\tmarginBottom: 20,\n\t\tmarginTop: 10,\n\t},\n\tlistFooter: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n\temptyList: {\n\t\ttextAlign: 'center',\n\t},\n})\n\nLocalPlaylistListComponent.displayName = 'LocalPlaylistListComponent'\n\nexport default LocalPlaylistListComponent\n"
  },
  {
    "path": "apps/mobile/src/features/library/multipage/MultiPageVideosItem.tsx",
    "content": "import { useRouter } from 'expo-router'\nimport { memo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Divider, Icon, Text } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport { LIST_ITEM_COVER_SIZE } from '@/theme/dimensions'\nimport type { BilibiliFavoriteListContent } from '@/types/apis/bilibili'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nconst MultiPageVideosItem = memo(\n\t({ item }: { item: BilibiliFavoriteListContent }) => {\n\t\tconst router = useRouter()\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<View>\n\t\t\t\t\t<RectButton\n\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\t\t\t\t\tparams: { bvid: item.bvid },\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tstyle={styles.rectButton}\n\t\t\t\t\t>\n\t\t\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\t\tid={item.bvid}\n\t\t\t\t\t\t\t\tcover={item.cover}\n\t\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<View style={styles.textContainer}>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{item.title}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t\t{item.upper.name}\n\t\t\t\t\t\t\t\t\t{'\\u2009'}•{''}\n\t\t\t\t\t\t\t\t\t{item.duration ? formatDurationToHHMMSS(item.duration) : ''}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tsource='arrow-right'\n\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</RectButton>\n\t\t\t\t</View>\n\t\t\t\t<Divider />\n\t\t\t</>\n\t\t)\n\t},\n)\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 8,\n\t\toverflow: 'hidden',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpadding: 8,\n\t},\n\ttextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t},\n\ttitle: {\n\t\tpaddingRight: 8,\n\t},\n})\n\nMultiPageVideosItem.displayName = 'MultiPageVideosItem'\n\nexport default MultiPageVideosItem\n"
  },
  {
    "path": "apps/mobile/src/features/library/multipage/MultiPageVideosList.tsx",
    "content": "import { FlashList } from '@shopify/flash-list'\nimport { memo, useCallback, useState } from 'react'\nimport { RefreshControl, StyleSheet, View } from 'react-native'\nimport { ActivityIndicator, Text, useTheme } from 'react-native-paper'\n\nimport { DataFetchingError } from '@/features/library/shared/DataFetchingError'\nimport TabDisable from '@/features/library/shared/TabDisabled'\nimport { LibraryTabSkeleton } from '@/features/library/skeletons/LibraryTabSkeleton'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport {\n\tuseGetFavoritePlaylists,\n\tuseInfiniteFavoriteList,\n} from '@/hooks/queries/bilibili/favorite'\nimport { usePersonalInformation } from '@/hooks/queries/bilibili/user'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport type { BilibiliFavoriteListContent } from '@/types/apis/bilibili'\n\nimport MultiPageVideosItem from './MultiPageVideosItem'\n\nconst renderPlaylistItem = ({\n\titem,\n}: {\n\titem: BilibiliFavoriteListContent\n}) => <MultiPageVideosItem item={item} />\n\nconst MultiPageVideosListComponent = memo(() => {\n\tconst { colors } = useTheme()\n\tconst haveTrack = useCurrentTrack()\n\tconst [refreshing, setRefreshing] = useState(false)\n\tconst enable = useAppStore((state) => state.hasBilibiliCookie())\n\n\tconst { data: userInfo } = usePersonalInformation()\n\tconst {\n\t\tdata: playlists,\n\t\tisPending: playlistsIsPending,\n\t\tisError: playlistsIsError,\n\t\tisRefetching: playlistsIsRefetching,\n\t\trefetch: refetchPlaylists,\n\t} = useGetFavoritePlaylists(userInfo?.mid)\n\tconst {\n\t\tdata: favoriteData,\n\t\tisError: isFavoriteDataError,\n\t\tisPending: isFavoriteDataPending,\n\t\tisRefetching: isFavoriteDataRefetching,\n\t\tfetchNextPage,\n\t\trefetch: refetchFavoriteData,\n\t\thasNextPage,\n\t} = useInfiniteFavoriteList(\n\t\tplaylists?.find((item) => item.title.startsWith('[mp]'))?.id,\n\t)\n\n\tconst keyExtractor = useCallback(\n\t\t(item: BilibiliFavoriteListContent) => item.bvid,\n\t\t[],\n\t)\n\n\tconst onRefresh = async () => {\n\t\tsetRefreshing(true)\n\t\tawait Promise.all([refetchPlaylists(), refetchFavoriteData()])\n\t\tsetRefreshing(false)\n\t}\n\n\tif (!enable) {\n\t\treturn <TabDisable />\n\t}\n\n\tif (playlistsIsPending || isFavoriteDataPending) {\n\t\treturn <LibraryTabSkeleton />\n\t}\n\n\tif (playlistsIsError || isFavoriteDataError) {\n\t\treturn (\n\t\t\t<DataFetchingError\n\t\t\t\ttext='加载失败'\n\t\t\t\tonRetry={() => onRefresh()}\n\t\t\t/>\n\t\t)\n\t}\n\n\tif (!playlists?.find((item) => item.title.startsWith('[mp]'))) {\n\t\treturn (\n\t\t\t<View style={styles.noMpContainer}>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\tstyle={styles.noMpText}\n\t\t\t\t>\n\t\t\t\t\t未找到分&thinsp;P&thinsp;视频收藏夹，请先创建一个收藏夹，并以&thinsp;[mp]&thinsp;开头\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\tstyle={styles.headerTitle}\n\t\t\t\t>\n\t\t\t\t\t分P视频\n\t\t\t\t</Text>\n\t\t\t\t<Text variant='bodyMedium'>\n\t\t\t\t\t{favoriteData.pages[0]?.info?.media_count ?? 0}\n\t\t\t\t\t&thinsp;个分&thinsp;P&thinsp;视频\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t\t<FlashList\n\t\t\t\tcontentContainerStyle={{ paddingBottom: haveTrack ? 90 : 10 }}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tdata={favoriteData.pages.flatMap((page) => page.medias ?? []) ?? []}\n\t\t\t\trenderItem={renderPlaylistItem}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\trefreshControl={\n\t\t\t\t\t<RefreshControl\n\t\t\t\t\t\trefreshing={\n\t\t\t\t\t\t\trefreshing || playlistsIsRefetching || isFavoriteDataRefetching\n\t\t\t\t\t\t}\n\t\t\t\t\t\tonRefresh={onRefresh}\n\t\t\t\t\t\tcolors={[colors.primary]}\n\t\t\t\t\t\tprogressViewOffset={50}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t\tListEmptyComponent={\n\t\t\t\t\t<Text style={styles.emptyList}>没有分&thinsp;P&thinsp;视频</Text>\n\t\t\t\t}\n\t\t\t\tonEndReached={hasNextPage ? () => fetchNextPage() : undefined}\n\t\t\t\tListFooterComponent={\n\t\t\t\t\thasNextPage ? (\n\t\t\t\t\t\t<View style={styles.footerLoadingContainer}>\n\t\t\t\t\t\t\t<ActivityIndicator size='small' />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.footerReachedEnd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t•\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t/>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\tmarginHorizontal: 16,\n\t},\n\theaderContainer: {\n\t\tmarginBottom: 8,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\theaderTitle: {\n\t\tfontWeight: 'bold',\n\t},\n\tnoMpContainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tnoMpText: {\n\t\ttextAlign: 'center',\n\t},\n\temptyList: {\n\t\ttextAlign: 'center',\n\t},\n\tfooterLoadingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tfooterReachedEnd: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n})\n\nMultiPageVideosListComponent.displayName = 'MultiPageVideosListComponent'\n\nexport default MultiPageVideosListComponent\n"
  },
  {
    "path": "apps/mobile/src/features/library/shared/DataFetchingError.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n\ninterface DataFetchingErrorProps {\n\ttext?: string\n\tonRetry?: () => void\n}\n\nexport function DataFetchingError({\n\ttext = '加载失败',\n\tonRetry,\n}: DataFetchingErrorProps) {\n\tconst { colors } = useTheme()\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Text\n\t\t\t\tvariant='titleMedium'\n\t\t\t\tstyle={styles.text}\n\t\t\t>\n\t\t\t\t{text}\n\t\t\t</Text>\n\t\t\t{onRetry && (\n\t\t\t\t<Button\n\t\t\t\t\tonPress={onRetry}\n\t\t\t\t\tmode='contained'\n\t\t\t\t>\n\t\t\t\t\t重试\n\t\t\t\t</Button>\n\t\t\t)}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\ttext: {\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/library/shared/TabDisabled.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\n\nexport default function TabDisable() {\n\tconst { colors } = useTheme()\n\tconst openModal = useModalStore((state) => state.open)\n\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Text\n\t\t\t\tvariant='titleMedium'\n\t\t\t\tstyle={styles.text}\n\t\t\t>\n\t\t\t\t登录 bilibili 账号后才能查看合集\n\t\t\t</Text>\n\t\t\t<Button\n\t\t\t\tmode='contained'\n\t\t\t\tonPress={() => openModal('QRCodeLogin', undefined)}\n\t\t\t>\n\t\t\t\t登录\n\t\t\t</Button>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tgap: 16,\n\t},\n\ttext: {\n\t\ttextAlign: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/library/skeletons/LibraryTabSkeleton.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Shimmer } from 'react-native-fast-shimmer'\nimport { useTheme } from 'react-native-paper'\n\nimport { LIST_ITEM_COVER_SIZE, SQUIRCLE_RADIUS_RATIO } from '@/theme/dimensions'\n\n/**\n * Generic item skeleton for all library lists\n */\nexport function LibraryListItemSkeleton() {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View style={styles.itemContainer}>\n\t\t\t<View style={styles.itemContent}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.coverSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.itemTextContainer}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.titleSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.subtitleSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.arrowIconSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t\t<View\n\t\t\t\tstyle={[styles.divider, { backgroundColor: colors.surfaceVariant }]}\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nexport function LocalPlaylistListSkeleton() {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View style={styles.listContainer}>\n\t\t\t<View style={styles.listHeaderContainer}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.headerTitleSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.headerActionsContainer}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.headerCountSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.iconButtonSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{Array.from({ length: 8 }, (_, index) => (\n\t\t\t\t<LibraryListItemSkeleton key={index} />\n\t\t\t))}\n\t\t</View>\n\t)\n}\n\nexport function FavoriteFolderListSkeleton() {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View style={styles.listContainer}>\n\t\t\t<View style={styles.listHeaderContainer}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.headerTitleSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant, width: 100 },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.headerCountSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t<View\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.searchBarSkeleton,\n\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<Shimmer />\n\t\t\t</View>\n\n\t\t\t{Array.from({ length: 8 }, (_, index) => (\n\t\t\t\t<LibraryListItemSkeleton key={index} />\n\t\t\t))}\n\t\t</View>\n\t)\n}\n\nexport function CollectionListSkeleton() {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View style={styles.listContainer}>\n\t\t\t<View style={styles.listHeaderContainer}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.headerTitleSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant, width: 150 },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.headerCountSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{Array.from({ length: 8 }, (_, index) => (\n\t\t\t\t<LibraryListItemSkeleton key={index} />\n\t\t\t))}\n\t\t</View>\n\t)\n}\n\n// Default export can act as a fallback or the main entry point if needed.\n// Since existing code imports { LibraryTabSkeleton }, we keep it.\n// We'll map it to LocalPlaylistListSkeleton as a default since it's the first tab.\nexport function LibraryTabSkeleton() {\n\treturn <LocalPlaylistListSkeleton />\n}\n\nconst styles = StyleSheet.create({\n\tlistContainer: {\n\t\tflex: 1,\n\t\tmarginHorizontal: 16,\n\t\tmarginTop: 8, // Gap from top\n\t},\n\tlistHeaderContainer: {\n\t\tmarginBottom: 8,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\theight: 40,\n\t},\n\theaderTitleSkeleton: {\n\t\twidth: 80,\n\t\theight: 24,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t},\n\theaderActionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tgap: 12,\n\t},\n\theaderCountSkeleton: {\n\t\twidth: 80,\n\t\theight: 16,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t},\n\ticonButtonSkeleton: {\n\t\twidth: 28, // IconButton size=20 + padding? Actual IconButton size=20, touch area bigger.\n\t\theight: 28,\n\t\tborderRadius: 14,\n\t\toverflow: 'hidden',\n\t},\n\tsearchBarSkeleton: {\n\t\theight: 45,\n\t\tborderRadius: 22.5, // 9999 in original, effectively pill\n\t\tmarginBottom: 20,\n\t\tmarginTop: 10,\n\t\toverflow: 'hidden',\n\t},\n\titemContainer: {\n\t\tmarginBottom: 1,\n\t},\n\titemContent: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingVertical: 8,\n\t\tpaddingHorizontal: 8,\n\t},\n\tcoverSkeleton: {\n\t\twidth: LIST_ITEM_COVER_SIZE,\n\t\theight: LIST_ITEM_COVER_SIZE,\n\t\tborderRadius: LIST_ITEM_COVER_SIZE * SQUIRCLE_RADIUS_RATIO,\n\t\toverflow: 'hidden',\n\t},\n\titemTextContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tgap: 6,\n\t\tjustifyContent: 'center',\n\t},\n\ttitleSkeleton: {\n\t\theight: 16,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t\twidth: '60%',\n\t},\n\tsubtitleSkeleton: {\n\t\theight: 12,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t\twidth: '40%',\n\t},\n\tarrowIconSkeleton: {\n\t\twidth: 24,\n\t\theight: 24,\n\t\tborderRadius: 12,\n\t\toverflow: 'hidden',\n\t},\n\tdivider: {\n\t\theight: StyleSheet.hairlineWidth,\n\t\tmarginLeft: 68, // cover(48) + padding(8) + margin(12) = 68\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/BGStreamerShader.ts",
    "content": "import type { SkRuntimeEffect } from '@shopify/react-native-skia'\nimport { Skia } from '@shopify/react-native-skia'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { reportErrorToSentry } from '@/utils/log'\n\nconst GLSL_SHADER_SOURCE = `\n  uniform float time;       // 时间\n  uniform vec2 resolution;  // 屏幕分辨率\n  uniform vec4 color1;      // 颜色1 (波谷)\n  uniform vec4 color2;      // 颜色2 (波峰)\n\n  vec4 main(vec2 fragCoord) {\n    vec2 uv = fragCoord.xy / resolution.xy;\n    \n    float wave1 = sin(uv.x * 1.5 + time * 1) * 0.5 + 0.5;\n    float wave2 = sin(uv.y * 1.0 - time * 0.5) * 0.5 + 0.5;\n    \n    float combinedWaves = wave1 + wave2 + time * 0.2;\n    \n    float blendFactor = sin(combinedWaves * 3.14159);\n    \n    blendFactor = pow(blendFactor * 0.5 + 0.5, 2.0); \n    \n    vec4 finalColor = mix(color1, color2, blendFactor);\n    \n    return finalColor;\n  }\n`\n\nlet backgroundStreamerShader: SkRuntimeEffect | null = null\n\ntry {\n\tbackgroundStreamerShader = Skia.RuntimeEffect.Make(GLSL_SHADER_SOURCE)\n} catch (e) {\n\ttoastAndLogError(\n\t\t'无法加载流光效果着色器，已自动回退到渐变模式',\n\t\te,\n\t\t'Features.Player.BGStreamerShader',\n\t)\n\treportErrorToSentry(\n\t\te,\n\t\t'无法加载流光效果着色器',\n\t\t'Features.Player.BGStreamerShader',\n\t)\n\tuseAppStore.getState().setSettings({\n\t\tplayerBackgroundStyle: 'gradient',\n\t})\n}\n\nexport default backgroundStreamerShader\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/LyricsControlOverlay.tsx",
    "content": "import { LinearGradient } from 'expo-linear-gradient'\nimport { memo, useCallback, useEffect, useRef } from 'react'\nimport { Dimensions, StyleSheet, View } from 'react-native'\nimport {\n\tGesture,\n\tGestureDetector,\n\tRectButton,\n} from 'react-native-gesture-handler'\nimport { Icon, useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedReaction,\n\tuseAnimatedStyle,\n\tuseSharedValue,\n\twithTiming,\n\tinterpolate,\n\ttype SharedValue,\n} from 'react-native-reanimated'\nimport { scheduleOnRN } from 'react-native-worklets'\n\nimport { MainPlaybackControls } from '@/features/player/components/PlayerControls'\nimport { PlayerSlider } from '@/features/player/components/PlayerSlider'\n\nconst { height: windowHeight } = Dimensions.get('window')\nconst OVERLAY_HEIGHT = windowHeight * 0.4\nconst AUTO_HIDE_DELAY = 3000\n\ninterface LyricsControlOverlayProps {\n\tscrollDirection: SharedValue<'up' | 'down' | 'idle'>\n\toffsetMenuVisible: boolean\n\tonOpenActionMenu: (anchor: {\n\t\tx: number\n\t\ty: number\n\t\twidth: number\n\t\theight: number\n\t}) => void\n\tonControlsVisibilityChange?: (visible: boolean) => void\n}\n\nexport const LyricsControlOverlay = memo(function LyricsControlOverlay({\n\tscrollDirection,\n\toffsetMenuVisible,\n\tonOpenActionMenu,\n\tonControlsVisibilityChange,\n}: LyricsControlOverlayProps) {\n\tconst { colors, dark } = useTheme()\n\tconst actionButtonRef = useRef<View>(null)\n\tconst controlsOpacity = useSharedValue(0)\n\tconst hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n\tconst clearHideTimer = useCallback(() => {\n\t\tif (hideTimerRef.current) {\n\t\t\tclearTimeout(hideTimerRef.current)\n\t\t\thideTimerRef.current = null\n\t\t}\n\t}, [])\n\n\tconst startHideTimer = useCallback(() => {\n\t\tclearHideTimer()\n\t\thideTimerRef.current = setTimeout(() => {\n\t\t\tcontrolsOpacity.set(withTiming(0, { duration: 300 }))\n\t\t}, AUTO_HIDE_DELAY)\n\t}, [clearHideTimer, controlsOpacity])\n\n\tconst showControls = useCallback(() => {\n\t\tcontrolsOpacity.set(withTiming(1, { duration: 200 }))\n\t\tonControlsVisibilityChange?.(true)\n\t\tstartHideTimer()\n\t}, [controlsOpacity, startHideTimer, onControlsVisibilityChange])\n\n\tconst hideControls = useCallback(() => {\n\t\tclearHideTimer()\n\t\tcontrolsOpacity.set(withTiming(0, { duration: 200 }))\n\t\tonControlsVisibilityChange?.(false)\n\t}, [clearHideTimer, controlsOpacity, onControlsVisibilityChange])\n\n\tconst resetHideTimer = useCallback(() => {\n\t\tstartHideTimer()\n\t}, [startHideTimer])\n\n\t// 监听滚动方向变化\n\tuseAnimatedReaction(\n\t\t() => scrollDirection.value,\n\t\t(current, previous) => {\n\t\t\tif (current === previous) return\n\t\t\tif (current === 'up') {\n\t\t\t\t// 上滑 - 隐藏控件\n\t\t\t\tscheduleOnRN(hideControls)\n\t\t\t} else if (current === 'down') {\n\t\t\t\t// 下滑 - 显示控件\n\t\t\t\tscheduleOnRN(showControls)\n\t\t\t}\n\t\t},\n\t)\n\n\t// 清理定时器\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tclearHideTimer()\n\t\t}\n\t}, [clearHideTimer])\n\n\t// 点击手势切换控件显示\n\tconst tapGesture = Gesture.Tap().onEnd(() => {\n\t\t'worklet'\n\t\tif (controlsOpacity.value < 0.5) {\n\t\t\tscheduleOnRN(showControls)\n\t\t} else {\n\t\t\tscheduleOnRN(resetHideTimer)\n\t\t}\n\t})\n\n\t// 控件交互时重置隐藏定时器\n\tconst handleInteraction = useCallback(() => {\n\t\tresetHideTimer()\n\t}, [resetHideTimer])\n\n\t// 按钮动画样式\n\tconst utilityButtonsAnimatedStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\topacity: interpolate(\n\t\t\t\tcontrolsOpacity.value,\n\t\t\t\t[0, 1],\n\t\t\t\t[1, 0], // 当控件显示时，完全隐藏按钮\n\t\t\t),\n\t\t\tpointerEvents: controlsOpacity.value > 0.5 ? 'none' : 'auto',\n\t\t}\n\t})\n\n\tconst controlsAnimatedStyle = useAnimatedStyle(() => ({\n\t\topacity: controlsOpacity.value,\n\t\tpointerEvents: controlsOpacity.value > 0.5 ? 'auto' : 'none',\n\t}))\n\n\t// 渐变颜色\n\tconst gradientColors = dark\n\t\t? (['rgba(0,0,0,0)', 'rgba(0,0,0,0.4)', 'rgba(0,0,0,0.8)'] as const)\n\t\t: ([\n\t\t\t\t'rgba(255,255,255,0)',\n\t\t\t\t'rgba(255,255,255,0.4)',\n\t\t\t\t'rgba(255,255,255,0.8)',\n\t\t\t] as const)\n\n\treturn (\n\t\t<View\n\t\t\tstyle={styles.overlayContainer}\n\t\t\tpointerEvents='box-none'\n\t\t>\n\t\t\t{/* 渐变背景 + 点击区域 */}\n\t\t\t<GestureDetector gesture={tapGesture}>\n\t\t\t\t<Animated.View style={styles.gradient}>\n\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\tstyle={styles.gradientInner}\n\t\t\t\t\t\tcolors={gradientColors}\n\t\t\t\t\t\tlocations={[0, 0.4, 1]}\n\t\t\t\t\t/>\n\t\t\t\t</Animated.View>\n\t\t\t</GestureDetector>\n\n\t\t\t{/* 功能按钮 - 始终可见，右下角 */}\n\t\t\t<Animated.View\n\t\t\t\tstyle={[styles.utilityButtons, utilityButtonsAnimatedStyle]}\n\t\t\t>\n\t\t\t\t<RectButton\n\t\t\t\t\tstyle={styles.utilityButton}\n\t\t\t\t\t// @ts-expect-error -- RectButton ref typing\n\t\t\t\t\tref={actionButtonRef}\n\t\t\t\t\tenabled={!offsetMenuVisible}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tactionButtonRef.current?.measure((_x, _y, w, h, pageX, pageY) => {\n\t\t\t\t\t\t\tonOpenActionMenu({ x: pageX, y: pageY, width: w, height: h })\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource='dots-vertical'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tcolor={\n\t\t\t\t\t\t\toffsetMenuVisible ? colors.onSurfaceDisabled : colors.primary\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</RectButton>\n\t\t\t</Animated.View>\n\n\t\t\t{/* 播放器控件 - 条件显示 */}\n\t\t\t<Animated.View style={[styles.playerControls, controlsAnimatedStyle]}>\n\t\t\t\t<PlayerSlider onInteraction={handleInteraction} />\n\t\t\t\t<View style={styles.playbackButtonsWrapper}>\n\t\t\t\t\t<MainPlaybackControls\n\t\t\t\t\t\tsize='compact'\n\t\t\t\t\t\tonInteraction={handleInteraction}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</Animated.View>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\toverlayContainer: {\n\t\tposition: 'absolute',\n\t\tbottom: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t\theight: OVERLAY_HEIGHT,\n\t},\n\tgradient: {\n\t\tposition: 'absolute',\n\t\ttop: 0,\n\t\tleft: 0,\n\t\tright: 0,\n\t\tbottom: 0,\n\t},\n\tgradientInner: {\n\t\tflex: 1,\n\t},\n\tutilityButtons: {\n\t\tposition: 'absolute',\n\t\tbottom: 40,\n\t\tright: 16,\n\t\tflexDirection: 'column',\n\t},\n\tutilityButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n\tplayerControls: {\n\t\tposition: 'absolute',\n\t\tbottom: 50,\n\t\tleft: 0,\n\t\tright: 0,\n\t},\n\tplaybackButtonsWrapper: {\n\t\tmarginTop: 8,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerControls.tsx",
    "content": "import {\n\tOrpheus,\n\tPlaybackState,\n\tRepeatMode,\n\tuseIsPlaying,\n\tusePlaybackState,\n} from '@bbplayer/orpheus'\nimport { useRouter } from 'expo-router'\nimport LottieView, { type AnimationObject } from 'lottie-react-native'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { AppState, StyleSheet, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { ActivityIndicator, useTheme } from 'react-native-paper'\n\nimport IconButton from '@/components/common/IconButton'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useShuffleMode } from '@/hooks/queries/orpheus'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport * as Haptics from '@/utils/haptics'\nimport { tintLottieSource } from '@/utils/lottie'\n\nconst skipPrevSource =\n\trequire('@/assets/lottie/skip-prev.json') as AnimationObject\nconst skipNextSource =\n\trequire('@/assets/lottie/skip-next.json') as AnimationObject\nconst playPauseSource =\n\trequire('@/assets/lottie/play-pause.json') as AnimationObject\n\ninterface MainPlaybackControlsProps {\n\tsize?: 'normal' | 'compact'\n\tonInteraction?: () => void\n}\n\n/**\n * 主播放控制按钮组件（上一曲/播放暂停/下一曲）\n * 可在主播放器和歌词页面复用\n */\nexport function MainPlaybackControls({\n\tsize = 'normal',\n\tonInteraction,\n}: MainPlaybackControlsProps) {\n\tconst { colors } = useTheme()\n\tconst isPlaying = useIsPlaying()\n\tconst state = usePlaybackState()\n\n\t// 对 isPlaying 状态添加防抖，避免 seek 时短暂闪烁图标\n\tconst [debouncedIsPlaying, setDebouncedIsPlaying] = useState(isPlaying)\n\tconst [debouncedBuffering, setDebouncedBuffering] = useState(false)\n\tconst playingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\tconst bufferingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n\tconst prevLottieRef = useRef<LottieView>(null)\n\tconst nextLottieRef = useRef<LottieView>(null)\n\tconst playPauseLottieRef = useRef<LottieView>(null)\n\tconst isFirstMount = useRef(true)\n\n\tuseEffect(() => {\n\t\tif (state === PlaybackState.BUFFERING) {\n\t\t\tif (bufferingTimeoutRef.current) {\n\t\t\t\tclearTimeout(bufferingTimeoutRef.current)\n\t\t\t\tbufferingTimeoutRef.current = null\n\t\t\t}\n\t\t\tbufferingTimeoutRef.current = setTimeout(() => {\n\t\t\t\tsetDebouncedBuffering(true)\n\t\t\t}, 300)\n\t\t} else {\n\t\t\tif (bufferingTimeoutRef.current) {\n\t\t\t\tclearTimeout(bufferingTimeoutRef.current)\n\t\t\t\tbufferingTimeoutRef.current = null\n\t\t\t}\n\t\t\tsetDebouncedBuffering(false)\n\t\t}\n\t\treturn () => {\n\t\t\tif (bufferingTimeoutRef.current) {\n\t\t\t\tclearTimeout(bufferingTimeoutRef.current)\n\t\t\t}\n\t\t}\n\t}, [state])\n\n\tuseEffect(() => {\n\t\tif (playingTimeoutRef.current) {\n\t\t\tclearTimeout(playingTimeoutRef.current)\n\t\t\tplayingTimeoutRef.current = null\n\t\t}\n\t\tif (isPlaying) {\n\t\t\t// 播放状态立即更新\n\t\t\tsetDebouncedIsPlaying(true)\n\t\t} else {\n\t\t\t// 暂停状态延迟更新，避免 seek 时短暂闪烁\n\t\t\tplayingTimeoutRef.current = setTimeout(() => {\n\t\t\t\tsetDebouncedIsPlaying(false)\n\t\t\t}, 200)\n\t\t}\n\t\treturn () => {\n\t\t\tif (playingTimeoutRef.current) {\n\t\t\t\tclearTimeout(playingTimeoutRef.current)\n\t\t\t}\n\t\t}\n\t}, [isPlaying])\n\n\tuseEffect(() => {\n\t\tif (isFirstMount.current) {\n\t\t\tisFirstMount.current = false\n\t\t\tif (debouncedIsPlaying) {\n\t\t\t\tplayPauseLottieRef.current?.play(0, 0)\n\t\t\t} else {\n\t\t\t\tplayPauseLottieRef.current?.play(8, 8)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tif (debouncedIsPlaying) {\n\t\t\tplayPauseLottieRef.current?.play(8, 0)\n\t\t} else {\n\t\t\tplayPauseLottieRef.current?.play(0, 8)\n\t\t}\n\t}, [debouncedIsPlaying, debouncedBuffering])\n\n\tconst skipButtonSize = size === 'compact' ? 40 : 46\n\tconst playButtonSize = size === 'compact' ? 80 : 96\n\tconst gap = size === 'compact' ? 24 : 40\n\n\t// 我知道这 tmd 是一种究极无敌肮脏的 hack，但没办法，colorFilters 不生效啊...\n\tconst tintedSkipPrev = useMemo(\n\t\t() => tintLottieSource(skipPrevSource, colors.onSurfaceVariant),\n\t\t[colors.onSurfaceVariant],\n\t)\n\tconst tintedPlayPause = useMemo(\n\t\t() => tintLottieSource(playPauseSource, colors.primary),\n\t\t[colors.primary],\n\t)\n\tconst tintedSkipNext = useMemo(\n\t\t() => tintLottieSource(skipNextSource, colors.onSurfaceVariant),\n\t\t[colors.onSurfaceVariant],\n\t)\n\n\treturn (\n\t\t<View style={[styles.mainControlsContainer, { gap }]}>\n\t\t\t<RectButton\n\t\t\t\tstyle={{\n\t\t\t\t\twidth: skipButtonSize,\n\t\t\t\t\theight: skipButtonSize,\n\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\talignItems: 'center',\n\t\t\t\t\tborderRadius: 99999,\n\t\t\t\t}}\n\t\t\t\tonPress={() => {\n\t\t\t\t\tonInteraction?.()\n\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Context_Click)\n\t\t\t\t\tprevLottieRef.current?.play(0, 60)\n\t\t\t\t\tvoid Orpheus.skipToPrevious()\n\t\t\t\t\tvoid analyticsService.logPlayerAction('skip_prev')\n\t\t\t\t}}\n\t\t\t\ttestID='player-prev'\n\t\t\t>\n\t\t\t\t<LottieView\n\t\t\t\t\tref={prevLottieRef}\n\t\t\t\t\tsource={tintedSkipPrev}\n\t\t\t\t\tstyle={{ width: '100%', height: '100%' }}\n\t\t\t\t\tautoPlay={false}\n\t\t\t\t\tspeed={2}\n\t\t\t\t\tloop={false}\n\t\t\t\t/>\n\t\t\t</RectButton>\n\t\t\t<RectButton\n\t\t\t\tstyle={{\n\t\t\t\t\twidth: playButtonSize,\n\t\t\t\t\theight: playButtonSize,\n\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\talignItems: 'center',\n\t\t\t\t\tborderRadius: 99999,\n\t\t\t\t}}\n\t\t\t\tonPress={async () => {\n\t\t\t\t\tonInteraction?.()\n\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Context_Click)\n\n\t\t\t\t\tconst nextIsPlaying = !debouncedIsPlaying\n\t\t\t\t\tsetDebouncedIsPlaying(nextIsPlaying)\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tif (debouncedIsPlaying) {\n\t\t\t\t\t\t\tawait Orpheus.pause()\n\t\t\t\t\t\t\tvoid analyticsService.logPlayerAction('pause')\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tawait Orpheus.play()\n\t\t\t\t\t\t\tvoid analyticsService.logPlayerAction('play')\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\ttoastAndLogError('播放操作失败', e, 'UI.Player.Controls')\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\ttestID='player-play-pause'\n\t\t\t>\n\t\t\t\t{debouncedBuffering ? (\n\t\t\t\t\t<ActivityIndicator\n\t\t\t\t\t\tsize={playButtonSize * 0.4}\n\t\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<LottieView\n\t\t\t\t\t\tref={playPauseLottieRef}\n\t\t\t\t\t\tsource={tintedPlayPause}\n\t\t\t\t\t\tstyle={{ width: '100%', height: '100%' }}\n\t\t\t\t\t\tautoPlay={false}\n\t\t\t\t\t\tspeed={2}\n\t\t\t\t\t\tloop={false}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</RectButton>\n\t\t\t<RectButton\n\t\t\t\tstyle={{\n\t\t\t\t\twidth: skipButtonSize,\n\t\t\t\t\theight: skipButtonSize,\n\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\talignItems: 'center',\n\t\t\t\t\tborderRadius: 99999,\n\t\t\t\t}}\n\t\t\t\tonPress={() => {\n\t\t\t\t\tonInteraction?.()\n\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Context_Click)\n\t\t\t\t\tnextLottieRef.current?.play(0, 60)\n\t\t\t\t\tvoid Orpheus.skipToNext()\n\t\t\t\t\tvoid analyticsService.logPlayerAction('skip_next')\n\t\t\t\t}}\n\t\t\t\ttestID='player-next'\n\t\t\t>\n\t\t\t\t<LottieView\n\t\t\t\t\tref={nextLottieRef}\n\t\t\t\t\tsource={tintedSkipNext}\n\t\t\t\t\tstyle={{ width: '100%', height: '100%' }}\n\t\t\t\t\tautoPlay={false}\n\t\t\t\t\tspeed={2}\n\t\t\t\t\tloop={false}\n\t\t\t\t/>\n\t\t\t</RectButton>\n\t\t</View>\n\t)\n}\n\nexport function PlayerControls({ onOpenQueue }: { onOpenQueue: () => void }) {\n\tconst { colors } = useTheme()\n\tconst { data: shuffleMode, refetch: refetchShuffleMode } = useShuffleMode()\n\tconst [repeatMode, setRepeatMode] = useState(RepeatMode.OFF)\n\tconst currentTrack = useCurrentTrack()\n\tconst router = useRouter()\n\n\tuseEffect(() => {\n\t\tvoid Orpheus.getRepeatMode().then(setRepeatMode)\n\t\tconst listener = AppState.addEventListener('change', (nextAppState) => {\n\t\t\tif (nextAppState === 'active') {\n\t\t\t\tvoid Orpheus.getRepeatMode().then(setRepeatMode)\n\t\t\t}\n\t\t})\n\t\treturn () => {\n\t\t\tlistener.remove()\n\t\t}\n\t}, [])\n\n\treturn (\n\t\t<View>\n\t\t\t<View style={styles.mainControlsWrapper}>\n\t\t\t\t<MainPlaybackControls />\n\t\t\t</View>\n\t\t\t<View style={styles.secondaryControlsContainer}>\n\t\t\t\t<IconButton\n\t\t\t\t\ticon={shuffleMode ? 'shuffle-variant' : 'shuffle-disabled'}\n\t\t\t\t\tsize={24}\n\t\t\t\t\ticonColor={shuffleMode ? colors.primary : colors.onSurfaceVariant}\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Confirm)\n\t\t\t\t\t\tawait (shuffleMode\n\t\t\t\t\t\t\t? Orpheus.setShuffleMode(false)\n\t\t\t\t\t\t\t: Orpheus.setShuffleMode(true))\n\t\t\t\t\t\tawait refetchShuffleMode()\n\t\t\t\t\t\tvoid analyticsService.logPlayerAction('shuffle', {\n\t\t\t\t\t\t\tmode: !shuffleMode,\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t\ttestID='player-mode-shuffle'\n\t\t\t\t/>\n\t\t\t\t<IconButton\n\t\t\t\t\ticon={\n\t\t\t\t\t\trepeatMode === RepeatMode.OFF\n\t\t\t\t\t\t\t? 'repeat-off'\n\t\t\t\t\t\t\t: repeatMode === RepeatMode.TRACK\n\t\t\t\t\t\t\t\t? 'repeat-once'\n\t\t\t\t\t\t\t\t: 'repeat'\n\t\t\t\t\t}\n\t\t\t\t\tsize={24}\n\t\t\t\t\ticonColor={\n\t\t\t\t\t\trepeatMode !== RepeatMode.OFF\n\t\t\t\t\t\t\t? colors.primary\n\t\t\t\t\t\t\t: colors.onSurfaceVariant\n\t\t\t\t\t}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Confirm)\n\t\t\t\t\t\tconst nextMode =\n\t\t\t\t\t\t\trepeatMode === RepeatMode.OFF\n\t\t\t\t\t\t\t\t? RepeatMode.TRACK\n\t\t\t\t\t\t\t\t: repeatMode === RepeatMode.TRACK\n\t\t\t\t\t\t\t\t\t? RepeatMode.QUEUE\n\t\t\t\t\t\t\t\t\t: RepeatMode.OFF\n\t\t\t\t\t\tvoid Orpheus.setRepeatMode(nextMode)\n\t\t\t\t\t\tsetRepeatMode(nextMode)\n\t\t\t\t\t\tvoid analyticsService.logPlayerAction('repeat', {\n\t\t\t\t\t\t\tmode: nextMode,\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t\ttestID='player-mode-repeat'\n\t\t\t\t/>\n\t\t\t\t<IconButton\n\t\t\t\t\ticon='comment-text-outline'\n\t\t\t\t\tsize={24}\n\t\t\t\t\tdisabled={currentTrack?.source !== 'bilibili'}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tif (currentTrack?.source === 'bilibili') {\n\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\tpathname: '/comments/[bvid]',\n\t\t\t\t\t\t\t\tparams: { bvid: currentTrack.bilibiliMetadata.bvid },\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\ttestID='player-open-comments'\n\t\t\t\t/>\n\t\t\t\t<IconButton\n\t\t\t\t\ticon='format-list-bulleted'\n\t\t\t\t\tsize={24}\n\t\t\t\t\ticonColor={colors.onSurfaceVariant}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Context_Click)\n\t\t\t\t\t\tonOpenQueue()\n\t\t\t\t\t\tvoid analyticsService.logPlayerQueueAction('open_queue')\n\t\t\t\t\t}}\n\t\t\t\t\ttestID='player-open-queue'\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tmainControlsWrapper: {\n\t\tmarginTop: 24,\n\t},\n\tmainControlsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tsecondaryControlsContainer: {\n\t\tmarginTop: 12,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tgap: 32,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerFunctionalMenu.tsx",
    "content": "import { DownloadState, Orpheus } from '@bbplayer/orpheus'\nimport { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport { useRouter } from 'expo-router'\nimport { useCallback, useEffect, useRef } from 'react'\nimport { ScrollView, View } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport {\n\tDivider,\n\tIcon,\n\tList,\n\tMD3Theme,\n\tText,\n\tTouchableRipple,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useBatchDownloadStatus } from '@/hooks/queries/orpheus'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { getInternalPlayUri } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nfunction HighFreqButton({\n\ticon,\n\tlabel,\n\tonPress,\n\tcolors,\n}: {\n\ticon: string\n\tlabel: string\n\tonPress: () => void\n\tcolors: MD3Theme['colors']\n}) {\n\treturn (\n\t\t<SquircleView\n\t\t\tstyle={{\n\t\t\t\tborderRadius: 20,\n\t\t\t\toverflow: 'hidden',\n\t\t\t\tbackgroundColor: colors.elevation.level2,\n\t\t\t\tflex: 1,\n\t\t\t\tmarginHorizontal: 4,\n\t\t\t}}\n\t\t\tcornerSmoothing={0.6}\n\t\t>\n\t\t\t<TouchableRipple\n\t\t\t\tonPress={onPress}\n\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t>\n\t\t\t\t<View\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\tpaddingVertical: 16,\n\t\t\t\t\t\theight: 80,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource={icon}\n\t\t\t\t\t\tsize={28}\n\t\t\t\t\t/>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\tstyle={{ marginTop: 8 }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{label}\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t</TouchableRipple>\n\t\t</SquircleView>\n\t)\n}\n\nexport function PlayerFunctionalMenu({\n\tmenuVisible,\n\tsetMenuVisible,\n}: {\n\tmenuVisible: boolean\n\tsetMenuVisible: (visible: boolean) => void\n}) {\n\tconst router = useRouter()\n\tconst currentTrack = useCurrentTrack()\n\tconst insets = useSafeAreaInsets()\n\tconst openModal = useModalStore((state) => state.open)\n\tconst uploaderMid = Number(currentTrack?.artist?.remoteId ?? undefined)\n\tconst trackId = currentTrack?.uniqueKey\n\tconst { data: downloadStatus } = useBatchDownloadStatus(\n\t\ttrackId ? [trackId] : [],\n\t)\n\tconst colors = useTheme().colors\n\tconst sheetRef = useRef<TrueSheet>(null)\n\n\tconst isPresented = useRef(false)\n\n\tuseEffect(() => {\n\t\tif (menuVisible) {\n\t\t\tsheetRef.current?.present().catch(() => {\n\t\t\t\t// Ignore error\n\t\t\t})\n\t\t} else {\n\t\t\tif (isPresented.current) {\n\t\t\t\tsheetRef.current?.dismiss().catch(() => {\n\t\t\t\t\t// Ignore error\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}, [menuVisible])\n\n\tconst onDismiss = useCallback(() => {\n\t\tisPresented.current = false\n\t\tsetMenuVisible(false)\n\t}, [setMenuVisible])\n\n\tconst onPresent = useCallback(() => {\n\t\tisPresented.current = true\n\t\tif (!menuVisible) {\n\t\t\tsheetRef.current?.dismiss().catch(() => {\n\t\t\t\t// Ignore error\n\t\t\t})\n\t\t}\n\t}, [menuVisible])\n\n\tconst handleAction = useCallback(\n\t\t(action: () => void) => {\n\t\t\tsetMenuVisible(false)\n\t\t\taction()\n\t\t},\n\t\t[setMenuVisible],\n\t)\n\n\tconst downloadHandler = useCallback(async () => {\n\t\tif (!currentTrack) {\n\t\t\ttoast.error('为什么 currentTrack 不存在？')\n\t\t\treturn\n\t\t}\n\t\tconst url = getInternalPlayUri(currentTrack)\n\t\tif (!url) {\n\t\t\ttoast.error('获取内部播放地址失败')\n\t\t\treturn\n\t\t}\n\t\tconst artistName = currentTrack.artist?.name\n\t\tconst artworkUrl = currentTrack.coverUrl ?? undefined\n\t\ttry {\n\t\t\tawait Orpheus.downloadTrack({\n\t\t\t\tid: currentTrack.uniqueKey,\n\t\t\t\turl: url,\n\t\t\t\ttitle: currentTrack.title,\n\t\t\t\tartist: artistName,\n\t\t\t\tartwork: artworkUrl,\n\t\t\t\tduration: currentTrack.duration,\n\t\t\t})\n\t\t\ttoast.success('已添加到下载队列')\n\t\t} catch (e) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'下载音频失败',\n\t\t\t\te,\n\t\t\t\t'Features.Player.PlayerFunctionalMenu',\n\t\t\t)\n\t\t}\n\t}, [currentTrack])\n\n\treturn (\n\t\t<TrueSheet\n\t\t\tref={sheetRef}\n\t\t\tdetents={['auto']}\n\t\t\tcornerRadius={24}\n\t\t\tbackgroundColor={colors.elevation.level1}\n\t\t\tonDidDismiss={onDismiss}\n\t\t\tonDidPresent={onPresent}\n\t\t>\n\t\t\t<ScrollView\n\t\t\t\tstyle={{ maxHeight: '100%', marginTop: 16 }}\n\t\t\t\tcontentContainerStyle={{ paddingBottom: insets.bottom + 20 }}\n\t\t\t>\n\t\t\t\t<View\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\t\tpaddingHorizontal: 12,\n\t\t\t\t\t\tpaddingTop: 16,\n\t\t\t\t\t\tpaddingBottom: 24,\n\t\t\t\t\t\twidth: '100%',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<HighFreqButton\n\t\t\t\t\t\ticon='speedometer'\n\t\t\t\t\t\tlabel='倍速'\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => openModal('PlaybackSpeed', undefined))\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcolors={colors}\n\t\t\t\t\t/>\n\t\t\t\t\t<HighFreqButton\n\t\t\t\t\t\ticon='timer-outline'\n\t\t\t\t\t\tlabel='定时关闭'\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => openModal('SleepTimer', undefined))\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcolors={colors}\n\t\t\t\t\t/>\n\t\t\t\t\t<HighFreqButton\n\t\t\t\t\t\ticon='download'\n\t\t\t\t\t\tlabel={\n\t\t\t\t\t\t\tdownloadStatus?.[currentTrack?.uniqueKey ?? ''] ===\n\t\t\t\t\t\t\tDownloadState.COMPLETED\n\t\t\t\t\t\t\t\t? '重新下载'\n\t\t\t\t\t\t\t\t: '下载'\n\t\t\t\t\t\t}\n\t\t\t\t\t\tonPress={() => handleAction(downloadHandler)}\n\t\t\t\t\t\tcolors={colors}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\n\t\t\t\t<Divider />\n\n\t\t\t\t<View style={{ paddingTop: 8 }}>\n\t\t\t\t\t{currentTrack?.source === 'bilibili' && (\n\t\t\t\t\t\t<List.Item\n\t\t\t\t\t\t\ttitle='添加到 bilibili 收藏夹'\n\t\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\t\tif (!currentTrack) return\n\t\t\t\t\t\t\t\t\topenModal('AddVideoToBilibiliFavorite', {\n\t\t\t\t\t\t\t\t\t\tbvid: currentTrack.bilibiliMetadata.bvid,\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='添加到本地歌单'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='playlist-plus'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\tif (!currentTrack) return\n\t\t\t\t\t\t\t\topenModal('UpdateTrackLocalPlaylists', { track: currentTrack })\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='查看作者'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='account-music'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\tif (!uploaderMid) {\n\t\t\t\t\t\t\t\t\ttoast.error('获取视频详细信息失败')\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\t\t\t\t\t\t\tparams: { mid: String(uploaderMid) },\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t\t{currentTrack?.source === 'bilibili' && (\n\t\t\t\t\t\t<List.Item\n\t\t\t\t\t\t\ttitle='查看视频详情'\n\t\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\ticon='open-in-new'\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\t\tif (!currentTrack) return\n\t\t\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\t\t\t\t\t\t\tparams: { bvid: currentTrack.bilibiliMetadata.bvid },\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='搜索歌词'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='magnify'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\tif (!currentTrack) return\n\t\t\t\t\t\t\t\topenModal('ManualSearchLyrics', {\n\t\t\t\t\t\t\t\t\tuniqueKey: currentTrack.uniqueKey,\n\t\t\t\t\t\t\t\t\tinitialQuery: currentTrack.title,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='分享歌词'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='share-variant'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\tif (!currentTrack) return\n\t\t\t\t\t\t\t\topenModal('LyricsSelection', undefined)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t\t<List.Item\n\t\t\t\t\t\ttitle='分享歌曲'\n\t\t\t\t\t\tleft={(props) => (\n\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\ticon='share-variant-outline'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\thandleAction(() => {\n\t\t\t\t\t\t\t\tif (!currentTrack) return\n\t\t\t\t\t\t\t\topenModal('SongShare', undefined)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t</TrueSheet>\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerHeader.tsx",
    "content": "import { DownloadState } from '@bbplayer/orpheus'\nimport { StyleSheet, View } from 'react-native'\nimport { Text } from 'react-native-paper'\nimport Animated from 'react-native-reanimated'\nimport type { SharedValue } from 'react-native-reanimated'\n\nimport IconButton from '@/components/common/IconButton'\nimport { usePlayerHeaderAnimation } from '@/features/player/hooks/usePlayerHeaderAnimation'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useBatchDownloadStatus } from '@/hooks/queries/orpheus'\n\nexport function PlayerHeader({\n\tonMorePress,\n\tonBack,\n\tindex,\n\tscrollX,\n}: {\n\tonMorePress: () => void\n\tonBack: () => void\n\tindex: number\n\tscrollX?: SharedValue<number>\n}) {\n\tconst currentTrack = useCurrentTrack()\n\tconst { data: downloadStatus } = useBatchDownloadStatus(\n\t\tcurrentTrack?.uniqueKey ? [currentTrack.uniqueKey] : [],\n\t)\n\n\tconst title = currentTrack?.title ?? '正在播放'\n\tconst statusText =\n\t\tdownloadStatus?.[currentTrack?.uniqueKey ?? ''] === DownloadState.COMPLETED\n\t\t\t? '正在播放 (已缓存)'\n\t\t\t: '正在播放'\n\n\tconst { titleStyle, statusStyle } = usePlayerHeaderAnimation(index, scrollX)\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t{\n\t\t\t\t<IconButton\n\t\t\t\t\ticon={index === 0 ? 'chevron-down' : 'chevron-left'}\n\t\t\t\t\tsize={24}\n\t\t\t\t\tonPress={onBack}\n\t\t\t\t/>\n\t\t\t}\n\t\t\t<View style={styles.titleContainer}>\n\t\t\t\t<Animated.View\n\t\t\t\t\tstyle={[styles.headerTextContainer, statusStyle]}\n\t\t\t\t\tpointerEvents='none'\n\t\t\t\t>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\tstyle={styles.text}\n\t\t\t\t\t>\n\t\t\t\t\t\t{statusText}\n\t\t\t\t\t</Text>\n\t\t\t\t</Animated.View>\n\t\t\t\t<Animated.View\n\t\t\t\t\tstyle={[styles.headerTextContainer, titleStyle]}\n\t\t\t\t\tpointerEvents='none'\n\t\t\t\t>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\tstyle={styles.text}\n\t\t\t\t\t>\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</Text>\n\t\t\t\t</Animated.View>\n\t\t\t</View>\n\t\t\t<IconButton\n\t\t\t\ticon='dots-vertical'\n\t\t\t\tsize={24}\n\t\t\t\tonPress={onMorePress}\n\t\t\t/>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 8,\n\t},\n\ttitleContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t\theight: 40,\n\t},\n\theaderTextContainer: {\n\t\t...StyleSheet.absoluteFillObject,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\ttext: {\n\t\ttextAlign: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerLyrics.tsx",
    "content": "import { parseAndMergeLyrics, type LyricLine } from '@bbplayer/splash'\nimport MaskedView from '@react-native-masked-view/masked-view'\nimport { LinearGradient } from 'expo-linear-gradient'\nimport { memo, useCallback, useEffect, useRef, useState } from 'react'\nimport {\n\tPressable,\n\tScrollView,\n\tStyleSheet,\n\tuseWindowDimensions,\n\tView,\n} from 'react-native'\nimport { ActivityIndicator, Text, useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedScrollHandler,\n\tuseSharedValue,\n\tuseDerivedValue,\n} from 'react-native-reanimated'\nimport { scheduleOnRN } from 'react-native-worklets'\n\nimport { LyricsControlOverlay } from '@/features/player/components/LyricsControlOverlay'\nimport useLyricSync from '@/features/player/hooks/useLyricSync'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport useSmoothProgress from '@/hooks/player/useSmoothProgress'\nimport { lyricsQueryKeys, useSmartFetchLyrics } from '@/hooks/queries/lyrics'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { queryClient } from '@/lib/config/queryClient'\nimport lyricService from '@/lib/services/lyricService'\nimport { toastAndLogError } from '@/utils/error-handling'\n\nimport { LyricActionSheet } from './lyrics/LyricActionSheet'\nimport {\n\tModernLyricLineItem,\n\tOldSchoolLyricLineItem,\n} from './lyrics/LyricLineItem'\nimport { LyricsOffsetControl } from './lyrics/LyricsOffsetControl'\n\nconst SCROLL_DIRECTION_THRESHOLD = 8\n\nconst Lyrics = memo(function Lyrics({\n\tcurrentIndex,\n\tonPressBackground,\n}: {\n\tcurrentIndex: number\n\tonPressBackground?: () => void\n}) {\n\tconst dimensions = useWindowDimensions()\n\tconst windowHeight = dimensions.height\n\tconst colors = useTheme().colors\n\tconst scrollViewRef = useRef<Animated.ScrollView>(null)\n\tconst [actionMenuVisible, setActionMenuVisible] = useState(false)\n\tconst itemLayoutsRef = useRef<{ [index: number]: number }>({})\n\n\tconst scrollToIndex = useCallback(\n\t\t(index: number, animated = true) => {\n\t\t\tconst y = itemLayoutsRef.current[index]\n\t\t\tif (y !== undefined && scrollViewRef.current) {\n\t\t\t\tscrollViewRef.current.scrollTo({\n\t\t\t\t\ty: Math.max(0, y - windowHeight * 0.15),\n\t\t\t\t\tanimated,\n\t\t\t\t})\n\t\t\t}\n\t\t},\n\t\t[windowHeight],\n\t)\n\n\tconst [offsetMenuVisible, setOffsetMenuVisible] = useState(false)\n\tconst [offsetMenuAnchor, setOffsetMenuAnchor] = useState<{\n\t\tx: number\n\t\ty: number\n\t\twidth: number\n\t\theight: number\n\t} | null>(null)\n\tconst scrollDirection = useSharedValue<'up' | 'down' | 'idle'>('idle')\n\tconst lastScrollY = useSharedValue(0)\n\tconst track = useCurrentTrack()\n\tconst enableOldSchoolStyleLyric = useAppStore(\n\t\t(state) => state.settings.enableOldSchoolStyleLyric,\n\t)\n\tconst enableVerbatimLyrics = useAppStore(\n\t\t(state) => state.settings.enableVerbatimLyrics,\n\t)\n\n\tconst { position: currentTime } = useSmoothProgress()\n\n\tuseEffect(() => {\n\t\titemLayoutsRef.current = {}\n\t}, [track?.uniqueKey])\n\n\tconst {\n\t\tdata: lyrics,\n\t\tisPending,\n\t\tisError,\n\t\terror,\n\t} = useSmartFetchLyrics(currentIndex === 1, track ?? undefined)\n\tconst [preferredLyricType, setPreferredLyricType] = useState<\n\t\t'translation' | 'romaji'\n\t>('translation')\n\n\tconst [tempOffset, setTempOffset] = useState(0)\n\n\tuseEffect(() => {\n\t\tif (lyrics?.misc?.userOffset !== undefined) {\n\t\t\tsetTempOffset(lyrics.misc.userOffset)\n\t\t} else {\n\t\t\tsetTempOffset(0)\n\t\t}\n\t}, [lyrics?.misc?.userOffset])\n\n\tconst offsetSharedValue = useSharedValue(0)\n\tuseEffect(() => {\n\t\toffsetSharedValue.set(tempOffset)\n\t}, [tempOffset, offsetSharedValue])\n\n\tconst adjustedCurrentTime = useDerivedValue(() => {\n\t\treturn currentTime.value - offsetSharedValue.value\n\t})\n\n\t// so bro I trust react compiler\n\tconst finalLyrics = (() => {\n\t\tif (!lyrics?.lrc) return []\n\n\t\tlet parsedLines\n\t\ttry {\n\t\t\tparsedLines = parseAndMergeLyrics({\n\t\t\t\tlrc: lyrics.lrc,\n\t\t\t\ttlyric: lyrics.tlyric,\n\t\t\t\tromalrc: lyrics.romalrc,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('解析歌词失败', e, 'Player.PlayerLyrics')\n\t\t\treturn null\n\t\t}\n\n\t\tif (parsedLines.length === 0) return null\n\n\t\tconst lastLine = parsedLines.at(-1)\n\t\tconst paddingTimestamp =\n\t\t\t(lastLine ? lastLine.startTime : 0) + Number.EPSILON\n\t\treturn [\n\t\t\t...parsedLines,\n\t\t\t{\n\t\t\t\tstartTime: paddingTimestamp,\n\t\t\t\tendTime: paddingTimestamp,\n\t\t\t\tcontent: '',\n\t\t\t\ttranslations: [],\n\t\t\t\tisDynamic: false,\n\t\t\t\tspans: [],\n\t\t\t\tisPaddingItem: true,\n\t\t\t} as LyricLine & { isPaddingItem?: boolean },\n\t\t]\n\t})()\n\n\tconst {\n\t\tcurrentLyricIndex,\n\t\tonUserScrollEnd,\n\t\tonUserScrollStart,\n\t\thandleJumpToLyric,\n\t} = useLyricSync(\n\t\t((finalLyrics ?? []) as (LyricLine & { isPaddingItem?: boolean })[]).filter(\n\t\t\t(l) => !l.isPaddingItem,\n\t\t),\n\t\tscrollToIndex,\n\t\t-tempOffset,\n\t\tcurrentIndex === 1,\n\t)\n\n\tconst scrollHandler = useAnimatedScrollHandler({\n\t\tonScroll: (e) => {\n\t\t\tconst currentY = e.contentOffset.y\n\t\t\tconst deltaY = currentY - lastScrollY.get()\n\n\t\t\t// 检测滚动方向\n\t\t\tif (Math.abs(deltaY) > SCROLL_DIRECTION_THRESHOLD) {\n\t\t\t\tscrollDirection.set(deltaY > 0 ? 'up' : 'down')\n\t\t\t}\n\n\t\t\tlastScrollY.set(currentY)\n\t\t},\n\t\tonBeginDrag: () => {\n\t\t\tscheduleOnRN(onUserScrollStart)\n\t\t},\n\t\tonEndDrag: () => {\n\t\t\tscrollDirection.set('idle')\n\t\t\tscheduleOnRN(onUserScrollEnd)\n\t\t},\n\t})\n\n\tconst handleChangeOffset = (delta: number) => {\n\t\tsetTempOffset((prev) => prev + delta)\n\t}\n\n\tconst handleCloseOffsetMenu = () => {\n\t\tsetOffsetMenuVisible(false)\n\t\tif (!lyrics || !track) return\n\n\t\trequestAnimationFrame(async () => {\n\t\t\tconst currentLyrics = lyrics\n\t\t\tconst newLyrics = {\n\t\t\t\t...currentLyrics,\n\t\t\t\tmisc: {\n\t\t\t\t\t...currentLyrics.misc,\n\t\t\t\t\tuserOffset: tempOffset,\n\t\t\t\t},\n\t\t\t}\n\t\t\tqueryClient.setQueryData(\n\t\t\t\tlyricsQueryKeys.smartFetchLyrics(track.uniqueKey),\n\t\t\t\tnewLyrics,\n\t\t\t)\n\n\t\t\tconst saveResult = await lyricService.saveLyricsToFile(\n\t\t\t\tnewLyrics,\n\t\t\t\ttrack.uniqueKey,\n\t\t\t)\n\t\t\tif (saveResult.isErr()) {\n\t\t\t\ttoastAndLogError('保存歌词偏移量失败', saveResult.error, 'Lyrics')\n\t\t\t}\n\t\t})\n\t}\n\n\tconst handleEditLyrics = useCallback(() => {\n\t\tif (!track || !lyrics) return\n\t\tuseModalStore.getState().open('EditLyrics', {\n\t\t\tuniqueKey: track.uniqueKey,\n\t\t\tlyrics: lyrics,\n\t\t})\n\t}, [track, lyrics])\n\n\tconst handleOpenOffsetMenu = useCallback(() => {\n\t\tsetOffsetMenuVisible(true)\n\t}, [])\n\n\tif (!track) return null\n\n\tif (isPending) {\n\t\treturn (\n\t\t\t<View style={styles.pendingContainer}>\n\t\t\t\t<ActivityIndicator size={'large'} />\n\t\t\t</View>\n\t\t)\n\t}\n\n\tif (isError) {\n\t\treturn (\n\t\t\t<ScrollView\n\t\t\t\tstyle={styles.errorScrollView}\n\t\t\t\tcontentContainerStyle={styles.errorContentContainer}\n\t\t\t>\n\t\t\t\t<Text\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t>\n\t\t\t\t\t歌词加载失败：{error.message}\n\t\t\t\t</Text>\n\t\t\t</ScrollView>\n\t\t)\n\t}\n\n\tconst renderLyrics = () => {\n\t\tif (lyrics.errorMessage) {\n\t\t\treturn (\n\t\t\t\t<ScrollView\n\t\t\t\t\tstyle={styles.errorScrollView}\n\t\t\t\t\tcontentContainerStyle={styles.errorContentContainer}\n\t\t\t\t>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.errorText}\n\t\t\t\t\t>\n\t\t\t\t\t\t{lyrics.errorMessage}\n\t\t\t\t\t</Text>\n\t\t\t\t</ScrollView>\n\t\t\t)\n\t\t}\n\n\t\tif (!lyrics.lrc || !finalLyrics) {\n\t\t\treturn (\n\t\t\t\t<Animated.ScrollView\n\t\t\t\t\tcontentContainerStyle={[\n\t\t\t\t\t\tstyles.rawLyricsScrollViewContainer,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tpaddingTop: windowHeight * 0.05,\n\t\t\t\t\t\t\tpaddingBottom: windowHeight * 0.5,\n\t\t\t\t\t\t},\n\t\t\t\t\t]}\n\t\t\t\t\tscrollEventThrottle={16}\n\t\t\t\t\tonScroll={scrollHandler}\n\t\t\t\t>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tstyle={styles.rawLyricsText}\n\t\t\t\t\t>\n\t\t\t\t\t\t{lyrics ? '原始歌词：' : ''}\n\t\t\t\t\t\t{lyrics.lrc}\n\t\t\t\t\t\t{lyrics.tlyric ? `\\n\\n翻译歌词：${lyrics.tlyric}` : ''}\n\t\t\t\t\t</Text>\n\t\t\t\t</Animated.ScrollView>\n\t\t\t)\n\t\t}\n\t\treturn (\n\t\t\t<Animated.ScrollView\n\t\t\t\tnestedScrollEnabled\n\t\t\t\tref={scrollViewRef}\n\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\tpointerEvents:\n\t\t\t\t\t\toffsetMenuVisible || actionMenuVisible ? 'none' : 'auto',\n\t\t\t\t\tpaddingTop: windowHeight * 0.02,\n\t\t\t\t}}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tscrollEventThrottle={30}\n\t\t\t\tonScroll={scrollHandler}\n\t\t\t>\n\t\t\t\t{(finalLyrics as (LyricLine & { isPaddingItem?: boolean })[]).map(\n\t\t\t\t\t(item, index) => {\n\t\t\t\t\t\tif (item.isPaddingItem) {\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<Pressable\n\t\t\t\t\t\t\t\t\tkey='padding_item'\n\t\t\t\t\t\t\t\t\tstyle={{ height: windowHeight / 2 }}\n\t\t\t\t\t\t\t\t\tonPress={onPressBackground}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t// oxlint-disable-next-line eslint/react/no-array-index-key -- lyrics might have duplicate start times, index is needed for uniqueness\n\t\t\t\t\t\t\t\tkey={`${index}_${item.startTime}`}\n\t\t\t\t\t\t\t\tonLayout={(e) => {\n\t\t\t\t\t\t\t\t\titemLayoutsRef.current[index] = e.nativeEvent.layout.y\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{enableOldSchoolStyleLyric ? (\n\t\t\t\t\t\t\t\t\t<OldSchoolLyricLineItem\n\t\t\t\t\t\t\t\t\t\titem={item}\n\t\t\t\t\t\t\t\t\t\tisHighlighted={index === currentLyricIndex}\n\t\t\t\t\t\t\t\t\t\tindex={index}\n\t\t\t\t\t\t\t\t\t\tjumpToThisLyric={handleJumpToLyric}\n\t\t\t\t\t\t\t\t\t\tonPressBackground={onPressBackground}\n\t\t\t\t\t\t\t\t\t\tcurrentTime={adjustedCurrentTime}\n\t\t\t\t\t\t\t\t\t\tenableVerbatimLyrics={enableVerbatimLyrics}\n\t\t\t\t\t\t\t\t\t\tpreferredLyricType={preferredLyricType}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<ModernLyricLineItem\n\t\t\t\t\t\t\t\t\t\titem={item}\n\t\t\t\t\t\t\t\t\t\tisHighlighted={index === currentLyricIndex}\n\t\t\t\t\t\t\t\t\t\tindex={index}\n\t\t\t\t\t\t\t\t\t\tjumpToThisLyric={handleJumpToLyric}\n\t\t\t\t\t\t\t\t\t\tonPressBackground={onPressBackground}\n\t\t\t\t\t\t\t\t\t\tcurrentTime={adjustedCurrentTime}\n\t\t\t\t\t\t\t\t\t\tenableVerbatimLyrics={enableVerbatimLyrics}\n\t\t\t\t\t\t\t\t\t\tpreferredLyricType={preferredLyricType}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t)\n\t\t\t\t\t},\n\t\t\t\t)}\n\t\t\t</Animated.ScrollView>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View\n\t\t\tstyle={styles.lyricsContainer}\n\t\t\ttestID='player-lyrics-view'\n\t\t>\n\t\t\t<View style={styles.lyricsContent}>\n\t\t\t\t<MaskedView\n\t\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t\t\tmaskElement={\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t\t\t\t\tpointerEvents='none'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\t\t\tstyle={[styles.gradient]}\n\t\t\t\t\t\t\t\tstart={{ x: 0, y: 0 }}\n\t\t\t\t\t\t\t\tend={{ x: 0, y: 1 }}\n\t\t\t\t\t\t\t\tcolors={['transparent', colors.background]}\n\t\t\t\t\t\t\t\tlocations={[0, 1]}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tflex: 1,\n\t\t\t\t\t\t\t\t\tbackgroundColor: colors.background,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\t\t\tstyle={[styles.gradient]}\n\t\t\t\t\t\t\t\tstart={{ x: 0, y: 0 }}\n\t\t\t\t\t\t\t\tend={{ x: 0, y: 1 }}\n\t\t\t\t\t\t\t\tcolors={[colors.background, 'transparent']}\n\t\t\t\t\t\t\t\tlocations={[0, 1]}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t{renderLyrics()}\n\t\t\t\t</MaskedView>\n\t\t\t</View>\n\n\t\t\t{/* 播放器控件覆盖层 */}\n\t\t\t<LyricsControlOverlay\n\t\t\t\tscrollDirection={scrollDirection}\n\t\t\t\toffsetMenuVisible={offsetMenuVisible}\n\t\t\t\tonOpenActionMenu={(anchor) => {\n\t\t\t\t\tsetOffsetMenuAnchor(anchor)\n\t\t\t\t\tsetActionMenuVisible(true)\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t<LyricActionSheet\n\t\t\t\tvisible={actionMenuVisible}\n\t\t\t\tanchor={offsetMenuAnchor}\n\t\t\t\tonDismiss={() => setActionMenuVisible(false)}\n\t\t\t\tshowTranslationToggle={!!lyrics?.tlyric && !!lyrics?.romalrc}\n\t\t\t\ttranslationType={preferredLyricType}\n\t\t\t\tonToggleTranslation={() =>\n\t\t\t\t\tsetPreferredLyricType((prev) =>\n\t\t\t\t\t\tprev === 'translation' ? 'romaji' : 'translation',\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tonEditLyrics={handleEditLyrics}\n\t\t\t\tonOpenOffsetMenu={handleOpenOffsetMenu}\n\t\t\t/>\n\n\t\t\t{/* 歌词偏移量调整面板 */}\n\t\t\t<LyricsOffsetControl\n\t\t\t\tvisible={offsetMenuVisible}\n\t\t\t\tanchor={offsetMenuAnchor}\n\t\t\t\toffset={tempOffset}\n\t\t\t\tonChangeOffset={handleChangeOffset}\n\t\t\t\tonClose={handleCloseOffsetMenu}\n\t\t\t/>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tpendingContainer: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\terrorScrollView: {\n\t\tflex: 1,\n\t\tmarginHorizontal: 30,\n\t},\n\terrorContentContainer: {\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t\tmarginTop: 40, // 不被渐变 mask 遮挡到\n\t},\n\terrorText: {\n\t\ttextAlign: 'center',\n\t},\n\trawLyricsScrollViewContainer: {\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\trawLyricsText: {\n\t\ttextAlign: 'center',\n\t},\n\tlyricsContainer: {\n\t\tflex: 1,\n\t},\n\tlyricsContent: {\n\t\tflex: 1,\n\t\tflexDirection: 'column',\n\t},\n\tgradient: {\n\t\theight: 60,\n\t},\n})\n\nLyrics.displayName = 'Lyrics'\n\nexport default Lyrics\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerMainTab.tsx",
    "content": "import type { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport type { ImageRef } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { memo, type RefObject } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { ScrollView } from 'react-native-gesture-handler'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport * as Haptics from '@/utils/haptics'\n\nimport { PlayerControls } from './PlayerControls'\nimport { PlayerSlider } from './PlayerSlider'\nimport { TrackInfo } from './PlayerTrackInfo'\n\ninterface PlayerMainTabProps {\n\tsheetRef: RefObject<TrueSheet | null>\n\tjumpTo: (key: string) => void\n\timageRef: ImageRef | null\n\tonPresent: () => void\n\tdanmakuEnabled: boolean\n}\n\nconst PlayerMainTab = memo(function PlayerMainTab({\n\tsheetRef,\n\tjumpTo,\n\timageRef,\n\tonPresent,\n\tdanmakuEnabled,\n}: PlayerMainTabProps) {\n\tconst router = useRouter()\n\tconst insets = useSafeAreaInsets()\n\tconst currentTrack = useCurrentTrack()\n\n\tif (!currentTrack) return null\n\treturn (\n\t\t<ScrollView\n\t\t\tcontentContainerStyle={styles.container}\n\t\t\tshowsVerticalScrollIndicator={false}\n\t\t>\n\t\t\t<TrackInfo\n\t\t\t\tonArtistPress={() =>\n\t\t\t\t\tcurrentTrack.artist?.remoteId\n\t\t\t\t\t\t? router.push({\n\t\t\t\t\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\t\t\t\t\tparams: { mid: currentTrack.artist?.remoteId },\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: void 0\n\t\t\t\t}\n\t\t\t\tonPressCover={() => {\n\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Context_Click)\n\t\t\t\t\tjumpTo('lyrics')\n\t\t\t\t}}\n\t\t\t\tcoverRef={imageRef}\n\t\t\t\tdanmakuEnabled={danmakuEnabled}\n\t\t\t/>\n\n\t\t\t<View\n\t\t\t\tstyle={[\n\t\t\t\t\t{ paddingBottom: Math.max(insets.bottom + 20, 20) },\n\t\t\t\t\tstyles.controlsContainer,\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<PlayerSlider />\n\t\t\t\t<PlayerControls\n\t\t\t\t\tonOpenQueue={() => {\n\t\t\t\t\t\tonPresent()\n\t\t\t\t\t\tsheetRef.current?.present().catch(() => {\n\t\t\t\t\t\t\t// Ignore error\n\t\t\t\t\t\t})\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</ScrollView>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflexGrow: 1,\n\t\tjustifyContent: 'space-between',\n\t},\n\tcontrolsContainer: {\n\t\tpaddingHorizontal: 24,\n\t},\n})\n\nPlayerMainTab.displayName = 'PlayerMainTab'\nexport default PlayerMainTab\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerSlider.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler'\nimport { Text, useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedReaction,\n\tuseAnimatedStyle,\n\tuseDerivedValue,\n\tuseSharedValue,\n\twithSpring,\n\twithTiming,\n\ttype SharedValue,\n} from 'react-native-reanimated'\nimport { scheduleOnRN } from 'react-native-worklets'\n\nimport useSmoothProgress from '@/hooks/player/useSmoothProgress'\nimport * as Haptics from '@/utils/haptics'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nconst THUMB_SIZE = 12\n\nfunction TextWithAnimation({\n\tsharedPosition,\n\tsharedDuration,\n}: {\n\tsharedPosition: SharedValue<number>\n\tsharedDuration: SharedValue<number>\n}) {\n\tconst { colors } = useTheme()\n\tconst [duration, setDuration] = useState(0)\n\tconst [position, setPosition] = useState(0)\n\n\tuseAnimatedReaction(\n\t\t() => {\n\t\t\tconst truncDuration = sharedDuration.value\n\t\t\t\t? Math.trunc(sharedDuration.value)\n\t\t\t\t: 0\n\t\t\tconst truncPosition = sharedPosition.value\n\t\t\t\t? Math.trunc(sharedPosition.value)\n\t\t\t\t: 0\n\t\t\treturn [truncDuration, truncPosition]\n\t\t},\n\t\t([curDuration, curPosition], prev) => {\n\t\t\tif (!prev) {\n\t\t\t\tscheduleOnRN(setDuration, curDuration)\n\t\t\t\tscheduleOnRN(setPosition, curPosition)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (curDuration !== prev[0]) {\n\t\t\t\tscheduleOnRN(setDuration, curDuration)\n\t\t\t}\n\t\t\tif (curPosition !== prev[1]) {\n\t\t\t\tscheduleOnRN(setPosition, curPosition)\n\t\t\t}\n\t\t},\n\t)\n\n\treturn (\n\t\t<>\n\t\t\t<Text\n\t\t\t\tvariant='bodySmall'\n\t\t\t\tnumberOfLines={1}\n\t\t\t\tadjustsFontSizeToFit\n\t\t\t\tstyle={{\n\t\t\t\t\tcolor: colors.onSurfaceVariant,\n\t\t\t\t\tfontVariant: ['tabular-nums'],\n\t\t\t\t\tincludeFontPadding: false,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{formatDurationToHHMMSS(position)}\n\t\t\t</Text>\n\t\t\t<Text\n\t\t\t\tvariant='bodySmall'\n\t\t\t\tnumberOfLines={1}\n\t\t\t\tadjustsFontSizeToFit\n\t\t\t\tstyle={{\n\t\t\t\t\tcolor: colors.onSurfaceVariant,\n\t\t\t\t\tfontVariant: ['tabular-nums'],\n\t\t\t\t\tincludeFontPadding: false,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{formatDurationToHHMMSS(duration)}\n\t\t\t</Text>\n\t\t</>\n\t)\n}\n\ninterface PlayerSliderProps {\n\tonInteraction?: () => void\n}\n\nexport function PlayerSlider({ onInteraction }: PlayerSliderProps = {}) {\n\tconst { colors } = useTheme()\n\tconst { position, duration, buffered } = useSmoothProgress()\n\n\tconst containerWidth = useSharedValue(0)\n\tconst isScrubbing = useSharedValue(false)\n\tconst scrubPosition = useSharedValue(0)\n\tconst isSeeking = useSharedValue(false)\n\tconst seekPosition = useSharedValue(0)\n\tconst seekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\tconst sliderContainerRef = useRef<View>(null)\n\n\tconst displayPosition = useDerivedValue(() => {\n\t\tif (isScrubbing.value) return scrubPosition.value\n\t\tif (isSeeking.value) return seekPosition.value\n\t\treturn position.value\n\t})\n\n\tconst handleSeek = useCallback(\n\t\t(time: number) => {\n\t\t\tif (seekTimeoutRef.current) clearTimeout(seekTimeoutRef.current)\n\t\t\tisSeeking.set(true)\n\t\t\tvoid Orpheus.seekTo(time)\n\n\t\t\tseekTimeoutRef.current = setTimeout(() => {\n\t\t\t\t// 获取实际播放位置并同步，避免暂停状态下 position 未更新导致进度条回退\n\t\t\t\tvoid Orpheus.getPosition().then((actualPosition) => {\n\t\t\t\t\tposition.set(actualPosition)\n\t\t\t\t\tisSeeking.set(false)\n\t\t\t\t\tseekTimeoutRef.current = null\n\t\t\t\t})\n\t\t\t}, 5000)\n\t\t},\n\t\t[isSeeking, position],\n\t)\n\n\tuseAnimatedReaction(\n\t\t() => position.value,\n\t\t(currentPosition) => {\n\t\t\tif (!isSeeking.value) return\n\t\t\tconst target = seekPosition.value\n\t\t\tconst threshold = 1\n\t\t\tconst diff = Math.abs(currentPosition - target)\n\t\t\tif (diff < threshold) {\n\t\t\t\tisSeeking.set(false)\n\t\t\t}\n\t\t},\n\t\t[position, isSeeking, seekPosition],\n\t)\n\n\tconst progress = useDerivedValue(() => {\n\t\tconst dur = duration.value || 1\n\t\tlet pos = position.value\n\t\tif (isScrubbing.value) {\n\t\t\tpos = scrubPosition.value\n\t\t} else if (isSeeking.value) {\n\t\t\tpos = seekPosition.value\n\t\t}\n\t\treturn Math.min(Math.max(pos / dur, 0), 1)\n\t})\n\n\tconst trackHeight = useDerivedValue(() => {\n\t\treturn withTiming(isScrubbing.value ? 12 : 4, { duration: 200 })\n\t})\n\n\tuseLayoutEffect(() => {\n\t\tif (sliderContainerRef.current) {\n\t\t\tsliderContainerRef.current.measure((_x, _y, width) => {\n\t\t\t\tif (width > 0) {\n\t\t\t\t\tcontainerWidth.set(width)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}, [containerWidth])\n\n\tconst pan = useMemo(\n\t\t() =>\n\t\t\tGesture.Pan()\n\t\t\t\t.minDistance(1)\n\t\t\t\t.onBegin((e) => {\n\t\t\t\t\tif (containerWidth.value === 0) return\n\t\t\t\t\tisScrubbing.set(true)\n\t\t\t\t\tconst newProgress = Math.min(\n\t\t\t\t\t\tMath.max(e.x / containerWidth.value, 0),\n\t\t\t\t\t\t1,\n\t\t\t\t\t)\n\t\t\t\t\tscrubPosition.set(newProgress * (duration.value || 1))\n\t\t\t\t\tscheduleOnRN(\n\t\t\t\t\t\tHaptics.performHaptics,\n\t\t\t\t\t\tHaptics.AndroidHaptics.Drag_Start,\n\t\t\t\t\t)\n\t\t\t\t\tif (onInteraction) {\n\t\t\t\t\t\tscheduleOnRN(onInteraction)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.onUpdate((e) => {\n\t\t\t\t\tif (containerWidth.value === 0) return\n\t\t\t\t\tconst newProgress = Math.min(\n\t\t\t\t\t\tMath.max(e.x / containerWidth.value, 0),\n\t\t\t\t\t\t1,\n\t\t\t\t\t)\n\t\t\t\t\tscrubPosition.set(newProgress * (duration.value || 1))\n\t\t\t\t\tif (onInteraction) {\n\t\t\t\t\t\tscheduleOnRN(onInteraction)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.onFinalize(() => {\n\t\t\t\t\tif (containerWidth.value === 0) return\n\t\t\t\t\tconst targetTime = scrubPosition.value\n\n\t\t\t\t\tseekPosition.set(targetTime)\n\t\t\t\t\tisSeeking.set(true)\n\n\t\t\t\t\tscheduleOnRN(handleSeek, targetTime)\n\t\t\t\t\tscheduleOnRN(\n\t\t\t\t\t\tHaptics.performHaptics,\n\t\t\t\t\t\tHaptics.AndroidHaptics.Gesture_End,\n\t\t\t\t\t)\n\t\t\t\t\tif (onInteraction) {\n\t\t\t\t\t\tscheduleOnRN(onInteraction)\n\t\t\t\t\t}\n\n\t\t\t\t\tisScrubbing.set(false)\n\t\t\t\t})\n\t\t\t\t.hitSlop({ top: 20, bottom: 20, left: 20, right: 20 }),\n\t\t[\n\t\t\tcontainerWidth,\n\t\t\tisScrubbing,\n\t\t\tscrubPosition,\n\t\t\tduration,\n\t\t\tonInteraction,\n\t\t\tseekPosition,\n\t\t\tisSeeking,\n\t\t\thandleSeek,\n\t\t],\n\t)\n\n\tconst trackAnimatedStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\theight: trackHeight.value,\n\t\t\tborderRadius: trackHeight.value / 2,\n\t\t\toverflow: 'hidden',\n\t\t}\n\t})\n\n\tconst activeTrackInnerStyle = useAnimatedStyle(() => {\n\t\tconst translateX = (progress.value - 1) * containerWidth.value\n\t\treturn {\n\t\t\ttransform: [{ translateX }],\n\t\t\twidth: containerWidth.value,\n\t\t\theight: '100%',\n\t\t}\n\t})\n\n\tconst bufferedProgress = useDerivedValue(() => {\n\t\tconst dur = duration.value || 1\n\t\tconst buf = buffered.value\n\t\treturn Math.min(Math.max(buf / dur, 0), 1)\n\t})\n\n\tconst bufferedTrackInnerStyle = useAnimatedStyle(() => {\n\t\tconst translateX = (bufferedProgress.value - 1) * containerWidth.value\n\t\treturn {\n\t\t\ttransform: [{ translateX }],\n\t\t\twidth: containerWidth.value,\n\t\t\theight: '100%',\n\t\t}\n\t})\n\n\tconst thumbAnimatedStyle = useAnimatedStyle(() => {\n\t\tconst translateX = progress.value * containerWidth.value - THUMB_SIZE / 2\n\t\treturn {\n\t\t\ttransform: [\n\t\t\t\t{ translateX },\n\t\t\t\t{ scale: withSpring(isScrubbing.value ? 1.5 : 1) },\n\t\t\t],\n\t\t\topacity: containerWidth.value > 0 ? 1 : 0,\n\t\t}\n\t})\n\n\treturn (\n\t\t<View style={styles.root}>\n\t\t\t<GestureDetector gesture={pan}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={styles.sliderContainer}\n\t\t\t\t\tref={sliderContainerRef}\n\t\t\t\t>\n\t\t\t\t\t<Animated.View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.track,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t\ttrackAnimatedStyle,\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Animated.View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.trackItem,\n\t\t\t\t\t\t\t\t{ backgroundColor: colors.inverseSurface, opacity: 0.3 },\n\t\t\t\t\t\t\t\tbufferedTrackInnerStyle,\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<Animated.View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.trackItem,\n\t\t\t\t\t\t\t\t{ backgroundColor: colors.primary },\n\t\t\t\t\t\t\t\tactiveTrackInnerStyle,\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Animated.View>\n\n\t\t\t\t\t<Animated.View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.thumb,\n\t\t\t\t\t\t\t{ backgroundColor: colors.primary },\n\t\t\t\t\t\t\tthumbAnimatedStyle,\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</GestureDetector>\n\n\t\t\t<View style={styles.timeContainer}>\n\t\t\t\t<TextWithAnimation\n\t\t\t\t\tsharedPosition={displayPosition}\n\t\t\t\t\tsharedDuration={duration}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\troot: {\n\t\twidth: '100%',\n\t\tjustifyContent: 'center',\n\t},\n\tsliderContainer: {\n\t\theight: 40,\n\t\tjustifyContent: 'center',\n\t\twidth: '90%',\n\t\talignSelf: 'center',\n\t},\n\ttimeContainer: {\n\t\tmarginTop: 4,\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\twidth: '90%',\n\t\talignSelf: 'center',\n\t},\n\ttrack: {\n\t\tposition: 'absolute',\n\t\twidth: '100%',\n\t\tleft: 0,\n\t},\n\tthumb: {\n\t\tposition: 'absolute',\n\t\twidth: THUMB_SIZE,\n\t\theight: THUMB_SIZE,\n\t\tborderRadius: THUMB_SIZE / 2,\n\t\tleft: 0,\n\t},\n\ttrackItem: {\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\ttop: 0,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/PlayerTrackInfo.tsx",
    "content": "import { useIsPlaying } from '@bbplayer/orpheus'\nimport type { ImageRef } from 'expo-image'\nimport { Image } from 'expo-image'\nimport { LinearGradient } from 'expo-linear-gradient'\nimport { useState } from 'react'\nimport type { ColorSchemeName } from 'react-native'\nimport {\n\tDimensions,\n\tPressable,\n\tStyleSheet,\n\tTouchableOpacity,\n\tuseColorScheme,\n\tView,\n} from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { Text, TouchableRipple, useTheme } from 'react-native-paper'\n\nimport IconButton from '@/components/common/IconButton'\nimport { useThumbUpVideo } from '@/hooks/mutations/bilibili/video'\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useGetVideoIsThumbUp } from '@/hooks/queries/bilibili/video'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { getGradientColors } from '@/utils/color'\n\nimport { DanmakuView } from './danmaku/DanmakuView'\nimport { SpectrumVisualizer } from './SpectrumVisualizer'\n\nconst { width: screenWidth } = Dimensions.get('window')\n\nconst COVER_SIZE_RECT = screenWidth - 80\nconst COVER_SIZE_CIRCLE = screenWidth - 120\n\nexport function TrackInfo({\n\tonArtistPress,\n\tonPressCover,\n\tcoverRef,\n\tdanmakuEnabled,\n}: {\n\tonArtistPress: () => void\n\tonPressCover: () => void\n\tcoverRef: ImageRef | null\n\tdanmakuEnabled: boolean\n}) {\n\tconst { colors } = useTheme()\n\tconst colorScheme: ColorSchemeName = useColorScheme()\n\tconst isDark: boolean = colorScheme === 'dark'\n\tconst [size, setSize] = useState({ width: 0, height: 0 })\n\tconst enableDanmaku = useAppStore((state) => state.settings.enableDanmaku)\n\n\tconst currentTrack = useCurrentTrack()\n\tconst isPlaying = useIsPlaying()\n\n\tconst enableSpectrumVisualizer = useAppStore(\n\t\t(state) => state.settings.enableSpectrumVisualizer,\n\t)\n\n\tconst { data: isThumbUp, isPending: isThumbUpPending } = useGetVideoIsThumbUp(\n\t\tcurrentTrack?.source === 'bilibili'\n\t\t\t? currentTrack?.bilibiliMetadata.bvid\n\t\t\t: undefined,\n\t)\n\tconst { mutate: doThumbUpAction } = useThumbUpVideo()\n\n\tconst isBilibiliVideo = currentTrack?.source === 'bilibili'\n\n\tconst { color1, color2 } = getGradientColors(\n\t\tcurrentTrack?.title ?? '',\n\t\tisDark,\n\t)\n\n\tconst firstChar =\n\t\tcurrentTrack &&\n\t\t(currentTrack.title.length > 0\n\t\t\t? currentTrack?.title.charAt(0).toUpperCase()\n\t\t\t: undefined)\n\n\tconst coverSize = enableSpectrumVisualizer\n\t\t? COVER_SIZE_CIRCLE\n\t\t: COVER_SIZE_RECT\n\tconst coverBorderRadius = enableSpectrumVisualizer\n\t\t? coverSize / 2\n\t\t: COVER_SIZE_RECT * 0.22\n\n\tconst onThumbUpPress = () => {\n\t\tif (isThumbUpPending || !isBilibiliVideo || !currentTrack) return\n\t\tdoThumbUpAction({\n\t\t\tbvid: currentTrack.bilibiliMetadata.bvid,\n\t\t\tlike: !isThumbUp,\n\t\t})\n\t}\n\n\tif (!currentTrack) return null\n\n\treturn (\n\t\t<View\n\t\t\tonLayout={(e) => {\n\t\t\t\tconst { width, height } = e.nativeEvent.layout\n\t\t\t\tsetSize({ width, height })\n\t\t\t}}\n\t\t\tstyle={{\n\t\t\t\tposition: 'relative',\n\t\t\t}}\n\t\t>\n\t\t\t<Pressable\n\t\t\t\tstyle={styles.coverContainer}\n\t\t\t\tonPress={onPressCover}\n\t\t\t>\n\t\t\t\t{enableSpectrumVisualizer && (\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tStyleSheet.absoluteFill,\n\t\t\t\t\t\t\t{ alignItems: 'center', justifyContent: 'center' },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<SpectrumVisualizer\n\t\t\t\t\t\t\tisPlaying={isPlaying}\n\t\t\t\t\t\t\tsize={coverSize}\n\t\t\t\t\t\t\tcolor={colors.primary}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t)}\n\t\t\t\t<TouchableOpacity\n\t\t\t\t\tactiveOpacity={0.8}\n\t\t\t\t\tonPress={onPressCover}\n\t\t\t\t\tstyle={{ width: coverSize, height: coverSize }}\n\t\t\t\t\ttestID='player-cover'\n\t\t\t\t>\n\t\t\t\t\t{!coverRef ? (\n\t\t\t\t\t\tenableSpectrumVisualizer ? (\n\t\t\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\t\t\tcolors={[color1, color2]}\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.coverGradient,\n\t\t\t\t\t\t\t\t\t{ borderRadius: coverBorderRadius },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\tstart={{ x: 0, y: 0 }}\n\t\t\t\t\t\t\t\tend={{ x: 1, y: 1 }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.coverPlaceholderText,\n\t\t\t\t\t\t\t\t\t\t{ fontSize: coverSize * 0.45 },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{firstChar}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</LinearGradient>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<SquircleView\n\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\tstyles.coverGradient,\n\t\t\t\t\t\t\t\t\t{ borderRadius: coverBorderRadius, overflow: 'hidden' },\n\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<LinearGradient\n\t\t\t\t\t\t\t\t\tcolors={[color1, color2]}\n\t\t\t\t\t\t\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\t\t\t\t\t\t\tstart={{ x: 0, y: 0 }}\n\t\t\t\t\t\t\t\t\tend={{ x: 1, y: 1 }}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.coverPlaceholderText,\n\t\t\t\t\t\t\t\t\t\t{ fontSize: coverSize * 0.45 },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{firstChar}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</SquircleView>\n\t\t\t\t\t\t)\n\t\t\t\t\t) : enableSpectrumVisualizer ? (\n\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\tsource={coverRef}\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\twidth: coverSize,\n\t\t\t\t\t\t\t\theight: coverSize,\n\t\t\t\t\t\t\t\tborderRadius: coverBorderRadius,\n\t\t\t\t\t\t\t\tzIndex: -1,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\trecyclingKey={currentTrack.uniqueKey}\n\t\t\t\t\t\t\tcachePolicy={'disk'}\n\t\t\t\t\t\t\ttransition={300}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<SquircleView\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\twidth: coverSize,\n\t\t\t\t\t\t\t\theight: coverSize,\n\t\t\t\t\t\t\t\tborderRadius: coverBorderRadius,\n\t\t\t\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\tsource={coverRef}\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\twidth: coverSize,\n\t\t\t\t\t\t\t\t\theight: coverSize,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\trecyclingKey={currentTrack.uniqueKey}\n\t\t\t\t\t\t\t\tcachePolicy={'disk'}\n\t\t\t\t\t\t\t\ttransition={300}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</SquircleView>\n\t\t\t\t\t)}\n\t\t\t\t</TouchableOpacity>\n\t\t\t\t{currentTrack.source === 'bilibili' &&\n\t\t\t\t\tenableDanmaku &&\n\t\t\t\t\tsize.width > 0 &&\n\t\t\t\t\tsize.height > 0 && (\n\t\t\t\t\t\t<DanmakuView\n\t\t\t\t\t\t\tbvid={currentTrack.bilibiliMetadata.bvid}\n\t\t\t\t\t\t\tcid={currentTrack.bilibiliMetadata.cid ?? undefined}\n\t\t\t\t\t\t\twidth={size.width}\n\t\t\t\t\t\t\theight={COVER_SIZE_RECT + 48}\n\t\t\t\t\t\t\tenable={danmakuEnabled}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t</Pressable>\n\n\t\t\t<View style={styles.trackInfoContainer}>\n\t\t\t\t<View style={styles.trackTitleContainer}>\n\t\t\t\t\t<View style={styles.trackTitleTextContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\tstyle={styles.trackTitle}\n\t\t\t\t\t\t\tnumberOfLines={4}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{currentTrack.title}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t{currentTrack.artist?.name && (\n\t\t\t\t\t\t\t<TouchableRipple onPress={onArtistPress}>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{currentTrack.artist.name}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</TouchableRipple>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</View>\n\t\t\t\t\t{isBilibiliVideo && (\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\ticon={isThumbUp ? 'heart' : 'heart-outline'}\n\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\ticonColor={isThumbUp ? colors.error : colors.onSurfaceVariant}\n\t\t\t\t\t\t\tonPress={onThumbUpPress}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcoverContainer: {\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\theight: COVER_SIZE_RECT + 48,\n\t\tpaddingHorizontal: 32,\n\t},\n\tcoverGradient: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tcoverPlaceholderText: {\n\t\tfontWeight: 'bold',\n\t\tcolor: 'rgba(255, 255, 255, 0.7)',\n\t},\n\ttrackInfoContainer: {\n\t\tpaddingHorizontal: 24,\n\t},\n\ttrackTitleContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t},\n\ttrackTitleTextContainer: {\n\t\tflex: 1,\n\t\tmarginRight: 8,\n\t},\n\ttrackTitle: {\n\t\tfontWeight: 'bold',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/SpectrumVisualizer.tsx",
    "content": "import { Orpheus, SPECTRUM_SIZE } from '@bbplayer/orpheus'\nimport { Canvas, Path, Skia } from '@shopify/react-native-skia'\nimport { useEffect, useRef, useState } from 'react'\nimport { AppState, PermissionsAndroid, Platform, View } from 'react-native'\nimport { useDerivedValue, useSharedValue } from 'react-native-reanimated'\n\nimport { alert } from '@/components/modals/AlertModal'\nimport useAppStore from '@/hooks/stores/useAppStore'\n\ninterface SpectrumVisualizerProps {\n\tisPlaying: boolean\n\tcolor?: string\n\tsize: number\n}\n\nconst BAR_COUNT = 60\nconst MAX_BAR_HEIGHT = 36\nconst SMOOTHING_FACTOR = 0.3\nconst GAP = 4\n\nexport const SpectrumVisualizer = ({\n\tisPlaying,\n\tcolor = 'white',\n\tsize,\n}: SpectrumVisualizerProps) => {\n\tconst frequencyData = useSharedValue<Float32Array>(\n\t\tnew Float32Array(BAR_COUNT).fill(0),\n\t)\n\tconst bufferRef = useRef(new Float32Array(SPECTRUM_SIZE))\n\tconst prevDataRef = useRef(new Float32Array(BAR_COUNT))\n\n\tconst [isAppActive, setIsAppActive] = useState(\n\t\tAppState.currentState === 'active',\n\t)\n\tconst [hasPermission, setHasPermission] = useState<boolean | null>(null)\n\n\tconst setSettings = useAppStore((state) => state.setSettings)\n\n\tuseEffect(() => {\n\t\tconst checkPermission = async () => {\n\t\t\tif (Platform.OS === 'android') {\n\t\t\t\tconst granted = await PermissionsAndroid.check(\n\t\t\t\t\tPermissionsAndroid.PERMISSIONS.RECORD_AUDIO,\n\t\t\t\t)\n\t\t\t\tsetHasPermission(granted)\n\n\t\t\t\tif (!granted) {\n\t\t\t\t\talert(\n\t\t\t\t\t\t'需要麦克风权限',\n\t\t\t\t\t\t'音频频谱功能需要访问麦克风以分析音频数据。请授予权限以继续使用此功能。',\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: '取消',\n\t\t\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t\t\tsetSettings({ enableSpectrumVisualizer: false })\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: '授权',\n\t\t\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t\t\tvoid PermissionsAndroid.request(\n\t\t\t\t\t\t\t\t\t\tPermissionsAndroid.PERMISSIONS.RECORD_AUDIO,\n\t\t\t\t\t\t\t\t\t).then((result) => {\n\t\t\t\t\t\t\t\t\t\tconst isGranted =\n\t\t\t\t\t\t\t\t\t\t\tresult === PermissionsAndroid.RESULTS.GRANTED\n\t\t\t\t\t\t\t\t\t\tsetHasPermission(isGranted)\n\t\t\t\t\t\t\t\t\t\tif (!isGranted) {\n\t\t\t\t\t\t\t\t\t\t\tsetSettings({ enableSpectrumVisualizer: false })\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\t{ cancelable: false },\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tsetHasPermission(true)\n\t\t\t}\n\t\t}\n\n\t\tvoid checkPermission()\n\t}, [setSettings])\n\n\tuseEffect(() => {\n\t\tconst subscription = AppState.addEventListener('change', (nextAppState) => {\n\t\t\tsetIsAppActive(nextAppState === 'active')\n\t\t})\n\n\t\treturn () => {\n\t\t\tsubscription.remove()\n\t\t}\n\t}, [])\n\n\tconst geometry = useDerivedValue(() => {\n\t\tconst center = size / 2 + MAX_BAR_HEIGHT\n\t\tconst radius = size / 2 + GAP\n\t\tconst result = new Float32Array(BAR_COUNT * 4)\n\n\t\tfor (let i = 0; i < BAR_COUNT; i++) {\n\t\t\tconst angle = (i / BAR_COUNT) * 2 * Math.PI - Math.PI / 2\n\t\t\tresult[i * 4] = center + radius * Math.cos(angle)\n\t\t\tresult[i * 4 + 1] = center + radius * Math.sin(angle)\n\t\t\tresult[i * 4 + 2] = Math.cos(angle)\n\t\t\tresult[i * 4 + 3] = Math.sin(angle)\n\t\t}\n\t\treturn result\n\t}, [size])\n\n\tconst path = useDerivedValue(() => {\n\t\tconst skPath = Skia.Path.Make()\n\t\tconst geo = geometry.value\n\t\tconst freq = frequencyData.value\n\n\t\tfor (let i = 0; i < BAR_COUNT; i++) {\n\t\t\tconst val = freq[i] || 0\n\t\t\tconst barHeight = Math.min(\n\t\t\t\tMath.max(val * MAX_BAR_HEIGHT, 4),\n\t\t\t\tMAX_BAR_HEIGHT,\n\t\t\t)\n\n\t\t\tconst px = geo[i * 4]\n\t\t\tconst py = geo[i * 4 + 1]\n\t\t\tconst nx = geo[i * 4 + 2]\n\t\t\tconst ny = geo[i * 4 + 3]\n\n\t\t\tskPath.moveTo(px, py)\n\t\t\tskPath.lineTo(px + nx * barHeight, py + ny * barHeight)\n\t\t}\n\n\t\treturn skPath\n\t}, [geometry])\n\n\tuseEffect(() => {\n\t\tif (!hasPermission) return\n\n\t\tlet animationFrameId: number\n\t\tlet lastFrameTime = 0\n\t\tconst TARGET_FPS = 30\n\t\tconst FRAME_INTERVAL = 1000 / TARGET_FPS\n\t\tconst DECAY_FACTOR = 0.9\n\n\t\tconst animate = (timestamp: number) => {\n\t\t\tif (!isAppActive) return\n\n\t\t\tconst elapsed = timestamp - lastFrameTime\n\n\t\t\tif (elapsed >= FRAME_INTERVAL) {\n\t\t\t\tlastFrameTime = timestamp - (elapsed % FRAME_INTERVAL)\n\n\t\t\t\tconst newData = new Float32Array(BAR_COUNT)\n\t\t\t\tconst rawData = bufferRef.current\n\t\t\t\tlet hasSignal = false\n\n\t\t\t\tif (isPlaying) {\n\t\t\t\t\tOrpheus.updateSpectrumData(rawData)\n\t\t\t\t\tconst halfCount = BAR_COUNT / 2\n\n\t\t\t\t\tfor (let i = 0; i < halfCount; i++) {\n\t\t\t\t\t\tconst t = i / (halfCount - 1)\n\t\t\t\t\t\tconst startBin = Math.floor(t * t * (SPECTRUM_SIZE - 1))\n\t\t\t\t\t\tconst tNext = (i + 1) / (halfCount - 1)\n\t\t\t\t\t\tconst endBin = Math.floor(tNext * tNext * (SPECTRUM_SIZE - 1))\n\t\t\t\t\t\tconst actualEndBin = Math.max(endBin, startBin + 1)\n\n\t\t\t\t\t\tlet sum = 0\n\t\t\t\t\t\tlet count = 0\n\t\t\t\t\t\tfor (let j = startBin; j < actualEndBin && j < SPECTRUM_SIZE; j++) {\n\t\t\t\t\t\t\tsum += rawData[j]\n\t\t\t\t\t\t\tcount++\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tlet val = 0\n\t\t\t\t\t\tif (count > 0) {\n\t\t\t\t\t\t\tconst magnitude = sum / count\n\t\t\t\t\t\t\tconst db = 20 * Math.log10(magnitude + 0.0001)\n\t\t\t\t\t\t\tconst minDb = -60\n\t\t\t\t\t\t\tconst maxDb = 0\n\t\t\t\t\t\t\tval = (db - minDb) / (maxDb - minDb)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (val < 0) val = 0\n\t\t\t\t\t\tif (val > 1.0) val = 1.0\n\n\t\t\t\t\t\tconst mirrorIdx = BAR_COUNT - 1 - i\n\n\t\t\t\t\t\tconst smoothL =\n\t\t\t\t\t\t\tprevDataRef.current[i] * SMOOTHING_FACTOR +\n\t\t\t\t\t\t\tval * (1 - SMOOTHING_FACTOR)\n\t\t\t\t\t\tprevDataRef.current[i] = smoothL\n\t\t\t\t\t\tnewData[i] = smoothL\n\n\t\t\t\t\t\tconst smoothR =\n\t\t\t\t\t\t\tprevDataRef.current[mirrorIdx] * SMOOTHING_FACTOR +\n\t\t\t\t\t\t\tval * (1 - SMOOTHING_FACTOR)\n\t\t\t\t\t\tprevDataRef.current[mirrorIdx] = smoothR\n\t\t\t\t\t\tnewData[mirrorIdx] = smoothR\n\n\t\t\t\t\t\tif (smoothL > 0.001 || smoothR > 0.001) {\n\t\t\t\t\t\t\thasSignal = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Decay logic when paused\n\t\t\t\t\tfor (let i = 0; i < BAR_COUNT; i++) {\n\t\t\t\t\t\tconst decayed = prevDataRef.current[i] * DECAY_FACTOR\n\t\t\t\t\t\tif (decayed > 0.001) {\n\t\t\t\t\t\t\tprevDataRef.current[i] = decayed\n\t\t\t\t\t\t\tnewData[i] = decayed\n\t\t\t\t\t\t\thasSignal = true\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tprevDataRef.current[i] = 0\n\t\t\t\t\t\t\tnewData[i] = 0\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfrequencyData.set(newData)\n\n\t\t\t\t// If no signal and not playing, stop the loop to save resources\n\t\t\t\tif (!isPlaying && !hasSignal) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tanimationFrameId = requestAnimationFrame(animate)\n\t\t}\n\n\t\tif (isAppActive) {\n\t\t\tanimationFrameId = requestAnimationFrame(animate)\n\t\t}\n\n\t\treturn () => {\n\t\t\tif (animationFrameId) {\n\t\t\t\tcancelAnimationFrame(animationFrameId)\n\t\t\t}\n\t\t}\n\t}, [frequencyData, isPlaying, isAppActive, hasPermission])\n\n\tif (hasPermission !== true) {\n\t\treturn null\n\t}\n\n\tconst containerSize = size + MAX_BAR_HEIGHT * 2\n\n\treturn (\n\t\t<View\n\t\t\tstyle={{\n\t\t\t\twidth: containerSize,\n\t\t\t\theight: containerSize,\n\t\t\t\tpointerEvents: 'none',\n\t\t\t}}\n\t\t>\n\t\t\t<Canvas style={{ flex: 1 }}>\n\t\t\t\t<Path\n\t\t\t\t\tpath={path}\n\t\t\t\t\tcolor={color}\n\t\t\t\t\tstyle='stroke'\n\t\t\t\t\tstrokeWidth={4}\n\t\t\t\t\tstrokeCap='round'\n\t\t\t\t\topacity={0.6}\n\t\t\t\t/>\n\t\t\t</Canvas>\n\t\t</View>\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/danmaku/DanmakuView.tsx",
    "content": "import { useIsPlaying } from '@bbplayer/orpheus'\nimport { Canvas, Skia, Picture, FontStyle } from '@shopify/react-native-skia'\nimport { useEffect } from 'react'\nimport { Platform, StyleSheet, View } from 'react-native'\nimport {\n\tuseAnimatedReaction,\n\tuseDerivedValue,\n\tuseSharedValue,\n} from 'react-native-reanimated'\nimport { scheduleOnRN } from 'react-native-worklets'\n\nimport useDanmakuLoader from '@/features/player/hooks/danmaku/useDanmakuLoader'\nimport { useDanmakuRender } from '@/features/player/hooks/danmaku/useDanmakuRender'\nimport useSmoothProgress from '@/hooks/player/useSmoothProgress'\n\ninterface DanmakuViewProps {\n\tbvid: string\n\tcid: number | undefined\n\twidth: number\n\theight: number\n\tenable: boolean\n}\n\nconst fontMgr = Skia.FontMgr.System()\nconst familyName = Platform.select({\n\tios: 'PingFang SC',\n\tandroid: 'sans-serif',\n\tdefault: 'sans-serif',\n})\nconst typeface = fontMgr.matchFamilyStyle(familyName, FontStyle.Bold)\nconst customFontMgr = Skia.TypefaceFontProvider.Make()\ncustomFontMgr.registerFont(typeface, 'BBPlayerFont')\n\nexport const DanmakuView = ({\n\tbvid,\n\tcid,\n\twidth,\n\theight,\n\tenable,\n}: DanmakuViewProps) => {\n\tconst { position } = useSmoothProgress()\n\tconst currentTimeMs = useDerivedValue(() => position.value * 1000)\n\tconst isPlaying = useIsPlaying()\n\n\tconst loaderTime = useSharedValue(0)\n\n\tconst { rawDataSV } = useDanmakuLoader(bvid, cid, loaderTime)\n\n\tconst { picture, resetEngine } = useDanmakuRender({\n\t\trawDataSV,\n\t\tcurrentTime: currentTimeMs,\n\t\tisPlaying,\n\t\tfontMgr: customFontMgr,\n\t\twidth,\n\t\theight,\n\t\tfontFamilyName: 'BBPlayerFont',\n\t\tenabled: enable,\n\t})\n\n\tuseEffect(() => {\n\t\tresetEngine(0)\n\t}, [bvid, cid, resetEngine])\n\n\tuseAnimatedReaction(\n\t\t() => position.value,\n\t\t(current, previous) => {\n\t\t\tif (previous === null) return\n\n\t\t\tconst diff = Math.abs(current - previous)\n\t\t\tif (diff > 1.0) {\n\t\t\t\tscheduleOnRN(resetEngine, current * 1000)\n\t\t\t\tloaderTime.set(current * 1000)\n\t\t\t} else {\n\t\t\t\tconst currentInt = Math.floor(current)\n\t\t\t\tif (currentInt % 5 === 0 && Math.floor(previous) !== currentInt) {\n\t\t\t\t\tloaderTime.set(current * 1000)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[position],\n\t)\n\n\treturn (\n\t\t<View\n\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\tpointerEvents='none'\n\t\t>\n\t\t\t<Canvas style={{ flex: 1 }}>\n\t\t\t\t<Picture picture={picture} />\n\t\t\t</Canvas>\n\t\t</View>\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/lyrics/KaraokeWord.tsx",
    "content": "import type { LyricSpan } from '@bbplayer/splash'\nimport { memo } from 'react'\nimport type { StyleProp, TextStyle } from 'react-native'\nimport { StyleSheet, Text, View } from 'react-native'\nimport Animated, {\n\tcreateAnimatedComponent,\n\tExtrapolation,\n\tinterpolate,\n\ttype SharedValue,\n\tuseAnimatedReaction,\n\tuseAnimatedStyle,\n\tuseSharedValue,\n} from 'react-native-reanimated'\n\nconst AnimatedText = createAnimatedComponent(Text)\n\ninterface KaraokeWordProps {\n\tspan: LyricSpan\n\tcurrentTime: SharedValue<number>\n\tbaseStyle?: StyleProp<TextStyle>\n\tactiveColor: string\n\tinactiveColor: string\n\tisHighlighted: boolean\n}\n\nexport const KaraokeWord = memo(function KaraokeWord({\n\tspan,\n\tcurrentTime,\n\tbaseStyle,\n\tactiveColor,\n\tinactiveColor,\n\tisHighlighted,\n}: KaraokeWordProps) {\n\tconst localProgress = useSharedValue(0)\n\tconst layoutWidth = useSharedValue(0)\n\n\tuseAnimatedReaction(\n\t\t() => currentTime.value,\n\t\t(currentVal: number) => {\n\t\t\tif (!isHighlighted) {\n\t\t\t\tif (localProgress.value !== 0) {\n\t\t\t\t\tlocalProgress.set(0)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst timeMs = currentVal * 1000\n\t\t\tif (timeMs < span.startTime) {\n\t\t\t\tlocalProgress.set(0)\n\t\t\t} else if (timeMs > span.endTime) {\n\t\t\t\tlocalProgress.set(1)\n\t\t\t} else {\n\t\t\t\tlocalProgress.set(\n\t\t\t\t\tinterpolate(\n\t\t\t\t\t\ttimeMs,\n\t\t\t\t\t\t[span.startTime, span.endTime],\n\t\t\t\t\t\t[0, 1],\n\t\t\t\t\t\tExtrapolation.CLAMP,\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\t\t},\n\t\t[isHighlighted, span],\n\t)\n\n\tconst maskStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\twidth: layoutWidth.value * localProgress.value,\n\t\t\topacity: isHighlighted ? 1 : 0,\n\t\t}\n\t})\n\n\tconst activeTextStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\twidth: layoutWidth.value,\n\t\t\tcolor: activeColor,\n\t\t}\n\t})\n\n\treturn (\n\t\t<View\n\t\t\tstyle={styles.container}\n\t\t\tonLayout={(e) => {\n\t\t\t\tlayoutWidth.set(e.nativeEvent.layout.width)\n\t\t\t}}\n\t\t>\n\t\t\t<Text\n\t\t\t\tstyle={[baseStyle, { color: inactiveColor }]}\n\t\t\t\tnumberOfLines={1}\n\t\t\t>\n\t\t\t\t{span.text}\n\t\t\t</Text>\n\n\t\t\t<Animated.View style={[styles.mask, maskStyle]}>\n\t\t\t\t{isHighlighted && (\n\t\t\t\t\t<AnimatedText\n\t\t\t\t\t\tstyle={[baseStyle, activeTextStyle]}\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{span.text}\n\t\t\t\t\t</AnimatedText>\n\t\t\t\t)}\n\t\t\t</Animated.View>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tposition: 'relative',\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tmask: {\n\t\t...StyleSheet.absoluteFill,\n\t\toverflow: 'hidden',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/lyrics/LyricActionSheet.tsx",
    "content": "import { Menu } from 'react-native-paper'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\n\ninterface Props {\n\tvisible: boolean\n\tanchor: { x: number; y: number } | null\n\tonDismiss: () => void\n\tshowTranslationToggle: boolean\n\ttranslationType: 'translation' | 'romaji'\n\tonToggleTranslation: () => void\n\tonEditLyrics: () => void\n\tonOpenOffsetMenu: () => void\n}\n\nexport function LyricActionSheet({\n\tvisible,\n\tanchor,\n\tonDismiss,\n\tshowTranslationToggle,\n\ttranslationType,\n\tonToggleTranslation,\n\tonEditLyrics,\n\tonOpenOffsetMenu,\n}: Props) {\n\tif (!anchor) return null\n\n\treturn (\n\t\t<FunctionalMenu\n\t\t\tvisible={visible}\n\t\t\tonDismiss={onDismiss}\n\t\t\tanchor={anchor}\n\t\t\tstatusBarHeight={0}\n\t\t>\n\t\t\t{showTranslationToggle && (\n\t\t\t\t<Menu.Item\n\t\t\t\t\ttitle={translationType === 'translation' ? '切换罗马音' : '切换翻译'}\n\t\t\t\t\tleadingIcon={\n\t\t\t\t\t\ttranslationType === 'translation'\n\t\t\t\t\t\t\t? 'alphabetical-variant'\n\t\t\t\t\t\t\t: 'translate'\n\t\t\t\t\t}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tonToggleTranslation()\n\t\t\t\t\t\tonDismiss()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t<Menu.Item\n\t\t\t\ttitle='编辑歌词'\n\t\t\t\tleadingIcon='pencil'\n\t\t\t\tonPress={() => {\n\t\t\t\t\tonEditLyrics()\n\t\t\t\t\tonDismiss()\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<Menu.Item\n\t\t\t\ttitle='时间轴偏移'\n\t\t\t\tleadingIcon='swap-vertical-circle-outline'\n\t\t\t\tonPress={() => {\n\t\t\t\t\tonOpenOffsetMenu()\n\t\t\t\t\tonDismiss()\n\t\t\t\t}}\n\t\t\t/>\n\t\t</FunctionalMenu>\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/lyrics/LyricLineItem.tsx",
    "content": "import { type LyricLine } from '@bbplayer/splash'\nimport { memo, useEffect } from 'react'\nimport { Pressable, StyleSheet, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { useTheme } from 'react-native-paper'\nimport Animated, {\n\tcreateAnimatedComponent,\n\ttype SharedValue,\n\tuseAnimatedStyle,\n\tuseDerivedValue,\n\tuseSharedValue,\n\twithTiming,\n} from 'react-native-reanimated'\n\nimport { KaraokeWord } from './KaraokeWord'\n\nconst AnimatedRectButton = createAnimatedComponent(RectButton)\n\nexport interface LyricLineItemProps {\n\titem: LyricLine & { isPaddingItem?: boolean }\n\tisHighlighted: boolean\n\tjumpToThisLyric: (index: number) => void\n\tindex: number\n\tonPressBackground?: (() => void) | undefined\n\tcurrentTime: SharedValue<number>\n\tenableVerbatimLyrics: boolean\n\tpreferredLyricType?: 'translation' | 'romaji'\n}\n\nexport const OldSchoolLyricLineItem = memo(function OldSchoolLyricLineItem({\n\titem,\n\tisHighlighted,\n\tjumpToThisLyric,\n\tindex,\n\tonPressBackground,\n\tcurrentTime,\n\tenableVerbatimLyrics,\n\tpreferredLyricType = 'translation',\n}: LyricLineItemProps) {\n\tconst colors = useTheme().colors\n\tconst isHighlightedShared = useSharedValue(isHighlighted)\n\n\tuseEffect(() => {\n\t\tisHighlightedShared.set(isHighlighted)\n\t}, [isHighlighted, item.startTime, index, isHighlightedShared])\n\n\tconst gatedCurrentTime = useDerivedValue(() => {\n\t\treturn isHighlightedShared.value ? currentTime.value : -1\n\t})\n\n\tconst isVerbatim = !!(\n\t\tenableVerbatimLyrics &&\n\t\titem.isDynamic &&\n\t\titem.spans &&\n\t\titem.spans.length > 0\n\t)\n\n\tconst animatedStyle = useAnimatedStyle(() => {\n\t\tconst duration = isVerbatim ? 0 : 300\n\t\tif (isHighlightedShared.value) {\n\t\t\treturn {\n\t\t\t\topacity: withTiming(1, { duration }),\n\t\t\t\tcolor: withTiming(colors.primary, { duration }),\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\topacity: withTiming(0.7, { duration }),\n\t\t\tcolor: withTiming(colors.onSurfaceDisabled, { duration }),\n\t\t}\n\t})\n\n\tconst subText =\n\t\tpreferredLyricType === 'romaji'\n\t\t\t? item.romaji || item.translation || item.translations?.[0]\n\t\t\t: item.translation || item.romaji || item.translations?.[0]\n\n\treturn (\n\t\t<View style={styles.oldSchoolItemWrapper}>\n\t\t\t<Pressable\n\t\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\t\tonPress={onPressBackground}\n\t\t\t/>\n\t\t\t<RectButton\n\t\t\t\tstyle={styles.oldSchoolItemButton}\n\t\t\t\tonPress={() => jumpToThisLyric(index)}\n\t\t\t>\n\t\t\t\t{isVerbatim ? (\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\t\t\tflexWrap: 'wrap',\n\t\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.spans.map((span, idx) => (\n\t\t\t\t\t\t\t<KaraokeWord\n\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\tkey={`${index}_${idx}`}\n\t\t\t\t\t\t\t\tspan={span}\n\t\t\t\t\t\t\t\tcurrentTime={gatedCurrentTime}\n\t\t\t\t\t\t\t\tbaseStyle={styles.oldSchoolItemText}\n\t\t\t\t\t\t\t\tactiveColor={colors.primary}\n\t\t\t\t\t\t\t\tinactiveColor={colors.onSurfaceDisabled}\n\t\t\t\t\t\t\t\tisHighlighted={isHighlighted}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</View>\n\t\t\t\t) : (\n\t\t\t\t\t<Animated.Text style={[styles.oldSchoolItemText, animatedStyle]}>\n\t\t\t\t\t\t{item.content}\n\t\t\t\t\t</Animated.Text>\n\t\t\t\t)}\n\t\t\t\t{subText && (\n\t\t\t\t\t<Animated.Text\n\t\t\t\t\t\tstyle={[styles.oldSchoolItemTranslation, animatedStyle]}\n\t\t\t\t\t>\n\t\t\t\t\t\t{subText}\n\t\t\t\t\t</Animated.Text>\n\t\t\t\t)}\n\t\t\t</RectButton>\n\t\t</View>\n\t)\n})\n\nexport const ModernLyricLineItem = memo(function ModernLyricLineItem({\n\titem,\n\tisHighlighted,\n\tjumpToThisLyric,\n\tindex,\n\tonPressBackground,\n\tcurrentTime,\n\tenableVerbatimLyrics,\n\tpreferredLyricType = 'translation',\n}: LyricLineItemProps) {\n\tconst theme = useTheme()\n\tconst isHighlightedShared = useSharedValue(isHighlighted)\n\n\tuseEffect(() => {\n\t\tisHighlightedShared.set(isHighlighted)\n\t}, [isHighlighted, item.startTime, index, isHighlightedShared])\n\n\tconst gatedCurrentTime = useDerivedValue(() => {\n\t\treturn isHighlightedShared.value ? currentTime.value : -1\n\t})\n\n\tconst containerAnimatedStyle = useAnimatedStyle(() => {\n\t\tif (isHighlightedShared.value) {\n\t\t\treturn {\n\t\t\t\topacity: withTiming(1, { duration: 300 }),\n\t\t\t\ttransform: [\n\t\t\t\t\t{ scale: withTiming(1.05, { duration: 300 }) },\n\t\t\t\t\t{ translateX: withTiming(12, { duration: 300 }) },\n\t\t\t\t],\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\topacity: withTiming(0.7, { duration: 300 }),\n\t\t\ttransform: [\n\t\t\t\t{ scale: withTiming(1, { duration: 300 }) },\n\t\t\t\t{ translateX: withTiming(0, { duration: 300 }) },\n\t\t\t],\n\t\t}\n\t})\n\n\tconst isVerbatim = !!(\n\t\tenableVerbatimLyrics &&\n\t\titem.isDynamic &&\n\t\titem.spans &&\n\t\titem.spans.length > 0\n\t)\n\n\tconst textAnimatedStyle = useAnimatedStyle(() => {\n\t\tconst duration = isVerbatim ? 0 : 300\n\t\tif (isHighlightedShared.value) {\n\t\t\treturn {\n\t\t\t\tcolor: withTiming(theme.colors.primary, { duration }),\n\t\t\t}\n\t\t}\n\t\treturn {\n\t\t\tcolor: withTiming(theme.colors.onSurfaceDisabled, { duration }),\n\t\t}\n\t})\n\n\tconst renderContent = () => {\n\t\tif (isVerbatim) {\n\t\t\treturn (\n\t\t\t\t<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>\n\t\t\t\t\t{item.spans.map((span, idx) => (\n\t\t\t\t\t\t<KaraokeWord\n\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\tkey={`${index}_${idx}`}\n\t\t\t\t\t\t\tspan={span}\n\t\t\t\t\t\t\tcurrentTime={gatedCurrentTime}\n\t\t\t\t\t\t\tbaseStyle={styles.modernItemText}\n\t\t\t\t\t\t\tactiveColor={theme.colors.primary}\n\t\t\t\t\t\t\tinactiveColor={theme.colors.onSurfaceDisabled}\n\t\t\t\t\t\t\tisHighlighted={isHighlighted}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</View>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<Animated.Text style={[styles.modernItemText, textAnimatedStyle]}>\n\t\t\t\t{item.content}\n\t\t\t</Animated.Text>\n\t\t)\n\t}\n\n\tconst subText =\n\t\tpreferredLyricType === 'romaji'\n\t\t\t? item.romaji || item.translation || item.translations?.[0]\n\t\t\t: item.translation || item.romaji || item.translations?.[0]\n\n\treturn (\n\t\t<View style={styles.modernItemWrapper}>\n\t\t\t<Pressable\n\t\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\t\tonPress={onPressBackground}\n\t\t\t/>\n\t\t\t<AnimatedRectButton\n\t\t\t\tstyle={[styles.modernItemButton, containerAnimatedStyle]}\n\t\t\t\tonPress={() => jumpToThisLyric(index)}\n\t\t\t>\n\t\t\t\t{renderContent()}\n\t\t\t\t{subText && (\n\t\t\t\t\t<Animated.Text\n\t\t\t\t\t\tstyle={[styles.modernItemTranslation, textAnimatedStyle]}\n\t\t\t\t\t>\n\t\t\t\t\t\t{subText}\n\t\t\t\t\t</Animated.Text>\n\t\t\t\t)}\n\t\t\t</AnimatedRectButton>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\toldSchoolItemWrapper: {\n\t\talignItems: 'center',\n\t\tpaddingVertical: 4,\n\t},\n\toldSchoolItemButton: {\n\t\tflexDirection: 'column',\n\t\talignItems: 'center',\n\t\tgap: 4,\n\t\tborderRadius: 16,\n\t\tpaddingVertical: 8,\n\t\tpaddingHorizontal: 16,\n\t\tmarginHorizontal: 30,\n\t\talignSelf: 'center',\n\t},\n\toldSchoolItemText: {\n\t\ttextAlign: 'center',\n\t\tfontSize: 14,\n\t\tfontWeight: '400',\n\t\tletterSpacing: 0.25,\n\t\tlineHeight: 20,\n\t},\n\toldSchoolItemTranslation: {\n\t\ttextAlign: 'center',\n\t\tfontSize: 12,\n\t\tfontWeight: '400',\n\t\tletterSpacing: 0.4,\n\t\tlineHeight: 16,\n\t},\n\tmodernItemWrapper: {\n\t\tflexDirection: 'column',\n\t\talignItems: 'stretch',\n\t\tmarginVertical: 4,\n\t\tpaddingVertical: 2,\n\t},\n\tmodernItemButton: {\n\t\tflexDirection: 'column',\n\t\talignItems: 'flex-start',\n\t\tgap: 4,\n\t\tborderRadius: 8,\n\t\tpaddingVertical: 4,\n\t\tmarginHorizontal: 30,\n\t\tpaddingLeft: 8,\n\t\tpaddingRight: 8,\n\t\talignSelf: 'flex-start',\n\t},\n\tmodernItemText: {\n\t\ttextAlign: 'left',\n\t\tfontSize: 24,\n\t\tfontWeight: '700',\n\t\tletterSpacing: 0,\n\t\tlineHeight: 32,\n\t},\n\tmodernItemTranslation: {\n\t\ttextAlign: 'left',\n\t\tfontSize: 18,\n\t\tfontWeight: '400',\n\t\tletterSpacing: 0,\n\t\tlineHeight: 26,\n\t\tmarginTop: 2,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/lyrics/LyricsOffsetControl.tsx",
    "content": "import { memo } from 'react'\nimport { StyleSheet, useWindowDimensions, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Divider, Icon, Text, useTheme } from 'react-native-paper'\n\nexport interface LyricsOffsetControlProps {\n\tvisible: boolean\n\tanchor: { x: number; y: number; width: number; height: number } | null\n\toffset: number\n\tonChangeOffset: (delta: number) => void\n\tonClose: () => void\n}\n\nexport const LyricsOffsetControl = memo(function LyricsOffsetControl({\n\tvisible,\n\tanchor,\n\toffset,\n\tonChangeOffset,\n\tonClose,\n}: LyricsOffsetControlProps) {\n\tconst dimensions = useWindowDimensions()\n\tconst windowHeight = dimensions.height\n\tconst windowWidth = dimensions.width\n\tconst colors = useTheme().colors\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[\n\t\t\t\tstyles.offsetControlContainer,\n\t\t\t\t{\n\t\t\t\t\tright: anchor ? windowWidth - (anchor.x + anchor.width) : 0,\n\t\t\t\t\tbottom: anchor ? windowHeight - anchor.y : 0,\n\t\t\t\t\tbackgroundColor: colors.elevation.level2,\n\t\t\t\t\topacity: visible ? 1 : 0,\n\t\t\t\t\tpointerEvents: visible ? 'auto' : 'none',\n\t\t\t\t},\n\t\t\t]}\n\t\t>\n\t\t\t<RectButton\n\t\t\t\tstyle={styles.offsetControlButton}\n\t\t\t\tonPress={() => onChangeOffset(0.5)}\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tsource='arrow-up'\n\t\t\t\t\tsize={20}\n\t\t\t\t\tcolor={colors.onSurface}\n\t\t\t\t/>\n\t\t\t</RectButton>\n\t\t\t<Text\n\t\t\t\tvariant='titleSmall'\n\t\t\t\tstyle={[styles.offsetControlText, { color: colors.onSurface }]}\n\t\t\t>\n\t\t\t\t{offset.toFixed(1)}s\n\t\t\t</Text>\n\t\t\t<RectButton\n\t\t\t\tstyle={styles.offsetControlButton}\n\t\t\t\tonPress={() => onChangeOffset(-0.5)}\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tsource='arrow-down'\n\t\t\t\t\tsize={20}\n\t\t\t\t\tcolor={colors.onSurface}\n\t\t\t\t/>\n\t\t\t</RectButton>\n\t\t\t<Divider />\n\t\t\t<RectButton\n\t\t\t\tstyle={styles.offsetControlButton}\n\t\t\t\tonPress={onClose}\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tsource='check'\n\t\t\t\t\tsize={20}\n\t\t\t\t\tcolor={colors.onSurface}\n\t\t\t\t/>\n\t\t\t</RectButton>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\toffsetControlContainer: {\n\t\tposition: 'absolute',\n\t\tgap: 8,\n\t\tborderRadius: 12,\n\t\televation: 10,\n\t\tpaddingHorizontal: 2,\n\t\tpaddingVertical: 4,\n\t\tzIndex: 99999,\n\t},\n\toffsetControlButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n\toffsetControlText: {\n\t\ttextAlign: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/sharing/LyricsShareCard.tsx",
    "content": "import { type LyricLine } from '@bbplayer/splash'\nimport { Image, type ImageRef } from 'expo-image'\nimport { LinearGradient } from 'expo-linear-gradient'\nimport { StyleSheet, View } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { Icon, Text } from 'react-native-paper'\nimport QRCode from 'react-native-qrcode-svg'\nimport ViewShot from 'react-native-view-shot'\n\ninterface LyricsShareCardProps {\n\ttitle: string\n\tartistName: string\n\timageRef?: ImageRef | null\n\tshareUrl: string\n\tselectedLyrics: LyricLine[]\n\tviewShotRef: React.RefObject<ViewShot | null>\n\tbackgroundColor: string\n}\n\nexport const LyricsShareCard = ({\n\ttitle,\n\tartistName,\n\timageRef,\n\tshareUrl,\n\tselectedLyrics,\n\tviewShotRef,\n\tbackgroundColor,\n}: LyricsShareCardProps) => {\n\treturn (\n\t\t<ViewShot\n\t\t\tref={viewShotRef}\n\t\t\toptions={{\n\t\t\t\tformat: 'png',\n\t\t\t\tquality: 1,\n\t\t\t}}\n\t\t\tstyle={[styles.container, { backgroundColor }]}\n\t\t>\n\t\t\t<LinearGradient\n\t\t\t\tcolors={['rgba(0,0,0,0.1)', 'rgba(0,0,0,0.4)']}\n\t\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\t/>\n\n\t\t\t<View style={styles.content}>\n\t\t\t\t<View style={styles.header}>\n\t\t\t\t\t<SquircleView\n\t\t\t\t\t\tstyle={styles.coverSquircle}\n\t\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\tsource={imageRef}\n\t\t\t\t\t\t\tstyle={styles.cover}\n\t\t\t\t\t\t\tcontentFit='cover'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</SquircleView>\n\t\t\t\t\t<View style={styles.trackInfo}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\tstyle={[styles.title, { color: '#fff' }]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={{ color: 'rgba(255,255,255,0.8)' }}\n\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{artistName}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.lyricsContainer}>\n\t\t\t\t\t<View style={styles.quoteContainer}>\n\t\t\t\t\t\t<View style={styles.quoteOpen}>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tsource='format-quote-open'\n\t\t\t\t\t\t\t\tsize={120}\n\t\t\t\t\t\t\t\tcolor='rgba(255,255,255,0.1)'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t<View style={styles.quoteClose}>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tsource='format-quote-close'\n\t\t\t\t\t\t\t\tsize={120}\n\t\t\t\t\t\t\t\tcolor='rgba(255,255,255,0.1)'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t\t{selectedLyrics.map((lyric, index) => (\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\tkey={`${lyric.startTime}-${index}`}\n\t\t\t\t\t\t\tstyle={styles.lyricLine}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='headlineSmall'\n\t\t\t\t\t\t\t\tstyle={[styles.lyricText, { color: '#fff' }]}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{lyric.content}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t{lyric.translations?.[0] && (\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\tstyles.translationText,\n\t\t\t\t\t\t\t\t\t\t{ color: 'rgba(255,255,255,0.7)' },\n\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{lyric.translations[0]}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</View>\n\t\t\t\t\t))}\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.footer}>\n\t\t\t\t\t<View style={styles.qrContainer}>\n\t\t\t\t\t\t<QRCode\n\t\t\t\t\t\t\tvalue={shareUrl}\n\t\t\t\t\t\t\tsize={60}\n\t\t\t\t\t\t\tcolor='#000'\n\t\t\t\t\t\t\tbackgroundColor='#fff'\n\t\t\t\t\t\t\tquietZone={4}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t\t<View style={styles.footerTextContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={{ color: 'rgba(255,255,255,0.8)', fontWeight: 'bold' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t长按识别二维码查看\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='labelSmall'\n\t\t\t\t\t\t\tstyle={{ color: 'rgba(255,255,255,0.6)', marginTop: 4 }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t一起来听歌！\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.logoContainer}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='labelLarge'\n\t\t\t\t\t\t\t\tstyle={{ color: '#fff', fontWeight: '900', letterSpacing: 1 }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tBBPLAYER\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</ViewShot>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\twidth: 375,\n\t\tpadding: 24,\n\t\tborderRadius: 0,\n\t\toverflow: 'hidden',\n\t\tposition: 'relative',\n\t},\n\tcontent: {\n\t\tflexDirection: 'column',\n\t\tgap: 24,\n\t\tzIndex: 1,\n\t},\n\tquoteContainer: {\n\t\t...StyleSheet.absoluteFillObject,\n\t\tzIndex: 0,\n\t\tjustifyContent: 'space-between',\n\t\tpadding: 0,\n\t},\n\tquoteOpen: {\n\t\tposition: 'absolute',\n\t\ttop: -36,\n\t\tleft: -36,\n\t},\n\tquoteClose: {\n\t\tposition: 'absolute',\n\t\tright: -36,\n\t\tbottom: -36,\n\t},\n\theader: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tgap: 16,\n\t},\n\tcover: {\n\t\twidth: 80,\n\t\theight: 80,\n\t\tbackgroundColor: 'rgba(255,255,255,0.1)',\n\t},\n\tcoverSquircle: {\n\t\twidth: 80,\n\t\theight: 80,\n\t\tborderRadius: 18,\n\t\toverflow: 'hidden',\n\t},\n\ttrackInfo: {\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t\tmarginBottom: 4,\n\t},\n\tlyricsContainer: {\n\t\tpaddingVertical: 12,\n\t\tgap: 16,\n\t},\n\tlyricLine: {\n\t\tflexDirection: 'column',\n\t},\n\tlyricText: {\n\t\tfontWeight: '600',\n\t\tlineHeight: 32,\n\t},\n\ttranslationText: {\n\t\tmarginTop: 4,\n\t},\n\tfooter: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 12,\n\t\tpaddingTop: 16,\n\t\tborderTopWidth: 1,\n\t\tborderTopColor: 'rgba(255,255,255,0.2)',\n\t},\n\tqrContainer: {\n\t\tborderRadius: 8,\n\t\toverflow: 'hidden',\n\t},\n\tfooterTextContainer: {\n\t\talignItems: 'flex-end',\n\t},\n\tlogoContainer: {\n\t\tmarginTop: 8,\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 2,\n\t\tborderWidth: 1,\n\t\tborderColor: 'rgba(255,255,255,0.4)',\n\t\tborderRadius: 4,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/components/sharing/SongShareCard.tsx",
    "content": "import { Image, type ImageRef } from 'expo-image'\nimport { LinearGradient } from 'expo-linear-gradient'\nimport { StyleSheet, View } from 'react-native'\nimport SquircleView from 'react-native-fast-squircle'\nimport { Text } from 'react-native-paper'\nimport QRCode from 'react-native-qrcode-svg'\nimport ViewShot from 'react-native-view-shot'\n\ninterface SongShareCardProps {\n\ttitle: string\n\tartistName: string\n\timageRef?: ImageRef | null\n\tshareUrl: string\n\tviewShotRef: React.RefObject<ViewShot | null>\n\tbackgroundColor: string\n}\n\nexport const SongShareCard = ({\n\ttitle,\n\tartistName,\n\timageRef,\n\tshareUrl,\n\tviewShotRef,\n\tbackgroundColor,\n}: SongShareCardProps) => {\n\treturn (\n\t\t<ViewShot\n\t\t\tref={viewShotRef}\n\t\t\toptions={{\n\t\t\t\tformat: 'png',\n\t\t\t\tquality: 1,\n\t\t\t}}\n\t\t\tstyle={[styles.container, { backgroundColor }]}\n\t\t>\n\t\t\t<LinearGradient\n\t\t\t\tcolors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.6)']}\n\t\t\t\tstyle={StyleSheet.absoluteFill}\n\t\t\t/>\n\n\t\t\t<View style={styles.cardContent}>\n\t\t\t\t<View style={styles.coverContainer}>\n\t\t\t\t\t<SquircleView\n\t\t\t\t\t\tstyle={styles.coverSquircle}\n\t\t\t\t\t\tcornerSmoothing={0.6}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\tsource={imageRef}\n\t\t\t\t\t\t\tstyle={styles.cover}\n\t\t\t\t\t\t\tcontentFit='cover'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</SquircleView>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.infoContainer}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='headlineMedium'\n\t\t\t\t\t\tstyle={[styles.title, { color: '#fff' }]}\n\t\t\t\t\t>\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</Text>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\tstyle={[styles.artist, { color: 'rgba(255,255,255,0.8)' }]}\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{artistName}\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\n\t\t\t\t<View style={styles.footer}>\n\t\t\t\t\t<View style={styles.qrContainer}>\n\t\t\t\t\t\t<QRCode\n\t\t\t\t\t\t\tvalue={shareUrl}\n\t\t\t\t\t\t\tsize={80}\n\t\t\t\t\t\t\tcolor='#000'\n\t\t\t\t\t\t\tbackgroundColor='#fff'\n\t\t\t\t\t\t\tquietZone={4}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</View>\n\t\t\t\t\t<View style={styles.footerTextContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\tstyle={{ color: 'rgba(255,255,255,0.8)', fontWeight: 'bold' }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t长按识别二维码\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='labelSmall'\n\t\t\t\t\t\t\tstyle={{ color: 'rgba(255,255,255,0.6)', marginTop: 4 }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t一起来听歌！\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.logoContainer}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='labelLarge'\n\t\t\t\t\t\t\t\tstyle={{ color: '#fff', fontWeight: '900', letterSpacing: 1 }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\tBBPLAYER\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</ViewShot>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\twidth: 375,\n\t\tpadding: 32,\n\t\tpaddingBottom: 40,\n\t\talignItems: 'center',\n\t},\n\tcardContent: {\n\t\twidth: '100%',\n\t\tgap: 24,\n\t},\n\tcoverContainer: {\n\t\tshadowColor: '#000',\n\t\tshadowOffset: {\n\t\t\twidth: 0,\n\t\t\theight: 8,\n\t\t},\n\t\tshadowOpacity: 0.3,\n\t\tshadowRadius: 12,\n\t\televation: 10,\n\t},\n\tcover: {\n\t\twidth: '100%',\n\t\taspectRatio: 1,\n\t\tbackgroundColor: 'rgba(255,255,255,0.1)',\n\t},\n\tcoverSquircle: {\n\t\twidth: '100%',\n\t\taspectRatio: 1,\n\t\tborderRadius: 68,\n\t\toverflow: 'hidden',\n\t},\n\tinfoContainer: {\n\t\tgap: 8,\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t\ttextAlign: 'left',\n\t},\n\tartist: {\n\t\ttextAlign: 'left',\n\t},\n\tfooter: {\n\t\tmarginTop: 16,\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'space-between',\n\t\tpaddingTop: 24,\n\t\tborderTopWidth: 1,\n\t\tborderTopColor: 'rgba(255,255,255,0.2)',\n\t},\n\tqrContainer: {\n\t\tborderRadius: 8,\n\t\toverflow: 'hidden',\n\t},\n\tfooterTextContainer: {\n\t\talignItems: 'flex-end',\n\t\tjustifyContent: 'center',\n\t\tflex: 1,\n\t},\n\tlogoContainer: {\n\t\tmarginTop: 8,\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 2,\n\t\tborderWidth: 1,\n\t\tborderColor: 'rgba(255,255,255,0.4)',\n\t\tborderRadius: 4,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/player/hooks/danmaku/constants.ts",
    "content": "export const CONFIG = {\n\tSPEED: 0.15, // px per ms\n\tSAFE_GAP: 4,\n\tLINE_HEIGHT: 28,\n\tFONT_SIZE: 16,\n\tOPACITY: 0.8,\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/hooks/danmaku/useDanmakuLoader.ts",
    "content": "import { useCallback, useEffect, useRef } from 'react'\nimport {\n\tuseAnimatedReaction,\n\tuseSharedValue,\n\ttype SharedValue,\n} from 'react-native-reanimated'\nimport { scheduleOnRN } from 'react-native-worklets'\n\nimport { fetchDanmakuSegmentQuery } from '@/hooks/queries/bilibili/danmaku'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useIsActuallyOffline } from '@/hooks/utils/useIsActuallyOffline'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport type { BilibiliDanmakuItem } from '@/types/apis/bilibili'\nimport { cleanDanmaku } from '@/utils/danmaku'\nimport log from '@/utils/log'\n\nconst PRELOAD_DISTANCE_MS = 1000 * 60\nconst SEGMENT_DURATION_MS = 1000 * 60 * 6\nconst BASE_RETRY_DELAY = 1000\nconst MAX_RETRY_DELAY = 1000 * 60 * 5\n\nconst logger = log.extend('UI.Player.DanmakuLoader')\n\nexport default function useDanmakuLoader(\n\tbvid: string,\n\tcid: number | undefined,\n\tcurrentTime: SharedValue<number>,\n) {\n\tconst isOffline = useIsActuallyOffline()\n\tconst rawDataSV = useSharedValue<BilibiliDanmakuItem[]>([])\n\tconst loadedSegmentsRef = useRef<Set<number>>(new Set())\n\tconst isLoadingRef = useRef(false)\n\tconst retryCountRef = useRef<Record<number, number>>({})\n\tconst retryTimersRef = useRef<Record<number, ReturnType<typeof setTimeout>>>(\n\t\t{},\n\t)\n\tconst danmakuFilterLevel = useAppStore(\n\t\t(state) => state.settings.danmakuFilterLevel,\n\t)\n\n\tconst fetchSegment = useCallback(\n\t\tasync (segIndex: number) => {\n\t\t\tif (isLoadingRef.current) return\n\n\t\t\tif (isOffline) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tisLoadingRef.current = true\n\t\t\tlet cidToUse = cid\n\t\t\tif (!cid) {\n\t\t\t\tconst cidResult = await bilibiliApi.getPageList(bvid)\n\t\t\t\tif (cidResult.isErr()) {\n\t\t\t\t\tlogger.error('获取 cid 失败', cidResult.error)\n\t\t\t\t\tisLoadingRef.current = false\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcidToUse = cidResult.value[0].cid\n\t\t\t\tif (!cidToUse) {\n\t\t\t\t\tlogger.error('获取 cid 失败')\n\t\t\t\t\tisLoadingRef.current = false\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst danmakus = await fetchDanmakuSegmentQuery(\n\t\t\t\t\tbvid,\n\t\t\t\t\tcidToUse!,\n\t\t\t\t\tsegIndex,\n\t\t\t\t)\n\t\t\t\tconst cleaned = cleanDanmaku(danmakus, danmakuFilterLevel)\n\t\t\t\tconst nextData = [...rawDataSV.value, ...cleaned].sort(\n\t\t\t\t\t(a, b) => a.progress - b.progress,\n\t\t\t\t)\n\t\t\t\trawDataSV.value = nextData\n\t\t\t\tloadedSegmentsRef.current.add(segIndex)\n\t\t\t\tretryCountRef.current[segIndex] = 0\n\t\t\t\tif (retryTimersRef.current[segIndex]) {\n\t\t\t\t\tclearTimeout(retryTimersRef.current[segIndex])\n\t\t\t\t\tdelete retryTimersRef.current[segIndex]\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tlogger.error(`获取弹幕失败 segIndex:${segIndex}`, e)\n\t\t\t\tconst retryCount = (retryCountRef.current[segIndex] || 0) + 1\n\t\t\t\tretryCountRef.current[segIndex] = retryCount\n\t\t\t\tconst delay = Math.min(\n\t\t\t\t\tBASE_RETRY_DELAY * Math.pow(2, retryCount - 1),\n\t\t\t\t\tMAX_RETRY_DELAY,\n\t\t\t\t)\n\t\t\t\tlogger.info(`弹幕分段 ${segIndex} 将在 ${delay}ms 后才允许重试`)\n\t\t\t\tloadedSegmentsRef.current.add(segIndex)\n\t\t\t\tif (retryTimersRef.current[segIndex]) {\n\t\t\t\t\tclearTimeout(retryTimersRef.current[segIndex])\n\t\t\t\t}\n\t\t\t\tretryTimersRef.current[segIndex] = setTimeout(() => {\n\t\t\t\t\tloadedSegmentsRef.current.delete(segIndex)\n\t\t\t\t\tdelete retryTimersRef.current[segIndex]\n\t\t\t\t}, delay)\n\t\t\t} finally {\n\t\t\t\tisLoadingRef.current = false\n\t\t\t}\n\t\t},\n\t\t[bvid, cid, rawDataSV, danmakuFilterLevel, isOffline],\n\t)\n\n\tconst checkAndLoad = useCallback(\n\t\t(timeMs: number) => {\n\t\t\tconst segIndex = Math.max(1, Math.ceil(timeMs / SEGMENT_DURATION_MS))\n\n\t\t\t// 1. 加载当前段\n\t\t\tif (!loadedSegmentsRef.current.has(segIndex)) {\n\t\t\t\tvoid fetchSegment(segIndex)\n\t\t\t}\n\n\t\t\t// 2. 预加载下一段\n\t\t\tconst timeLeft = SEGMENT_DURATION_MS - (timeMs % SEGMENT_DURATION_MS)\n\t\t\tif (timeLeft < PRELOAD_DISTANCE_MS) {\n\t\t\t\tconst nextSeg = segIndex + 1\n\t\t\t\tif (!loadedSegmentsRef.current.has(nextSeg)) {\n\t\t\t\t\tvoid fetchSegment(nextSeg)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[fetchSegment],\n\t)\n\n\tuseAnimatedReaction(\n\t\t() => currentTime.value,\n\t\t(current, previous) => {\n\t\t\tif (previous === null) return\n\t\t\tconst currentSec = current / 1000\n\t\t\tconst previousSec = previous / 1000\n\n\t\t\tconst diff = Math.abs(currentSec - previousSec)\n\t\t\tif (diff > 1.0) {\n\t\t\t\tscheduleOnRN(checkAndLoad, current)\n\t\t\t} else {\n\t\t\t\tconst currentInt = Math.floor(currentSec)\n\t\t\t\tif (currentInt % 5 === 0 && Math.floor(previousSec) !== currentInt) {\n\t\t\t\t\tscheduleOnRN(checkAndLoad, current)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[checkAndLoad],\n\t)\n\n\tuseEffect(() => {\n\t\trawDataSV.set([])\n\t\tloadedSegmentsRef.current.clear()\n\t\tisLoadingRef.current = false\n\t\tretryCountRef.current = {}\n\t\tObject.values(retryTimersRef.current).forEach(clearTimeout)\n\t\tretryTimersRef.current = {}\n\t}, [bvid, cid, rawDataSV])\n\n\treturn {\n\t\trawDataSV,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/hooks/danmaku/useDanmakuRender.ts",
    "content": "import { Skia } from '@shopify/react-native-skia'\nimport type {\n\tSkParagraph,\n\tSkPicture,\n\tSkTypefaceFontProvider,\n} from '@shopify/react-native-skia'\nimport { useEffect } from 'react'\nimport { useTheme } from 'react-native-paper'\nimport type { SharedValue } from 'react-native-reanimated'\nimport {\n\tuseAnimatedReaction,\n\tuseFrameCallback,\n\tuseSharedValue,\n} from 'react-native-reanimated'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport type { BilibiliDanmakuItem } from '@/types/apis/bilibili'\n\nimport { CONFIG } from './constants'\n\ninterface ActiveBullet {\n\tparagraph: SkParagraph\n\tx: number\n\ty: number\n\twidth: number\n\topacity: number\n\tvx: number\n\tbirthTime: number\n}\n\nfunction binarySearch(data: BilibiliDanmakuItem[], targetTime: number): number {\n\t'worklet'\n\tlet left = 0\n\tlet right = data.length - 1\n\tlet result = data.length\n\n\twhile (left <= right) {\n\t\tconst mid = Math.floor((left + right) / 2)\n\t\tif (data[mid].progress >= targetTime) {\n\t\t\tresult = mid\n\t\t\tright = mid - 1\n\t\t} else {\n\t\t\tleft = mid + 1\n\t\t}\n\t}\n\treturn result\n}\n\nconst createBlankPicture = () => {\n\tconst recorder = Skia.PictureRecorder()\n\trecorder.beginRecording(Skia.XYWHRect(0, 0, 1, 1))\n\treturn recorder.finishRecordingAsPicture()\n}\n\n/**\n * Heuristic to find the best track for a scrolling bullet.\n * Prefers middle tracks, avoids overlapping.\n */\nfunction findBestScrollTrack(tracks: number[], width: number) {\n\t'worklet'\n\tconst totalTracks = tracks.length\n\tconst reserve = totalTracks > 6 ? 2 : 0\n\tconst startTrack = reserve\n\tconst endTrack = totalTracks - reserve\n\n\tconst validTracks: number[] = []\n\tlet minRightX = Number.POSITIVE_INFINITY\n\n\tfor (let i = startTrack; i < endTrack; i++) {\n\t\tconst rightX = tracks[i]\n\n\t\tif (rightX < minRightX - 1) {\n\t\t\tminRightX = rightX\n\t\t\tvalidTracks.length = 0\n\t\t\tvalidTracks.push(i)\n\t\t} else if (rightX < minRightX + 1) {\n\t\t\tvalidTracks.push(i)\n\t\t}\n\t}\n\n\tif (validTracks.length > 0 && minRightX + CONFIG.SAFE_GAP < width) {\n\t\tconst idx = Math.floor(Math.random() * validTracks.length)\n\t\treturn validTracks[idx]\n\t}\n\treturn -1\n}\n\nexport const useDanmakuRender = ({\n\trawDataSV,\n\tcurrentTime,\n\tisPlaying,\n\tfontMgr,\n\twidth,\n\theight,\n\tfontFamilyName,\n\tenabled,\n}: {\n\trawDataSV: SharedValue<BilibiliDanmakuItem[]>\n\tcurrentTime: SharedValue<number>\n\tisPlaying: boolean\n\tfontMgr: SkTypefaceFontProvider | null\n\twidth: number\n\theight: number\n\tfontFamilyName: string\n\tenabled: boolean\n}) => {\n\tconst defaultColor = useTheme().colors.primary\n\tconst activeBullets = useSharedValue<ActiveBullet[]>([])\n\tconst tracks = useSharedValue<number[]>(new Array<number>(1).fill(0))\n\tconst staticTopTracks = useSharedValue<number[]>(new Array<number>(1).fill(0))\n\tconst staticBottomTracks = useSharedValue<number[]>(\n\t\tnew Array<number>(1).fill(0),\n\t)\n\tconst heightSV = useSharedValue(height)\n\tconst enableDanmaku = useAppStore((state) => state.settings.enableDanmaku)\n\n\tuseEffect(() => {\n\t\theightSV.value = height\n\t}, [height, heightSV])\n\n\tconst cursor = useSharedValue(0)\n\tconst picture = useSharedValue<SkPicture>(createBlankPicture())\n\n\tconst resetEngine = (targetTime: number) => {\n\t\tactiveBullets.set([])\n\t\tconst newTracksCount = Math.max(\n\t\t\tMath.floor(heightSV.value / CONFIG.LINE_HEIGHT),\n\t\t\t1,\n\t\t)\n\t\ttracks.set(new Array<number>(newTracksCount).fill(0))\n\t\tstaticTopTracks.set(new Array<number>(newTracksCount).fill(0))\n\t\tstaticBottomTracks.set(new Array<number>(newTracksCount).fill(0))\n\t\tcursor.set(binarySearch(rawDataSV.value, targetTime))\n\t}\n\n\tuseAnimatedReaction(\n\t\t() => heightSV.value,\n\t\t(newHeight, oldHeight) => {\n\t\t\tif (newHeight === oldHeight) return\n\t\t\tconst newTrackCount = Math.max(\n\t\t\t\tMath.floor(newHeight / CONFIG.LINE_HEIGHT),\n\t\t\t\t1,\n\t\t\t)\n\n\t\t\ttracks.set(new Array<number>(newTrackCount).fill(0))\n\t\t\tstaticTopTracks.set(new Array<number>(newTrackCount).fill(0))\n\t\t\tstaticBottomTracks.set(new Array<number>(newTrackCount).fill(0))\n\t\t\tactiveBullets.set([])\n\t\t},\n\t)\n\n\tuseFrameCallback((info) => {\n\t\tif (!enabled || !isPlaying || !currentTime || !fontMgr) return\n\n\t\tconst now = currentTime.value\n\t\tconst dt = info.timeSincePreviousFrame ?? 0\n\t\tconst scrollMoveDist = CONFIG.SPEED * dt\n\n\t\ttracks.modify((t) => {\n\t\t\t'worklet'\n\t\t\tfor (let i = 0; i < t.length; i++) {\n\t\t\t\tif (t[i] > -9999) {\n\t\t\t\t\tt[i] -= scrollMoveDist\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn t\n\t\t})\n\n\t\tconst MAX_SPAWN_PER_FRAME = 10\n\t\tlet spawnedCount = 0\n\n\t\twhile (\n\t\t\tcursor.value < rawDataSV.value.length &&\n\t\t\tspawnedCount < MAX_SPAWN_PER_FRAME\n\t\t) {\n\t\t\tconst item = rawDataSV.value[cursor.value]\n\n\t\t\tif (item.progress > now) break\n\n\t\t\tif (item.progress < now - 5000) {\n\t\t\t\tcursor.value++\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tspawnedCount++\n\n\t\t\tconst hexColor = '#' + item.color?.toString(16).padStart(6, '0')\n\t\t\tconst color = item.color ? Skia.Color(hexColor) : Skia.Color(defaultColor)\n\n\t\t\tlet fontSize = CONFIG.FONT_SIZE\n\t\t\tif (item.fontsize === 18) fontSize = 12\n\t\t\telse if (item.fontsize === 25) fontSize = 16\n\t\t\telse if (item.fontsize === 36) fontSize = 22\n\n\t\t\tconst isBlack = hexColor === '#000000'\n\n\t\t\tconst shadows = isBlack\n\t\t\t\t? undefined\n\t\t\t\t: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: 1, y: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: -1, y: -1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: 1, y: -1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: -1, y: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: 1, y: 0 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: -1, y: 0 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: 0, y: 1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tblurRadius: 0,\n\t\t\t\t\t\t\tcolor: Skia.Color('black'),\n\t\t\t\t\t\t\toffset: { x: 0, y: -1 },\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\n\t\t\tconst builder = Skia.ParagraphBuilder.Make(\n\t\t\t\t{\n\t\t\t\t\tmaxLines: 1,\n\t\t\t\t\ttextStyle: {\n\t\t\t\t\t\tfontSize,\n\t\t\t\t\t\tcolor,\n\t\t\t\t\t\tfontFamilies: [fontFamilyName],\n\t\t\t\t\t\tshadows,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tfontMgr,\n\t\t\t)\n\t\t\tbuilder.addText(item.content)\n\t\t\tconst paragraph = builder.build()\n\t\t\tparagraph.layout(Number.POSITIVE_INFINITY)\n\t\t\tconst textWidth = paragraph.getMinIntrinsicWidth()\n\t\t\tconst mode = item.mode || 1\n\n\t\t\tif (mode === 5) {\n\t\t\t\tlet targetIndex = -1\n\t\t\t\tstaticTopTracks.modify((t) => {\n\t\t\t\t\t'worklet'\n\t\t\t\t\tfor (let i = 0; i < t.length; i++) {\n\t\t\t\t\t\tif (t[i] <= now) {\n\t\t\t\t\t\t\tt[i] = now + 4000\n\t\t\t\t\t\t\ttargetIndex = i\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn t\n\t\t\t\t})\n\n\t\t\t\tif (targetIndex !== -1) {\n\t\t\t\t\tactiveBullets.modify((bullets) => {\n\t\t\t\t\t\t'worklet'\n\t\t\t\t\t\tbullets.push({\n\t\t\t\t\t\t\tparagraph,\n\t\t\t\t\t\t\tx: (width - textWidth) / 2,\n\t\t\t\t\t\t\ty: targetIndex * CONFIG.LINE_HEIGHT + 10,\n\t\t\t\t\t\t\twidth: textWidth,\n\t\t\t\t\t\t\topacity: CONFIG.OPACITY,\n\t\t\t\t\t\t\tvx: 0,\n\t\t\t\t\t\t\tbirthTime: now,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn bullets\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else if (mode === 4) {\n\t\t\t\tlet targetIndex = -1\n\t\t\t\tstaticBottomTracks.modify((t) => {\n\t\t\t\t\t'worklet'\n\t\t\t\t\tfor (let i = 0; i < t.length; i++) {\n\t\t\t\t\t\tif (t[i] <= now) {\n\t\t\t\t\t\t\tt[i] = now + 4000\n\t\t\t\t\t\t\ttargetIndex = i\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn t\n\t\t\t\t})\n\n\t\t\t\tif (targetIndex !== -1) {\n\t\t\t\t\tactiveBullets.modify((bullets) => {\n\t\t\t\t\t\t'worklet'\n\t\t\t\t\t\tbullets.push({\n\t\t\t\t\t\t\tparagraph,\n\t\t\t\t\t\t\tx: (width - textWidth) / 2,\n\t\t\t\t\t\t\ty: heightSV.value - (targetIndex + 1) * CONFIG.LINE_HEIGHT - 10,\n\t\t\t\t\t\t\twidth: textWidth,\n\t\t\t\t\t\t\topacity: CONFIG.OPACITY,\n\t\t\t\t\t\t\tvx: 0,\n\t\t\t\t\t\t\tbirthTime: now,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn bullets\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst bestTrack = findBestScrollTrack(tracks.value, width)\n\t\t\t\tif (bestTrack !== -1) {\n\t\t\t\t\ttracks.modify((t) => {\n\t\t\t\t\t\tt[bestTrack] = width + textWidth\n\t\t\t\t\t\treturn t\n\t\t\t\t\t})\n\t\t\t\t\tactiveBullets.modify((bullets) => {\n\t\t\t\t\t\tbullets.push({\n\t\t\t\t\t\t\tparagraph,\n\t\t\t\t\t\t\tx: width,\n\t\t\t\t\t\t\ty: bestTrack * CONFIG.LINE_HEIGHT + 10,\n\t\t\t\t\t\t\twidth: textWidth,\n\t\t\t\t\t\t\topacity: CONFIG.OPACITY,\n\t\t\t\t\t\t\tvx: CONFIG.SPEED,\n\t\t\t\t\t\t\tbirthTime: now,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn bullets\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcursor.value++\n\t\t}\n\n\t\tactiveBullets.modify((bullets) => {\n\t\t\t'worklet'\n\t\t\tfor (let i = bullets.length - 1; i >= 0; i--) {\n\t\t\t\tconst b = bullets[i]\n\t\t\t\tif (b.vx > 0) {\n\t\t\t\t\tb.x -= b.vx * dt\n\t\t\t\t\tif (b.x + b.width < 0) {\n\t\t\t\t\t\tbullets.splice(i, 1)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif (now > b.birthTime + 4000) {\n\t\t\t\t\t\tbullets.splice(i, 1)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn bullets\n\t\t})\n\t}, enableDanmaku)\n\n\tuseFrameCallback(() => {\n\t\tconst recorder = Skia.PictureRecorder()\n\t\tconst canvas = recorder.beginRecording(\n\t\t\tSkia.XYWHRect(0, 0, width, heightSV.value),\n\t\t)\n\n\t\tconst bullets = activeBullets.value\n\t\tfor (const b of bullets) {\n\t\t\tif (b.vx > 0) {\n\t\t\t\tb.paragraph.paint(canvas, b.x, b.y)\n\t\t\t}\n\t\t}\n\t\tfor (const b of bullets) {\n\t\t\tif (b.vx === 0) {\n\t\t\t\tb.paragraph.paint(canvas, b.x, b.y)\n\t\t\t}\n\t\t}\n\n\t\tpicture.value = recorder.finishRecordingAsPicture()\n\t}, enableDanmaku)\n\n\treturn { picture, resetEngine }\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/hooks/useLyricSync.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport type { LyricLine } from '@bbplayer/splash'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { AppState } from 'react-native'\n\nimport playerProgressEmitter from '@/lib/player/progressListener'\n\nexport default function useLyricSync(\n\tlyrics: LyricLine[],\n\tscrollToIndex: (index: number, animated?: boolean) => void,\n\toffset: number, // 单位秒\n\tenabled: boolean,\n) {\n\tconst [currentLyricIndex, setCurrentLyricIndex] = useState(0)\n\tconst isManualScrollingRef = useRef(false)\n\tconst manualScrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(\n\t\tnull,\n\t)\n\tconst [isActive, setIsActive] = useState(true)\n\tconst latestJumpRequestRef = useRef(0)\n\n\tconst findIndexForTime = useCallback(\n\t\t(timestamp: number) => {\n\t\t\tlet lo = 0,\n\t\t\t\thi = lyrics.length - 1,\n\t\t\t\tans = 0\n\t\t\twhile (lo <= hi) {\n\t\t\t\tconst mid = Math.floor((lo + hi) / 2)\n\t\t\t\tif (lyrics[mid].startTime / 1000 <= timestamp) {\n\t\t\t\t\tans = mid\n\t\t\t\t\tlo = mid + 1\n\t\t\t\t} else {\n\t\t\t\t\thi = mid - 1\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn Math.max(0, Math.min(ans, lyrics.length - 1))\n\t\t},\n\t\t[lyrics],\n\t)\n\n\tconst onUserScrollStart = () => {\n\t\tif (!lyrics.length) return\n\t\tif (manualScrollTimeoutRef.current) {\n\t\t\tclearTimeout(manualScrollTimeoutRef.current)\n\t\t\tmanualScrollTimeoutRef.current = null\n\t\t}\n\t\tisManualScrollingRef.current = true\n\t}\n\n\tconst onUserScrollEnd = () => {\n\t\tif (!lyrics.length) return\n\t\tif (manualScrollTimeoutRef.current)\n\t\t\tclearTimeout(manualScrollTimeoutRef.current)\n\n\t\tmanualScrollTimeoutRef.current = setTimeout(() => {\n\t\t\tmanualScrollTimeoutRef.current = null\n\t\t\tisManualScrollingRef.current = false\n\n\t\t\tscrollToIndex(currentLyricIndex, true)\n\t\t}, 2000)\n\t}\n\n\tconst handleJumpToLyric = useCallback(\n\t\tasync (index: number) => {\n\t\t\tif (lyrics.length === 0) return\n\t\t\tif (!lyrics[index]) return\n\t\t\tconst requestId = ++latestJumpRequestRef.current\n\t\t\tawait Orpheus.seekTo(lyrics[index].startTime / 1000 - offset)\n\t\t\tif (latestJumpRequestRef.current !== requestId) return\n\t\t\tsetCurrentLyricIndex(index)\n\t\t\tif (manualScrollTimeoutRef.current) {\n\t\t\t\tclearTimeout(manualScrollTimeoutRef.current)\n\t\t\t\tmanualScrollTimeoutRef.current = null\n\t\t\t}\n\t\t\tisManualScrollingRef.current = false\n\t\t},\n\t\t[lyrics, offset],\n\t)\n\n\tuseEffect(() => {\n\t\tconst appStateSubscription = AppState.addEventListener(\n\t\t\t'change',\n\t\t\t(nextAppState) => {\n\t\t\t\tif (nextAppState === 'active') {\n\t\t\t\t\tsetIsActive(true)\n\t\t\t\t} else {\n\t\t\t\t\tsetIsActive(false)\n\t\t\t\t}\n\t\t\t},\n\t\t)\n\t\tconst handler = playerProgressEmitter.subscribe('progress', (data) => {\n\t\t\tif (!enabled) return\n\n\t\t\tconst offsetedPosition = data.position + offset\n\t\t\tif (!isActive || offsetedPosition <= 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst index = findIndexForTime(offsetedPosition)\n\t\t\tif (index === currentLyricIndex) return\n\t\t\tsetCurrentLyricIndex(index)\n\t\t})\n\t\treturn () => {\n\t\t\thandler()\n\t\t\tappStateSubscription.remove()\n\t\t}\n\t}, [currentLyricIndex, enabled, findIndexForTime, isActive, offset])\n\n\tuseEffect(() => {\n\t\tif (!enabled) return\n\t\tvoid Orpheus.getPosition().then((data) => {\n\t\t\tconst offsetedPosition = data + offset\n\t\t\tif (!isActive || offsetedPosition <= 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst index = findIndexForTime(offsetedPosition)\n\t\t\tif (index === currentLyricIndex) return\n\t\t\tsetCurrentLyricIndex(index)\n\t\t})\n\t}, [currentLyricIndex, enabled, findIndexForTime, isActive, offset])\n\n\t// 当歌词发生变化且用户没自己滚时，滚动到当前歌词\n\tuseEffect(() => {\n\t\tif (!enabled) return\n\t\tif (isManualScrollingRef.current || manualScrollTimeoutRef.current) return\n\t\tscrollToIndex(currentLyricIndex, true)\n\t}, [currentLyricIndex, enabled, lyrics.length, scrollToIndex])\n\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tif (manualScrollTimeoutRef.current) {\n\t\t\t\tclearTimeout(manualScrollTimeoutRef.current)\n\t\t\t}\n\t\t}\n\t}, [])\n\n\treturn {\n\t\tcurrentLyricIndex,\n\t\thandleJumpToLyric,\n\t\tonUserScrollStart,\n\t\tonUserScrollEnd,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/features/player/hooks/usePlayerHeaderAnimation.ts",
    "content": "import {\n\tExtrapolation,\n\tinterpolate,\n\tuseAnimatedStyle,\n} from 'react-native-reanimated'\nimport type { SharedValue } from 'react-native-reanimated'\n\nexport function usePlayerHeaderAnimation(\n\tindex: number,\n\tscrollX?: SharedValue<number>,\n) {\n\tconst titleStyle = useAnimatedStyle(() => {\n\t\tif (!scrollX) return { opacity: index === 1 ? 1 : 0 }\n\t\treturn {\n\t\t\topacity: interpolate(\n\t\t\t\tscrollX.value,\n\t\t\t\t[0.4, 1],\n\t\t\t\t[0, 1],\n\t\t\t\tExtrapolation.CLAMP,\n\t\t\t),\n\t\t}\n\t})\n\n\tconst statusStyle = useAnimatedStyle(() => {\n\t\tif (!scrollX) return { opacity: index === 0 ? 1 : 0 }\n\t\treturn {\n\t\t\topacity: interpolate(\n\t\t\t\tscrollX.value,\n\t\t\t\t[0, 0.4],\n\t\t\t\t[1, 0],\n\t\t\t\tExtrapolation.CLAMP,\n\t\t\t),\n\t\t}\n\t})\n\n\treturn {\n\t\ttitleStyle,\n\t\tstatusStyle,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/components/LocalPlaylistHeader.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport * as Clipboard from 'expo-clipboard'\nimport type { ImageRef } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { memo, useCallback, useMemo, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tAvatar,\n\tDivider,\n\tText,\n\tTouchableRipple,\n\tuseTheme,\n} from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport IconButton from '@/components/common/IconButton'\nimport { alert } from '@/components/modals/AlertModal'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport type { SharedPlaylistMember } from '@/hooks/queries/sharedPlaylistMembers'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { playlistService } from '@/lib/services/playlistService'\nimport type { Playlist } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { getInternalPlayUri } from '@/utils/player'\nimport { formatDurationToText, formatRelativeTime } from '@/utils/time'\nimport toast from '@/utils/toast'\n\ninterface PlaylistHeaderProps {\n\tplaylist: Playlist & { validTrackCount: number }\n\ttotalDuration?: number\n\tonClickPlayAll: () => void\n\tonClickSync: () => void\n\tonClickCopyToLocalPlaylist: () => void\n\t/** 当作者为 bilibili 时触发。可选，未提供时仅视觉提示不响应 */\n\tonPressAuthor?: (author: NonNullable<Playlist['author']>) => void\n\tcoverRef?: ImageRef | null\n\tshareMembers?: SharedPlaylistMember[]\n\tonPressShareMember?: () => void\n}\n\ninterface SubtitlePieces {\n\tisLocal: boolean\n\tauthorName?: string\n\tauthorClickable: boolean\n\tcountText: string\n\tsyncLine?: string // 带“最后同步：xxx”的整行\n}\n\n// 三元运算符过于难懂，还是用函数好一些\nfunction buildSubtitlePieces(\n\tplaylist: Playlist & { validTrackCount: number },\n\ttotalDuration: number | undefined,\n): SubtitlePieces {\n\tconst isLocal = playlist.type === 'local' || playlist.type === 'dynamic'\n\n\tconst countRaw =\n\t\tplaylist.validTrackCount !== playlist.itemCount\n\t\t\t? `${playlist.itemCount}\\u2009首\\u2009(\\u2009${playlist.itemCount - playlist.validTrackCount}\\u2009首失效) `\n\t\t\t: `${playlist.itemCount}\\u2009首`\n\n\tlet countText = `${countRaw}歌曲`\n\tif (totalDuration !== undefined) {\n\t\tcountText += `\\u2009•\\u2009共\\u2009${formatDurationToText(totalDuration)}`\n\t}\n\n\tconst authorName = !isLocal\n\t\t? (playlist.author?.name ?? '未知作者')\n\t\t: undefined\n\tconst authorClickable =\n\t\t!!authorName && !isLocal && playlist.author?.source === 'bilibili'\n\n\tconst syncLine = !isLocal\n\t\t? `最后同步：${\n\t\t\t\tplaylist.lastSyncedAt\n\t\t\t\t\t? formatRelativeTime(playlist.lastSyncedAt)\n\t\t\t\t\t: '未知'\n\t\t\t}`\n\t\t: undefined\n\n\treturn { isLocal, authorName, authorClickable, countText, syncLine }\n}\n\n/**\n * 播放列表头部组件。\n */\nexport const PlaylistHeader = memo(function PlaylistHeader({\n\tplaylist,\n\ttotalDuration,\n\tonClickPlayAll,\n\tonClickSync,\n\tonClickCopyToLocalPlaylist,\n\tonPressAuthor,\n\tcoverRef,\n\tshareMembers,\n\tonPressShareMember,\n}: PlaylistHeaderProps) {\n\tconst [showFullTitle, setShowFullTitle] = useState(false)\n\tconst router = useRouter()\n\tconst { colors } = useTheme()\n\n\tconst { isLocal, authorName, authorClickable, countText, syncLine } = useMemo(\n\t\t() => buildSubtitlePieces(playlist, totalDuration),\n\t\t[playlist, totalDuration],\n\t)\n\n\tconst onClickDownloadAll = useCallback(async () => {\n\t\tconst tracksResult = await playlistService.getPlaylistTracks(playlist.id)\n\t\tif (tracksResult.isErr()) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'获取播放列表内容失败',\n\t\t\t\ttracksResult.error,\n\t\t\t\t'UI.Playlist.Local.Header',\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tvoid Orpheus.multiDownload(\n\t\t\ttracksResult.value\n\t\t\t\t.filter((item) =>\n\t\t\t\t\titem.source === 'bilibili'\n\t\t\t\t\t\t? item.bilibiliMetadata.videoIsValid\n\t\t\t\t\t\t: true,\n\t\t\t\t)\n\t\t\t\t.map((t) => {\n\t\t\t\t\tconst url = getInternalPlayUri(t)\n\t\t\t\t\tif (!url) return\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: t.uniqueKey,\n\t\t\t\t\t\ttitle: t.title,\n\t\t\t\t\t\turl: url,\n\t\t\t\t\t\tartist: t.artist?.name,\n\t\t\t\t\t\tartwork: resolveTrackCover(t.uniqueKey, t.coverUrl) ?? undefined,\n\t\t\t\t\t\tduration: t.duration,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((t) => !!t),\n\t\t)\n\t\tuseModalStore.getState().doAfterModalHostClosed(() => {\n\t\t\trouter.push('/download')\n\t\t})\n\t}, [playlist.id, router])\n\n\tif (!playlist.title) return null\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t{/* 顶部信息 */}\n\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\tid={playlist.id}\n\t\t\t\t\tcover={coverRef ?? playlist.coverUrl}\n\t\t\t\t\ttitle={playlist.title}\n\t\t\t\t\tsize={120}\n\t\t\t\t/>\n\n\t\t\t\t<View style={styles.headerTextContainer}>\n\t\t\t\t\t<TouchableRipple\n\t\t\t\t\t\tonPress={() => setShowFullTitle(!showFullTitle)}\n\t\t\t\t\t\tonLongPress={async () => {\n\t\t\t\t\t\t\tconst result = await Clipboard.setStringAsync(playlist.title)\n\t\t\t\t\t\t\tif (!result) {\n\t\t\t\t\t\t\t\ttoast.error('复制失败')\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttoast.success('已复制标题到剪贴板')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t\t\tnumberOfLines={showFullTitle ? undefined : 2}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{playlist.title}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</TouchableRipple>\n\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\tstyle={styles.subtitle}\n\t\t\t\t\t\tnumberOfLines={3}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLocal ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{playlist.shareId && playlist.shareRole && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<Text style={{ color: colors.primary, fontWeight: 'bold' }}>\n\t\t\t\t\t\t\t\t\t\t\t{playlist.shareRole === 'owner'\n\t\t\t\t\t\t\t\t\t\t\t\t? '所有者'\n\t\t\t\t\t\t\t\t\t\t\t\t: playlist.shareRole === 'editor'\n\t\t\t\t\t\t\t\t\t\t\t\t\t? '编辑者'\n\t\t\t\t\t\t\t\t\t\t\t\t\t: '订阅者'}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t{'\\n'}\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{countText}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{/* 作者名 */}\n\t\t\t\t\t\t\t\t{'创建者：'}\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tonPress={\n\t\t\t\t\t\t\t\t\t\tauthorClickable && playlist.author\n\t\t\t\t\t\t\t\t\t\t\t? () => onPressAuthor?.(playlist.author!)\n\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\ttextDecorationLine: authorClickable ? 'underline' : 'none',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{authorName}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t{'\\n'}\n\t\t\t\t\t\t\t\t{countText}\n\t\t\t\t\t\t\t\t{syncLine ? '\\n' : ''}\n\t\t\t\t\t\t\t\t{syncLine}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Text>\n\n\t\t\t\t\t{playlist.shareId && shareMembers && shareMembers.length > 0 && (\n\t\t\t\t\t\t<TouchableRipple\n\t\t\t\t\t\t\tonPress={\n\t\t\t\t\t\t\t\tonPressShareMember && playlist.shareRole !== 'subscriber'\n\t\t\t\t\t\t\t\t\t? onPressShareMember\n\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tmarginTop: 8,\n\t\t\t\t\t\t\t\talignSelf: 'flex-start',\n\t\t\t\t\t\t\t\tborderRadius: 16,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<View style={styles.shareInfoRow}>\n\t\t\t\t\t\t\t\t{playlist.shareRole === 'subscriber' ? (\n\t\t\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\t\t\tconst owner =\n\t\t\t\t\t\t\t\t\t\t\tshareMembers.find((m) => m.role === 'owner') ||\n\t\t\t\t\t\t\t\t\t\t\tshareMembers[0]\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyles.avatarWrapper,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{ borderColor: colors.background },\n\t\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{owner.avatarUrl ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar.Image\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsource={{ uri: owner.avatarUrl }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar.Text\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tlabel={owner.name.slice(0, 1)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tmarginLeft: 6,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcolor: colors.onSurfaceVariant,\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{owner.name}\n\t\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})()\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t{shareMembers.slice(0, 3).map((member, index) => (\n\t\t\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\t\t\tkey={member.mid}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyles.avatarWrapper,\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tmarginLeft: index === 0 ? 0 : -8,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tzIndex: 5 - index,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: colors.background,\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{member.avatarUrl ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar.Image\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsource={{ uri: member.avatarUrl }}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Avatar.Text\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlabel={member.name.slice(0, 1)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t{shareMembers.length > 5 && (\n\t\t\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyles.avatarWrapper,\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tmarginLeft: -8,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tzIndex: 0,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tborderColor: colors.background,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: colors.surfaceVariant,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: 28,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\theight: 28,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\t\tvariant='labelSmall'\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ fontSize: 10 }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t+{shareMembers.length - 3}\n\t\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ marginLeft: 6, color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{shareMembers.length} 位协作者\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t</TouchableRipple>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* 操作按钮 */}\n\t\t\t<View\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.actionsContainer,\n\t\t\t\t\t{ marginBottom: playlist.description ? 0 : 16 },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<View style={styles.actionButtons}>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\ticon='play'\n\t\t\t\t\t\tonPress={() => onClickPlayAll()}\n\t\t\t\t\t\ttestID='playlist-play-all'\n\t\t\t\t\t>\n\t\t\t\t\t\t播放全部\n\t\t\t\t\t</Button>\n\n\t\t\t\t\t{playlist.type !== 'local' && playlist.type !== 'dynamic' && (\n\t\t\t\t\t\t<IconButton\n\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\ticon='sync'\n\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\tonPress={onClickSync}\n\t\t\t\t\t\t\ttestID='playlist-sync'\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\ticon='content-copy'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={onClickCopyToLocalPlaylist}\n\t\t\t\t\t\ttestID='playlist-copy'\n\t\t\t\t\t/>\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\ticon='download'\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\talert(\n\t\t\t\t\t\t\t\t'下载全部？',\n\t\t\t\t\t\t\t\t'是否要下载该播放列表内的全部歌曲？（已下载过的不会重新下载）',\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\ttext: '取消',\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\t\t\t\t\tonPress: onClickDownloadAll,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttestID='playlist-download'\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* 描述 */}\n\t\t\t{!!playlist.description && (\n\t\t\t\t<Text\n\t\t\t\t\tstyle={styles.description}\n\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t>\n\t\t\t\t\t{playlist.description}\n\t\t\t\t</Text>\n\t\t\t)}\n\n\t\t\t<Divider />\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tposition: 'relative',\n\t\tflexDirection: 'column',\n\t},\n\theaderContainer: {\n\t\tflexDirection: 'row',\n\t\tmargin: 16,\n\t\talignItems: 'center',\n\t},\n\theaderTextContainer: {\n\t\tmarginLeft: 16,\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t\tmarginVertical: 8,\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t\tmarginBottom: 8,\n\t},\n\tsubtitle: {\n\t\tfontWeight: '100',\n\t\tlineHeight: 18,\n\t},\n\tshareInfoRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tavatarWrapper: {\n\t\tborderWidth: 2,\n\t\tborderRadius: 16,\n\t},\n\tactionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'flex-start',\n\t\tmarginHorizontal: 16,\n\t},\n\tactionButtons: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\tdescription: {\n\t\tmargin: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/components/LocalPlaylistItem.tsx",
    "content": "import { DownloadState } from '@bbplayer/orpheus'\nimport { memo, useCallback } from 'react'\nimport { Easing, StyleSheet, useColorScheme, View } from 'react-native'\nimport {\n\tGesture,\n\tGestureDetector,\n\tRectButton,\n} from 'react-native-gesture-handler'\nimport { Checkbox, Icon, Surface, Text, useTheme } from 'react-native-paper'\nimport TextTicker from 'react-native-text-ticker'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport useIsCurrentTrack from '@/hooks/player/useIsCurrentTrack'\nimport { resolveTrackCover } from '@/hooks/player/useLocalCover'\nimport {\n\tLIST_ITEM_COVER_SIZE,\n\tLIST_ITEM_BORDER_RADIUS,\n} from '@/theme/dimensions'\nimport type { Playlist, Track } from '@/types/core/media'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nexport interface TrackMenuItem {\n\ttitle: string\n\tleadingIcon: string\n\tonPress: () => void\n\tdanger?: boolean\n\tisHighFreq?: boolean\n}\n\ninterface TrackListItemProps {\n\tindex: number\n\tonTrackPress: () => void\n\tonMenuPress?: () => void\n\t/**\n\t * 拖拽把手上的 RNGH 合成手势回调。\n\t *\n\t * `onDragStart(absoluteY)` — 长按阈値到达时触发\n\t * `onDragUpdate(absoluteY)` — 手指移动时持续触发\n\t * `onDragEnd()` — 手指抬起或手势取消时触发\n\t */\n\tonDragStart?: (absoluteY: number) => void\n\tonDragUpdate?: (absoluteY: number) => void\n\tonDragEnd?: () => void\n\tshowCoverImage?: boolean\n\tdata: Track\n\tdisabled?: boolean\n\tplaylist: Playlist\n\ttoggleSelected: (id: number) => void\n\tisSelected: boolean\n\tselectMode: boolean\n\tisSearching?: boolean\n\tenterSelectMode: (id: number) => void\n\tisReadOnly?: boolean\n\tdownloadState?: DownloadState\n}\n\n/**\n * 可复用的播放列表项目组件。\n */\nexport const TrackListItem = memo(function TrackListItem({\n\tindex,\n\tonTrackPress,\n\tonMenuPress,\n\tonDragStart,\n\tonDragUpdate,\n\tonDragEnd,\n\tshowCoverImage = true,\n\tdata,\n\tdisabled = false,\n\tplaylist,\n\ttoggleSelected,\n\tisSelected,\n\tselectMode,\n\tisSearching = false,\n\tenterSelectMode,\n\tisReadOnly,\n\tdownloadState,\n}: TrackListItemProps) {\n\tconst theme = useTheme()\n\tconst dark = useColorScheme() === 'dark'\n\tconst isCurrentTrack = useIsCurrentTrack(data.uniqueKey)\n\n\tconst highlighted = (isCurrentTrack && !selectMode) || isSelected\n\n\tconst renderDownloadStatus = useCallback(() => {\n\t\tif (!downloadState) return null\n\t\tlet iconConfig\n\t\tswitch (downloadState) {\n\t\t\tcase DownloadState.COMPLETED:\n\t\t\t\ticonConfig = {\n\t\t\t\t\tsource: 'check-circle-outline',\n\t\t\t\t\tcolor: theme.colors.primary,\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\tcase DownloadState.FAILED:\n\t\t\t\ticonConfig = {\n\t\t\t\t\tsource: 'alert-circle-outline',\n\t\t\t\t\tcolor: theme.colors.error,\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\tdefault:\n\t\t\t\ticonConfig = {\n\t\t\t\t\tsource: 'help-circle-outline',\n\t\t\t\t\tcolor: theme.colors.onSurfaceVariant,\n\t\t\t\t}\n\t\t}\n\n\t\treturn (\n\t\t\t<View style={styles.downloadStatusContainer}>\n\t\t\t\t<Icon\n\t\t\t\t\tsource={iconConfig.source}\n\t\t\t\t\tsize={12}\n\t\t\t\t\tcolor={iconConfig.color}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t)\n\t}, [\n\t\tdownloadState,\n\t\ttheme.colors.error,\n\t\ttheme.colors.onSurfaceVariant,\n\t\ttheme.colors.primary,\n\t])\n\n\treturn (\n\t\t<RectButton\n\t\t\tstyle={[\n\t\t\t\tstyles.rectButton,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: highlighted\n\t\t\t\t\t\t? dark\n\t\t\t\t\t\t\t? 'rgba(255, 255, 255, 0.12)'\n\t\t\t\t\t\t\t: 'rgba(0, 0, 0, 0.12)'\n\t\t\t\t\t\t: 'transparent',\n\t\t\t\t},\n\t\t\t]}\n\t\t\tdelayLongPress={500}\n\t\t\tenabled={!disabled}\n\t\t\ttestID={`track-item-${index}`}\n\t\t\tonPress={() => {\n\t\t\t\tif (selectMode) {\n\t\t\t\t\tif (!isReadOnly) toggleSelected(data.id)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (isCurrentTrack) return\n\t\t\t\tonTrackPress()\n\t\t\t}}\n\t\t\tonLongPress={() => {\n\t\t\t\tif (selectMode || isReadOnly) return\n\t\t\t\tenterSelectMode(data.id)\n\t\t\t}}\n\t\t>\n\t\t\t<Surface\n\t\t\t\tstyle={styles.surface}\n\t\t\t\televation={0}\n\t\t\t>\n\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t{/* Index Number & Checkbox Container */}\n\t\t\t\t\t<View style={styles.indexContainer}>\n\t\t\t\t\t\t{/* 始终渲染，或许能降低一点性能开销？ */}\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.checkboxContainer,\n\t\t\t\t\t\t\t\t{ opacity: selectMode ? 1 : 0 },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Checkbox status={isSelected ? 'checked' : 'unchecked'} />\n\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t{/* 序号也是 */}\n\t\t\t\t\t\t<View style={{ opacity: selectMode ? 0 : 1 }}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\tstyle={{ color: theme.colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{/* Cover Image */}\n\t\t\t\t\t{showCoverImage ? (\n\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\tid={data.id}\n\t\t\t\t\t\t\tcover={\n\t\t\t\t\t\t\t\tdownloadState === DownloadState.COMPLETED\n\t\t\t\t\t\t\t\t\t? resolveTrackCover(data.uniqueKey, data.coverUrl)\n\t\t\t\t\t\t\t\t\t: data.coverUrl\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\ttitle={data.title}\n\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : null}\n\n\t\t\t\t\t{/* Title and Details */}\n\t\t\t\t\t<View style={styles.titleContainer}>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\tnumberOfLines={selectMode ? 1 : 0}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{data.title}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<View style={styles.detailsContainer}>\n\t\t\t\t\t\t\t{/* Display Artist if available */}\n\t\t\t\t\t\t\t{data.artist && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{data.artist.name ?? '未知'}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tstyle={styles.dotSeparator}\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Display Duration */}\n\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t{data.duration ? formatDurationToHHMMSS(data.duration) : ''}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t{/* 显示下载状态 */}\n\t\t\t\t\t\t\t{renderDownloadStatus()}\n\t\t\t\t\t\t</View>\n\t\t\t\t\t\t{/* 显示主视频标题（如果是分 p） — selectMode 下隐藏以固定高度 */}\n\t\t\t\t\t\t{!selectMode &&\n\t\t\t\t\t\t\tdata.source === 'bilibili' &&\n\t\t\t\t\t\t\tdata.bilibiliMetadata.mainTrackTitle &&\n\t\t\t\t\t\t\tdata.bilibiliMetadata.mainTrackTitle !== data.title &&\n\t\t\t\t\t\t\tplaylist.type !== 'multi_page' && (\n\t\t\t\t\t\t\t\t<TextTicker\n\t\t\t\t\t\t\t\t\tstyle={{ ...theme.fonts.bodySmall }}\n\t\t\t\t\t\t\t\t\tloop\n\t\t\t\t\t\t\t\t\tanimationType='scroll'\n\t\t\t\t\t\t\t\t\tduration={130 * data.bilibiliMetadata.mainTrackTitle.length}\n\t\t\t\t\t\t\t\t\teasing={Easing.linear}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{data.bilibiliMetadata.mainTrackTitle}\n\t\t\t\t\t\t\t\t</TextTicker>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{/* Context Menu / Drag Handle */}\n\t\t\t\t\t{!disabled && (\n\t\t\t\t\t\t<View>\n\t\t\t\t\t\t\t{selectMode ? (\n\t\t\t\t\t\t\t\tplaylist.type === 'local' && !isSearching ? (\n\t\t\t\t\t\t\t\t\t<GestureDetector\n\t\t\t\t\t\t\t\t\t\tgesture={Gesture.Pan()\n\t\t\t\t\t\t\t\t\t\t\t.activateAfterLongPress(200)\n\t\t\t\t\t\t\t\t\t\t\t.runOnJS(true)\n\t\t\t\t\t\t\t\t\t\t\t.onStart((e) => onDragStart?.(e.absoluteY))\n\t\t\t\t\t\t\t\t\t\t\t.onUpdate((e) => onDragUpdate?.(e.absoluteY))\n\t\t\t\t\t\t\t\t\t\t\t.onFinalize(() => onDragEnd?.())}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<View style={styles.menuButton}>\n\t\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\t\tsource='drag-vertical'\n\t\t\t\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\t\t\t\tcolor={theme.colors.onSurfaceVariant}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t</GestureDetector>\n\t\t\t\t\t\t\t\t) : null\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t\t\tstyle={styles.menuButton}\n\t\t\t\t\t\t\t\t\tenabled={!!onMenuPress}\n\t\t\t\t\t\t\t\t\tonPress={() => onMenuPress?.()}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\tsource='dots-vertical'\n\t\t\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\t\t\tcolor={theme.colors.primary}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</View>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</Surface>\n\t\t</RectButton>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 4,\n\t},\n\tsurface: {\n\t\toverflow: 'hidden',\n\t\tborderRadius: LIST_ITEM_BORDER_RADIUS,\n\t\tbackgroundColor: 'transparent',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 6,\n\t},\n\tindexContainer: {\n\t\twidth: 35,\n\t\tmarginRight: 8,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tcheckboxContainer: {\n\t\tposition: 'absolute',\n\t},\n\ttitleContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tmarginRight: 4,\n\t},\n\tdetailsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginTop: 2,\n\t\tflexWrap: 'wrap',\n\t},\n\tdotSeparator: {\n\t\tmarginHorizontal: 4,\n\t},\n\tmenuButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n\tdownloadStatusContainer: {\n\t\tpaddingLeft: 4,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/components/LocalTrackList.tsx",
    "content": "import type { DownloadState } from '@bbplayer/orpheus'\nimport { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport type { FlashListProps, FlashListRef } from '@shopify/flash-list'\nimport { FlashList } from '@shopify/flash-list'\nimport type { RefObject } from 'react'\nimport { useCallback, useMemo, useRef, useState } from 'react'\nimport { ScrollView, StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tDivider,\n\tIcon,\n\tList,\n\tSurface,\n\tText,\n\tTouchableRipple,\n\tuseTheme,\n} from 'react-native-paper'\nimport type { MD3Theme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport useCurrentTrack from '@/hooks/player/useCurrentTrack'\nimport { useBatchDownloadStatus } from '@/hooks/queries/orpheus'\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\nimport type { Playlist, Track } from '@/types/core/media'\nimport type {\n\tListRenderItemInfoWithExtraData,\n\tSelectionState,\n} from '@/types/flashlist'\nimport * as Haptics from '@/utils/haptics'\n\nimport type { TrackMenuItem } from './LocalPlaylistItem'\nimport { TrackListItem } from './LocalPlaylistItem'\n\ninterface LocalTrackListProps extends Omit<\n\tFlashListProps<Track>,\n\t'data' | 'renderItem' | 'extraData'\n> {\n\t/** 要显示的本地曲目数组 */\n\ttracks: Track[]\n\t/** 所属的播放列表信息 */\n\tplaylist: Playlist\n\t/** 点击曲目时的处理函数 */\n\thandleTrackPress: (track: Track) => void\n\t/** 生成曲目菜单项的函数 */\n\ttrackMenuItems: (\n\t\ttrack: Track,\n\t\tdownloadState?: DownloadState,\n\t) => TrackMenuItem[]\n\t/** 多选状态管理 */\n\tselection: SelectionState\n\t/** 列表引用 */\n\tlistRef?: RefObject<FlashListRef<Track> | null>\n\t/** 是否还有下一页数据（可选） */\n\thasNextPage?: boolean\n\t/** 是否正在获取下一页数据（可选） */\n\tisFetchingNextPage?: boolean\n\t/** 数据是否已过期，如果为 true，列表项会显示半透明（可选） */\n\tisStale?: boolean\n\t/** 当前设备是否处于无网络离线状态 */\n\tisOffline?: boolean\n\t/** 在离线状态下，哪些歌曲的 uniqueKey 是被完整缓存可以播放的 */\n\tplayableOfflineKeys?: Set<string>\n\t/** 是否处于搜索状态 */\n\tisSearching?: boolean\n\t/** 在 selectMode 下长按拖拽把手时触发 */\n\tonDragStart?: (trackIndex: number, trackId: number, absoluteY: number) => void\n\t/** 手指在拖拽过程中持续移动时触发 */\n\tonDragUpdate?: (absoluteY: number) => void\n\t/** 手指抬起或手势取消时触发 */\n\tonDragEnd?: () => void\n\t/** 高亮显示插入位置 */\n\tinsertAfterIndex?: number | null\n}\n\nconst renderItem = ({\n\titem,\n\tindex,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tTrack,\n\t{\n\t\thandleTrackPress: (track: Track) => void\n\t\thandleMenuPress: (track: Track, downloadState?: DownloadState) => void\n\t\tselection: SelectionState\n\t\tplaylist: Playlist\n\t\tdownloadStatus?: Record<string, DownloadState>\n\t\tisStale?: boolean\n\t\tisOffline?: boolean\n\t\tplayableOfflineKeys?: Set<string>\n\t\tisSearching?: boolean\n\t\tonDragStart?: (\n\t\t\ttrackIndex: number,\n\t\t\ttrackId: number,\n\t\t\tabsoluteY: number,\n\t\t) => void\n\t\tonDragUpdate?: (absoluteY: number) => void\n\t\tonDragEnd?: () => void\n\t\tinsertAfterIndex: number | null\n\t\tcolors: MD3Theme['colors']\n\t\tisReadOnly?: boolean\n\t}\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst {\n\t\thandleTrackPress,\n\t\thandleMenuPress,\n\t\tselection,\n\t\tplaylist,\n\t\tdownloadStatus,\n\t\tisStale,\n\t\tisOffline,\n\t\tplayableOfflineKeys,\n\t\tisSearching,\n\t\tonDragStart,\n\t\tonDragUpdate,\n\t\tonDragEnd,\n\t\tinsertAfterIndex,\n\t\tcolors,\n\t} = extraData\n\tconst downloadState = downloadStatus\n\t\t? downloadStatus[item.uniqueKey]\n\t\t: undefined\n\n\tconst isUnplayableOffline =\n\t\tisOffline && playableOfflineKeys && !playableOfflineKeys.has(item.uniqueKey)\n\tconst isReadOnly = extraData.isReadOnly === true\n\n\treturn (\n\t\t<>\n\t\t\t<View style={{ opacity: isStale || isUnplayableOffline ? 0.4 : 1 }}>\n\t\t\t\t<TrackListItem\n\t\t\t\t\tindex={index}\n\t\t\t\t\tonTrackPress={() => handleTrackPress(item)}\n\t\t\t\t\tonMenuPress={() => {\n\t\t\t\t\t\thandleMenuPress(item, downloadState)\n\t\t\t\t\t}}\n\t\t\t\t\tonDragStart={\n\t\t\t\t\t\tisReadOnly\n\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t: (absoluteY) => onDragStart?.(index, item.id, absoluteY)\n\t\t\t\t\t}\n\t\t\t\t\tonDragUpdate={isReadOnly ? undefined : onDragUpdate}\n\t\t\t\t\tonDragEnd={isReadOnly ? undefined : onDragEnd}\n\t\t\t\t\tdisabled={\n\t\t\t\t\t\titem.source === 'bilibili' && !item.bilibiliMetadata.videoIsValid\n\t\t\t\t\t}\n\t\t\t\t\tdata={item}\n\t\t\t\t\tplaylist={playlist}\n\t\t\t\t\ttoggleSelected={(id: number) => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Clock_Tick)\n\t\t\t\t\t\tselection.toggle(id)\n\t\t\t\t\t}}\n\t\t\t\t\tisSelected={selection.selected.has(item.id)}\n\t\t\t\t\tselectMode={selection.active}\n\t\t\t\t\tisSearching={isSearching}\n\t\t\t\t\tenterSelectMode={(id: number) => {\n\t\t\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Long_Press)\n\t\t\t\t\t\tselection.enter(id)\n\t\t\t\t\t}}\n\t\t\t\t\tdownloadState={downloadState}\n\t\t\t\t\tisReadOnly={isReadOnly}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t\t{insertAfterIndex === index && (\n\t\t\t\t<View\n\t\t\t\t\tpointerEvents='none'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: 2,\n\t\t\t\t\t\tbackgroundColor: colors.primary,\n\t\t\t\t\t\tmarginHorizontal: 8,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</>\n\t)\n}\n\nconst HighFreqButton = ({\n\titem,\n\tonDismiss,\n}: {\n\titem: TrackMenuItem\n\tonDismiss: () => void\n}) => {\n\tconst theme = useTheme()\n\n\treturn (\n\t\t<Surface\n\t\t\tstyle={{\n\t\t\t\tborderRadius: 16,\n\t\t\t\toverflow: 'hidden',\n\t\t\t\tbackgroundColor: theme.colors.elevation.level2,\n\t\t\t\tflex: 1,\n\t\t\t\tmarginHorizontal: 4,\n\t\t\t}}\n\t\t\televation={0}\n\t\t>\n\t\t\t<TouchableRipple\n\t\t\t\tonPress={() => {\n\t\t\t\t\tonDismiss()\n\t\t\t\t\titem.onPress()\n\t\t\t\t}}\n\t\t\t\tstyle={{ flex: 1 }}\n\t\t\t>\n\t\t\t\t<View\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\talignItems: 'center',\n\t\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\t\tpaddingVertical: 16,\n\t\t\t\t\t\theight: 80,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Icon\n\t\t\t\t\t\tsource={item.leadingIcon}\n\t\t\t\t\t\tsize={28}\n\t\t\t\t\t/>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='labelMedium'\n\t\t\t\t\t\tstyle={{ marginTop: 8 }}\n\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{item.title}\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t</TouchableRipple>\n\t\t</Surface>\n\t)\n}\n\nexport function LocalTrackList({\n\ttracks,\n\tplaylist,\n\thandleTrackPress,\n\ttrackMenuItems,\n\tselection,\n\tListHeaderComponent,\n\tonEndReached,\n\tisFetchingNextPage,\n\thasNextPage,\n\tisStale,\n\tisOffline,\n\tplayableOfflineKeys,\n\tisSearching,\n\tlistRef,\n\tonDragStart,\n\tonDragUpdate,\n\tonDragEnd,\n\tinsertAfterIndex,\n\t...flashListProps\n}: LocalTrackListProps) {\n\tconst haveTrack = useCurrentTrack()\n\tconst insets = useSafeAreaInsets()\n\tconst theme = useTheme()\n\tconst isReadOnly =\n\t\tplaylist.shareRole === 'subscriber' || playlist.type === 'dynamic'\n\tconst ids = tracks.map((t) => t.uniqueKey)\n\tconst { data: downloadStatus } = useBatchDownloadStatus(ids)\n\tconst sheetRef = useRef<TrueSheet>(null)\n\n\tconst [menuState, setMenuState] = useState<{\n\t\tvisible: boolean\n\t\ttrack: Track | null\n\t\tdownloadState?: DownloadState\n\t}>({\n\t\tvisible: false,\n\t\ttrack: null,\n\t\tdownloadState: undefined,\n\t})\n\n\tconst handleMenuPress = useCallback(\n\t\t(track: Track, downloadState?: DownloadState) => {\n\t\t\tsetMenuState({ visible: true, track, downloadState })\n\t\t\tsheetRef.current?.present().catch(() => {\n\t\t\t\tsetMenuState((prev) => ({ ...prev, visible: false }))\n\t\t\t})\n\t\t},\n\t\t[],\n\t)\n\n\tconst dismissMenu = useCallback(() => {\n\t\tsheetRef.current?.dismiss().catch(() => {\n\t\t\t// ignore error\n\t\t})\n\t}, [])\n\n\tconst { highFreqItems, normalItems } = (() => {\n\t\tif (!menuState.track) return { highFreqItems: [], normalItems: [] }\n\t\tconst allItems = trackMenuItems(menuState.track, menuState.downloadState)\n\t\treturn {\n\t\t\thighFreqItems: allItems.filter((i) => i.isHighFreq),\n\t\t\tnormalItems: allItems.filter((i) => !i.isHighFreq),\n\t\t}\n\t})()\n\n\tconst keyExtractor = useCallback((item: Track) => String(item.id), [])\n\n\tconst extraData = useMemo(\n\t\t() => ({\n\t\t\tselection,\n\t\t\thandleTrackPress,\n\t\t\thandleMenuPress,\n\t\t\tplaylist,\n\t\t\tdownloadStatus,\n\t\t\tisStale,\n\t\t\tisOffline,\n\t\t\tplayableOfflineKeys,\n\t\t\tisSearching,\n\t\t\tonDragStart,\n\t\t\tonDragUpdate,\n\t\t\tonDragEnd,\n\t\t\tinsertAfterIndex: insertAfterIndex ?? null,\n\t\t\tcolors: theme.colors,\n\t\t\tisReadOnly,\n\t\t}),\n\t\t[\n\t\t\tselection,\n\t\t\thandleTrackPress,\n\t\t\thandleMenuPress,\n\t\t\tplaylist,\n\t\t\tdownloadStatus,\n\t\t\tisStale,\n\t\t\tisOffline,\n\t\t\tplayableOfflineKeys,\n\t\t\tisSearching,\n\t\t\tonDragStart,\n\t\t\tonDragUpdate,\n\t\t\tonDragEnd,\n\t\t\tinsertAfterIndex,\n\t\t\ttheme.colors,\n\t\t\tisReadOnly,\n\t\t],\n\t)\n\n\tusePreventRemove(menuState.visible, () => {\n\t\tsetMenuState({ visible: false, track: null, downloadState: undefined })\n\t\tsheetRef.current?.dismiss().catch(() => {\n\t\t\t// ignore error\n\t\t})\n\t})\n\n\treturn (\n\t\t<>\n\t\t\t<FlashList\n\t\t\t\tref={listRef}\n\t\t\t\tdata={tracks}\n\t\t\t\trenderItem={renderItem}\n\t\t\t\textraData={extraData}\n\t\t\t\tItemSeparatorComponent={() => <Divider />}\n\t\t\t\tListHeaderComponent={ListHeaderComponent}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\tpointerEvents: menuState.visible ? 'none' : 'auto',\n\t\t\t\t\tpaddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,\n\t\t\t\t}}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tListFooterComponent={\n\t\t\t\t\t(isFetchingNextPage ? (\n\t\t\t\t\t\t<View style={styles.footerLoadingContainer}>\n\t\t\t\t\t\t\t<ActivityIndicator size='small' />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : hasNextPage ? (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.footerReachedEnd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t•\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t) : null) ?? flashListProps.ListFooterComponent\n\t\t\t\t}\n\t\t\t\tonEndReached={onEndReached}\n\t\t\t\tonEndReachedThreshold={0.8}\n\t\t\t\t{...flashListProps}\n\t\t\t/>\n\t\t\t<TrueSheet\n\t\t\t\tref={sheetRef}\n\t\t\t\tdetents={['auto']}\n\t\t\t\tcornerRadius={24}\n\t\t\t\tbackgroundColor={theme.colors.elevation.level1}\n\t\t\t\tonDidDismiss={() => {\n\t\t\t\t\tsetMenuState((prev) => ({ ...prev, visible: false }))\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<ScrollView\n\t\t\t\t\tstyle={{ maxHeight: '100%', marginTop: 32 }}\n\t\t\t\t\tcontentContainerStyle={{ paddingBottom: insets.bottom + 20 }}\n\t\t\t\t>\n\t\t\t\t\t{menuState.track && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{menuState.track.title}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\tstyle={{ opacity: 0.6 }}\n\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{menuState.track.artist?.name ?? '未知艺术家'}\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t<Divider style={{ marginTop: 12 }} />\n\t\t\t\t\t\t\t\t{highFreqItems.length > 0 && (\n\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tflexDirection: 'row',\n\t\t\t\t\t\t\t\t\t\t\tpaddingBottom: 12,\n\t\t\t\t\t\t\t\t\t\t\tpaddingTop: 16,\n\t\t\t\t\t\t\t\t\t\t\twidth: '100%',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{highFreqItems.map((item, index) => (\n\t\t\t\t\t\t\t\t\t\t\t<HighFreqButton\n\t\t\t\t\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\t\t\t\t\t\titem={item}\n\t\t\t\t\t\t\t\t\t\t\t\tonDismiss={dismissMenu}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t\t{normalItems.map((menuItem, index) => (\n\t\t\t\t\t\t\t\t<List.Item\n\t\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\t\t\ttitle={menuItem.title}\n\t\t\t\t\t\t\t\t\ttitleStyle={\n\t\t\t\t\t\t\t\t\t\tmenuItem.danger ? { color: theme.colors.error } : {}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tleft={(props) =>\n\t\t\t\t\t\t\t\t\t\tmenuItem.leadingIcon ? (\n\t\t\t\t\t\t\t\t\t\t\t<List.Icon\n\t\t\t\t\t\t\t\t\t\t\t\t{...props}\n\t\t\t\t\t\t\t\t\t\t\t\ticon={menuItem.leadingIcon}\n\t\t\t\t\t\t\t\t\t\t\t\tcolor={\n\t\t\t\t\t\t\t\t\t\t\t\t\tmenuItem.danger\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? theme.colors.error\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: theme.colors.onSurface\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t) : null\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\t\tdismissMenu()\n\t\t\t\t\t\t\t\t\t\tmenuItem.onPress()\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</ScrollView>\n\t\t\t</TrueSheet>\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tfooterLoadingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tfooterReachedEnd: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/components/PlaylistError.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n\ninterface PlaylistErrorProps {\n\ttext?: string\n\tonRetry?: () => void\n}\n\nexport function PlaylistError({\n\ttext = '加载失败',\n\tonRetry,\n}: PlaylistErrorProps) {\n\tconst { colors } = useTheme()\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Text\n\t\t\t\tvariant='titleMedium'\n\t\t\t\tstyle={styles.text}\n\t\t\t>\n\t\t\t\t{text}\n\t\t\t</Text>\n\t\t\t{onRetry && (\n\t\t\t\t<Button\n\t\t\t\t\tonPress={onRetry}\n\t\t\t\t\tmode='contained'\n\t\t\t\t>\n\t\t\t\t\t重试\n\t\t\t\t</Button>\n\t\t\t)}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\ttext: {\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/components/SharedPlaylistMembersSheet.tsx",
    "content": "import type { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport { TrueSheet as TrueSheetComponent } from '@lodev09/react-native-true-sheet'\nimport { forwardRef, useState } from 'react'\nimport { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'\nimport { Avatar, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport { useSharedPlaylistAllMembers } from '@/hooks/queries/sharedPlaylistAllMembers'\nimport { formatRelativeTime } from '@/utils/time'\n\ninterface Props {\n\tshareId?: string | null\n}\n\nexport const SharedPlaylistMembersSheet = forwardRef<TrueSheet, Props>(\n\tfunction SharedPlaylistMembersSheet({ shareId }, ref) {\n\t\tconst [isOpen, setIsOpen] = useState(false)\n\t\tconst {\n\t\t\tdata: members,\n\t\t\tisPending,\n\t\t\tisError,\n\t\t} = useSharedPlaylistAllMembers(isOpen ? shareId : null)\n\t\tconst theme = useTheme()\n\t\tconst insets = useSafeAreaInsets()\n\n\t\treturn (\n\t\t\t<TrueSheetComponent\n\t\t\t\tref={ref}\n\t\t\t\tdetents={[0.5]}\n\t\t\t\tcornerRadius={24}\n\t\t\t\tbackgroundColor={theme.colors.elevation.level1}\n\t\t\t\tonDidPresent={() => setIsOpen(true)}\n\t\t\t\tonDidDismiss={() => setIsOpen(false)}\n\t\t\t\tscrollable\n\t\t\t>\n\t\t\t\t<View style={[styles.container, { paddingBottom: insets.bottom + 20 }]}>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t>\n\t\t\t\t\t\t协作者 {members ? `(${members.length})` : ''}\n\t\t\t\t\t</Text>\n\n\t\t\t\t\t{isPending ? (\n\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : isError || !members ? (\n\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t<Text>加载失败</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ScrollView\n\t\t\t\t\t\t\tstyle={styles.listContent}\n\t\t\t\t\t\t\tnestedScrollEnabled\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{members.map((item) => (\n\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\tkey={item.mid}\n\t\t\t\t\t\t\t\t\tstyle={styles.memberRow}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{item.avatarUrl ? (\n\t\t\t\t\t\t\t\t\t\t<Avatar.Image\n\t\t\t\t\t\t\t\t\t\t\tsize={40}\n\t\t\t\t\t\t\t\t\t\t\tsource={{ uri: item.avatarUrl }}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<Avatar.Text\n\t\t\t\t\t\t\t\t\t\t\tsize={40}\n\t\t\t\t\t\t\t\t\t\t\tlabel={item.name.slice(0, 1)}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t<View style={styles.memberInfo}>\n\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\tvariant='bodyLarge'\n\t\t\t\t\t\t\t\t\t\t\tstyle={styles.memberName}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{item.name}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: theme.colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{item.role === 'owner'\n\t\t\t\t\t\t\t\t\t\t\t\t? '所有者'\n\t\t\t\t\t\t\t\t\t\t\t\t: item.role === 'editor'\n\t\t\t\t\t\t\t\t\t\t\t\t\t? '编辑者'\n\t\t\t\t\t\t\t\t\t\t\t\t\t: '订阅者'}\n\t\t\t\t\t\t\t\t\t\t\t{' • '}\n\t\t\t\t\t\t\t\t\t\t\t{formatRelativeTime(item.joinedAt)}加入\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</ScrollView>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</TrueSheetComponent>\n\t\t)\n\t},\n)\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tmaxHeight: '80%',\n\t\tmarginTop: 24,\n\t},\n\tcenter: {\n\t\tpadding: 40,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t\tpaddingHorizontal: 20,\n\t\tpaddingBottom: 16,\n\t},\n\tlistContent: {\n\t\tpaddingHorizontal: 20,\n\t},\n\tmemberRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingVertical: 10,\n\t},\n\tmemberInfo: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t},\n\tmemberName: {\n\t\tfontWeight: '600',\n\t\tmarginBottom: 2,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/components/SyncFailuresSheet.tsx",
    "content": "import type { TrueSheet } from '@lodev09/react-native-true-sheet'\nimport { TrueSheet as TrueSheetComponent } from '@lodev09/react-native-true-sheet'\nimport { and, eq, inArray } from 'drizzle-orm'\nimport { useLiveQuery } from 'drizzle-orm/expo-sqlite'\nimport { forwardRef, useState } from 'react'\nimport { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'\nimport { GestureHandlerRootView } from 'react-native-gesture-handler'\nimport { Icon, Text, useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport Button from '@/components/common/Button'\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { playlistSyncWorker } from '@/lib/workers/PlaylistSyncWorker'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { formatRelativeTime } from '@/utils/time'\nimport toast from '@/utils/toast'\n\nconst SCOPE = 'SyncFailuresSheet'\n\nconst OPERATION_INFO = {\n\tadd_tracks: { label: '添加曲目', icon: 'plus-circle-outline' },\n\tremove_tracks: { label: '删除曲目', icon: 'minus-circle-outline' },\n\treorder_track: { label: '重新排序', icon: 'swap-vertical' },\n\tupdate_metadata: { label: '更新元数据', icon: 'pencil-outline' },\n}\n\n// DEV ONLY: 假数据，用于直接预览 Sheet 样式，不写数据库\nconst createMockRow = (\n\tid: number,\n\toperation: keyof typeof OPERATION_INFO,\n\tpayload: object,\n\ttimeOffset: number,\n): typeof schema.playlistSyncQueue.$inferSelect => ({\n\tid,\n\tplaylistId: 1,\n\toperation,\n\tpayload,\n\tstatus: 'failed',\n\toperationAt: new Date(Date.now() - timeOffset),\n\tcreatedAt: new Date(Date.now() - timeOffset),\n})\n\nconst DEV_MOCK_ROWS: (typeof schema.playlistSyncQueue.$inferSelect)[] = __DEV__\n\t? [\n\t\t\tcreateMockRow(1, 'add_tracks', { trackIds: [1, 2, 3] }, 3600000),\n\t\t\tcreateMockRow(2, 'remove_tracks', { removedTrackIds: [4, 5] }, 1800000),\n\t\t\tcreateMockRow(3, 'update_metadata', { title: '测试歌单' }, 300000),\n\t\t\tcreateMockRow(4, 'update_metadata', { title: '测试歌单' }, 300000),\n\t\t\tcreateMockRow(5, 'update_metadata', { title: '测试歌单' }, 300000),\n\t\t\tcreateMockRow(6, 'update_metadata', { title: '测试歌单' }, 300000),\n\t\t]\n\t: []\n\ninterface Props {\n\tplaylistId?: number\n\t/** DEV ONLY: 传入 true 直接展示 mock 数据，不读 DB */\n\tuseMockData?: boolean\n}\n\nexport const SyncFailuresSheet = forwardRef<TrueSheet, Props>(\n\tfunction SyncFailuresSheet({ playlistId, useMockData = false }, ref) {\n\t\tconst { colors } = useTheme()\n\t\tconst insets = useSafeAreaInsets()\n\t\tconst [loading, setLoading] = useState(false)\n\n\t\tconst { data: dbRows = [] } = useLiveQuery(\n\t\t\tdb\n\t\t\t\t.select()\n\t\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t\t.where(\n\t\t\t\t\tplaylistId != null\n\t\t\t\t\t\t? and(\n\t\t\t\t\t\t\t\teq(schema.playlistSyncQueue.playlistId, playlistId),\n\t\t\t\t\t\t\t\teq(schema.playlistSyncQueue.status, 'failed'),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t: eq(schema.playlistSyncQueue.status, 'failed'),\n\t\t\t\t),\n\t\t)\n\n\t\tconst rows = __DEV__ && useMockData ? DEV_MOCK_ROWS : dbRows\n\n\t\tconst handleRetry = async () => {\n\t\t\tif (!rows.length) {\n\t\t\t\tif (ref && 'current' in ref && ref.current) {\n\t\t\t\t\tvoid ref.current.dismiss()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetLoading(true)\n\t\t\tlet success = false\n\t\t\ttry {\n\t\t\t\tawait db\n\t\t\t\t\t.update(schema.playlistSyncQueue)\n\t\t\t\t\t.set({ status: 'pending' })\n\t\t\t\t\t.where(\n\t\t\t\t\t\tinArray(\n\t\t\t\t\t\t\tschema.playlistSyncQueue.id,\n\t\t\t\t\t\t\trows.map((r) => r.id),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\tplaylistSyncWorker.triggerSync()\n\t\t\t\ttoast.success('已重新加入同步队列')\n\t\t\t\tsuccess = true\n\t\t\t} catch (error) {\n\t\t\t\ttoastAndLogError('重试同步失败', error, SCOPE)\n\t\t\t} finally {\n\t\t\t\tsetLoading(false)\n\t\t\t}\n\n\t\t\tif (success) {\n\t\t\t\tif (ref && 'current' in ref && ref.current) {\n\t\t\t\t\tvoid ref.current.dismiss()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst getOperationInfo = (op: keyof typeof OPERATION_INFO) =>\n\t\t\tOPERATION_INFO[op] ?? { label: op, icon: 'help-circle-outline' }\n\n\t\treturn (\n\t\t\t<TrueSheetComponent\n\t\t\t\tref={ref}\n\t\t\t\tdetents={[0.5]}\n\t\t\t\tcornerRadius={24}\n\t\t\t\tbackgroundColor={colors.elevation.level1}\n\t\t\t\tscrollable\n\t\t\t>\n\t\t\t\t<GestureHandlerRootView style={{ flexGrow: 1 }}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[styles.container, { paddingBottom: insets.bottom + 20 }]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t同步失败记录\n\t\t\t\t\t\t</Text>\n\n\t\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<ActivityIndicator size='large' />\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : rows.length === 0 ? (\n\t\t\t\t\t\t\t<View style={styles.center}>\n\t\t\t\t\t\t\t\t<Text style={{ color: colors.onSurfaceVariant }}>\n\t\t\t\t\t\t\t\t\t暂无失败记录\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ScrollView\n\t\t\t\t\t\t\t\tstyle={styles.listContent}\n\t\t\t\t\t\t\t\tnestedScrollEnabled\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{rows.map((row) => {\n\t\t\t\t\t\t\t\t\tconst info = getOperationInfo(row.operation)\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\t\tkey={row.id}\n\t\t\t\t\t\t\t\t\t\t\tstyle={styles.row}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<View\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyles.iconContainer,\n\t\t\t\t\t\t\t\t\t\t\t\t\t{ backgroundColor: colors.elevation.level3 },\n\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\t\t\t\t\t\tsource={info.icon}\n\t\t\t\t\t\t\t\t\t\t\t\t\tsize={24}\n\t\t\t\t\t\t\t\t\t\t\t\t\tcolor={colors.onSurface}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t\t<View style={styles.rowInfo}>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text variant='bodyLarge'>{info.label}</Text>\n\t\t\t\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatRelativeTime(row.operationAt)}\n\t\t\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t\t</View>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</ScrollView>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<View style={styles.actions}>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\t\t\tonPress={handleRetry}\n\t\t\t\t\t\t\t\tloading={loading}\n\t\t\t\t\t\t\t\tdisabled={loading || rows.length === 0}\n\t\t\t\t\t\t\t\tstyle={styles.retryButton}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t全部重试\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\t\t\t\t</GestureHandlerRootView>\n\t\t\t</TrueSheetComponent>\n\t\t)\n\t},\n)\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tpaddingTop: 16,\n\t\tpaddingHorizontal: 16,\n\t\tmaxHeight: 500,\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t\tmarginBottom: 16,\n\t\ttextAlign: 'center',\n\t\tmarginTop: 16,\n\t},\n\tcenter: {\n\t\tpaddingVertical: 40,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tlistContent: {\n\t\tmaxHeight: 300,\n\t},\n\trow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingVertical: 12,\n\t\tborderBottomWidth: StyleSheet.hairlineWidth,\n\t\tborderBottomColor: 'rgba(150, 150, 150, 0.2)',\n\t},\n\ticonContainer: {\n\t\twidth: 40,\n\t\theight: 40,\n\t\tborderRadius: 20,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tmarginRight: 12,\n\t},\n\trowInfo: {\n\t\tflex: 1,\n\t\tgap: 2,\n\t},\n\tactions: {\n\t\tmarginTop: 24,\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'center',\n\t},\n\tretryButton: {\n\t\twidth: '100%',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistMenu.ts",
    "content": "import { DownloadState, Orpheus } from '@bbplayer/orpheus'\nimport * as Clipboard from 'expo-clipboard'\nimport { useRouter } from 'expo-router'\nimport { useCallback } from 'react'\n\nimport { alert } from '@/components/modals/AlertModal'\nimport type { TrackMenuItem } from '@/features/playlist/local/components/LocalPlaylistItem'\nimport { queryClient } from '@/lib/config/queryClient'\nimport type { Playlist, Track } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { convertToOrpheusTrack, getInternalPlayUri } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nconst SCOPE = 'UI.Playlist.Local.Menu'\n\ninterface LocalPlaylistMenuProps {\n\tdeleteTrack: (trackId: number) => void\n\topenAddToPlaylistModal: (track: Track) => void\n\topenEditTrackModal: (track: Track) => void\n\tplaylist: Playlist\n\tisReadOnly: boolean\n}\n\nexport function useLocalPlaylistMenu({\n\tdeleteTrack,\n\topenAddToPlaylistModal,\n\topenEditTrackModal,\n\tplaylist,\n\tisReadOnly,\n}: LocalPlaylistMenuProps) {\n\tconst router = useRouter()\n\n\tconst playNext = useCallback(async (track: Track) => {\n\t\ttry {\n\t\t\tconst oTrack = convertToOrpheusTrack(track)\n\t\t\tif (oTrack.isErr()) {\n\t\t\t\ttoastAndLogError('转换 Track 失败', oTrack.error, SCOPE)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tawait Orpheus.playNext(oTrack.value)\n\t\t\ttoast.success('添加到下一首播放成功')\n\t\t} catch (error) {\n\t\t\ttoastAndLogError('添加到队列失败', error, SCOPE)\n\t\t}\n\t}, [])\n\n\tconst menuFunctions = (\n\t\titem: Track,\n\t\tdownloadState?: DownloadState,\n\t): TrackMenuItem[] => {\n\t\tconst menuItems: TrackMenuItem[] = [\n\t\t\t{\n\t\t\t\ttitle: '下一首播放',\n\t\t\t\tleadingIcon: 'skip-next-circle-outline',\n\t\t\t\tonPress: () => playNext(item),\n\t\t\t\tisHighFreq: true,\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '添加到本地歌单',\n\t\t\t\tleadingIcon: 'playlist-plus',\n\t\t\t\tonPress: () => openAddToPlaylistModal(item),\n\t\t\t\tisHighFreq: true,\n\t\t\t},\n\t\t]\n\t\tif (item.source === 'bilibili') {\n\t\t\tmenuItems.push(\n\t\t\t\t{\n\t\t\t\t\ttitle: '查看详细信息',\n\t\t\t\t\tleadingIcon: 'file-document-outline',\n\t\t\t\t\tonPress: () =>\n\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\t\t\t\tparams: { bvid: item.bilibiliMetadata.bvid },\n\t\t\t\t\t\t}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttitle: '查看 up 主作品',\n\t\t\t\t\tleadingIcon: 'account-music',\n\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\tif (!item.artist?.remoteId) {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\t\t\t\tparams: { mid: item.artist?.remoteId },\n\t\t\t\t\t\t})\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ttitle:\n\t\t\t\t\t\tdownloadState === DownloadState.COMPLETED ? '删除缓存' : '缓存音频',\n\t\t\t\t\tleadingIcon:\n\t\t\t\t\t\tdownloadState === DownloadState.COMPLETED\n\t\t\t\t\t\t\t? 'delete-sweep'\n\t\t\t\t\t\t\t: 'download',\n\t\t\t\t\tonPress: async () => {\n\t\t\t\t\t\tif (downloadState === DownloadState.COMPLETED) {\n\t\t\t\t\t\t\tawait Orpheus.removeDownload(item.uniqueKey)\n\t\t\t\t\t\t\ttoast.success('删除缓存成功')\n\t\t\t\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\t\t\t\tqueryKey: ['batchDownloadStatus'],\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst url = getInternalPlayUri(item)\n\t\t\t\t\t\t\tif (!url) {\n\t\t\t\t\t\t\t\ttoastAndLogError('获取内部播放地址失败', '失败了！', SCOPE)\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tawait Orpheus.downloadTrack({\n\t\t\t\t\t\t\t\tid: item.uniqueKey,\n\t\t\t\t\t\t\t\turl: url,\n\t\t\t\t\t\t\t\ttitle: item.title,\n\t\t\t\t\t\t\t\tartist: item.artist?.name,\n\t\t\t\t\t\t\t\tartwork: item.coverUrl ?? undefined,\n\t\t\t\t\t\t\t\tduration: item.duration,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\ttoast.success('已开始下载')\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\ttoastAndLogError('缓存音频失败', error, SCOPE)\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tisHighFreq: true,\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\t\tmenuItems.push(\n\t\t\t{\n\t\t\t\ttitle: '复制封面链接',\n\t\t\t\tleadingIcon: 'link',\n\t\t\t\tonPress: () => {\n\t\t\t\t\tvoid Clipboard.setStringAsync(item.coverUrl ?? '')\n\t\t\t\t\ttoast.success('已复制到剪贴板')\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '改名',\n\t\t\t\tleadingIcon: 'pencil',\n\t\t\t\tonPress: () => openEditTrackModal(item),\n\t\t\t},\n\t\t)\n\t\tif (playlist?.type === 'local' && !isReadOnly) {\n\t\t\tmenuItems.push({\n\t\t\t\ttitle: '删除歌曲',\n\t\t\t\tleadingIcon: 'playlist-remove',\n\t\t\t\tonPress: () =>\n\t\t\t\t\talert(\n\t\t\t\t\t\t'确定？',\n\t\t\t\t\t\t'确定从列表中移除该歌曲？',\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: '取消',\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\t\t\tonPress: () => deleteTrack(item.id),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcancelable: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t\tdanger: true,\n\t\t\t})\n\t\t}\n\t\treturn menuItems\n\t}\n\n\treturn menuFunctions\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts",
    "content": "import { DownloadState, Orpheus } from '@bbplayer/orpheus'\nimport { useCallback } from 'react'\nimport type { MMKV } from 'react-native-mmkv'\nimport { useMMKVBoolean } from 'react-native-mmkv'\n\nimport { alert } from '@/components/modals/AlertModal'\nimport useCurrentTrackId from '@/hooks/player/useCurrentTrackId'\nimport { playlistService } from '@/lib/services/playlistService'\nimport type { Track } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { storage } from '@/utils/mmkv'\nimport { addToQueue } from '@/utils/player'\nimport { getInternalPlayUri } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nconst SCOPE = 'UI.Playlist.Local.Player'\n\nexport function useLocalPlaylistPlayer(\n\tplaylistId: number,\n\tisOffline?: boolean,\n\tplayableOfflineKeys?: Set<string>,\n) {\n\tconst currentTrackId = useCurrentTrackId()\n\tconst [ignoreAlertReplacePlaylist, setIgnoreAlertReplacePlaylist] =\n\t\tuseMMKVBoolean('ignore_alert_replace_playlist', storage as MMKV)\n\n\tconst playAll = useCallback(\n\t\tasync (startFromId?: string) => {\n\t\t\tconst tracksResult = await playlistService.getPlaylistTracks(playlistId)\n\t\t\tif (tracksResult.isErr()) {\n\t\t\t\ttoastAndLogError('获取播放列表内容失败', tracksResult.error, SCOPE)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlet tracks = tracksResult.value.filter((item) =>\n\t\t\t\titem.source === 'bilibili' ? item.bilibiliMetadata.videoIsValid : true,\n\t\t\t)\n\n\t\t\tif (isOffline) {\n\t\t\t\tconst originalLength = tracks.length\n\t\t\t\tconst urisToCheck: { uniqueKey: string; uri: string }[] = []\n\t\t\t\tconst keys = new Set<string>()\n\n\t\t\t\tfor (const track of tracks) {\n\t\t\t\t\tif (track.source === 'local') {\n\t\t\t\t\t\tkeys.add(track.uniqueKey)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tconst uri = getInternalPlayUri(track)\n\t\t\t\t\tif (uri) {\n\t\t\t\t\t\turisToCheck.push({ uniqueKey: track.uniqueKey, uri })\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst validUris = new Set(\n\t\t\t\t\tOrpheus.getLruCachedUris(urisToCheck.map((u) => u.uri)),\n\t\t\t\t)\n\t\t\t\tconst downloadStatus = await Orpheus.getDownloadStatusByIds(\n\t\t\t\t\turisToCheck.map((u) => u.uniqueKey),\n\t\t\t\t)\n\n\t\t\t\tfor (const item of urisToCheck) {\n\t\t\t\t\tif (\n\t\t\t\t\t\tvalidUris.has(item.uri) ||\n\t\t\t\t\t\tdownloadStatus?.[item.uniqueKey] === DownloadState.COMPLETED\n\t\t\t\t\t) {\n\t\t\t\t\t\tkeys.add(item.uniqueKey)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ttracks = tracks.filter((t) => keys.has(t.uniqueKey))\n\n\t\t\t\tif (tracks.length === 0) {\n\t\t\t\t\ttoast.show('当前离线，没有可播放的已缓存歌曲')\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (tracks.length < originalLength) {\n\t\t\t\t\ttoast.show('当前离线，仅添加可播放的已缓存歌曲到播放队列')\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!tracks || tracks.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tawait addToQueue({\n\t\t\t\t\ttracks: tracks,\n\t\t\t\t\tplayNow: true,\n\t\t\t\t\tclearQueue: true,\n\t\t\t\t\tstartFromKey: startFromId,\n\t\t\t\t\tplayNext: false,\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\ttoastAndLogError('播放全部失败', error, SCOPE)\n\t\t\t}\n\t\t},\n\t\t[playlistId, isOffline],\n\t)\n\n\tconst handleTrackPress = useCallback(\n\t\t(track: Track) => {\n\t\t\tif (\n\t\t\t\tisOffline &&\n\t\t\t\tplayableOfflineKeys &&\n\t\t\t\t!playableOfflineKeys.has(track.uniqueKey)\n\t\t\t) {\n\t\t\t\ttoast.show('当前无网络，无法播放，请检查网络设置')\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (track.uniqueKey === currentTrackId) return\n\t\t\tif (!ignoreAlertReplacePlaylist) {\n\t\t\t\talert(\n\t\t\t\t\t'替换播放列表',\n\t\t\t\t\t'点击列表中的单曲会直接替换当前播放列表，是否继续？（下次不再提醒）',\n\t\t\t\t\t[\n\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t\tsetIgnoreAlertReplacePlaylist(true)\n\t\t\t\t\t\t\t\tvoid playAll(track.uniqueKey)\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t],\n\t\t\t\t\t{ cancelable: true },\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tvoid playAll(track.uniqueKey)\n\t\t},\n\t\t[\n\t\t\tcurrentTrackId,\n\t\t\tignoreAlertReplacePlaylist,\n\t\t\tplayAll,\n\t\t\tsetIgnoreAlertReplacePlaylist,\n\t\t\tisOffline,\n\t\t\tplayableOfflineKeys,\n\t\t],\n\t)\n\n\treturn { playAll, handleTrackPress }\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/local/hooks/useTrackSelection.ts",
    "content": "import { useCallback, useState } from 'react'\n\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\n\nexport function useTrackSelection<T = number>() {\n\tconst [selected, setSelected] = useState<Set<T>>(() => new Set())\n\tconst [selectMode, setSelectMode] = useState<boolean>(false)\n\n\tconst toggle = useCallback((id: T) => {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(id)) {\n\t\t\t\tnext.delete(id)\n\t\t\t} else {\n\t\t\t\tnext.add(id)\n\t\t\t}\n\t\t\treturn next\n\t\t})\n\t}, [])\n\n\tconst enterSelectMode = useCallback((id?: T) => {\n\t\tsetSelectMode(true)\n\t\tif (id !== undefined) {\n\t\t\tsetSelected(new Set([id]))\n\t\t}\n\t}, [])\n\n\tconst exitSelectMode = useCallback(() => {\n\t\tsetSelectMode(false)\n\t\tsetSelected(new Set())\n\t}, [])\n\n\tusePreventRemove(selectMode, () => {\n\t\texitSelectMode()\n\t})\n\n\treturn {\n\t\tselected,\n\t\tselectMode,\n\t\ttoggle,\n\t\tenterSelectMode,\n\t\texitSelectMode,\n\t\tsetSelectMode,\n\t\tsetSelected,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/components/FlashingTrackListItem.tsx",
    "content": "import type { ComponentProps } from 'react'\nimport { useEffect } from 'react'\nimport { StyleSheet } from 'react-native'\nimport { useTheme } from 'react-native-paper'\nimport Animated, {\n\tuseAnimatedStyle,\n\tuseSharedValue,\n\twithSequence,\n\twithTiming,\n} from 'react-native-reanimated'\n\nimport { TrackListItem } from './PlaylistItem'\n\ntype TrackListItemProps = ComponentProps<typeof TrackListItem>\n\ninterface FlashingTrackListItemProps extends TrackListItemProps {\n\tshouldFlash?: boolean\n}\n\nexport function FlashingTrackListItem({\n\tshouldFlash,\n\t...props\n}: FlashingTrackListItemProps) {\n\tconst theme = useTheme()\n\tconst opacity = useSharedValue(0)\n\n\tconst animatedStyle = useAnimatedStyle(() => {\n\t\treturn {\n\t\t\tbackgroundColor: theme.colors.primaryContainer,\n\t\t\topacity: opacity.value,\n\t\t}\n\t})\n\n\tuseEffect(() => {\n\t\tif (shouldFlash) {\n\t\t\topacity.set(\n\t\t\t\twithSequence(\n\t\t\t\t\twithTiming(0.4, { duration: 300 }),\n\t\t\t\t\twithTiming(0, { duration: 300 }),\n\t\t\t\t\twithTiming(0.4, { duration: 300 }),\n\t\t\t\t\twithTiming(0, { duration: 300 }),\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\t}, [shouldFlash, opacity])\n\n\treturn (\n\t\t<Animated.View style={[styles.container]}>\n\t\t\t<TrackListItem {...props} />\n\t\t\t<Animated.View\n\t\t\t\tpointerEvents='none'\n\t\t\t\tstyle={[StyleSheet.absoluteFill, animatedStyle]}\n\t\t\t/>\n\t\t</Animated.View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tposition: 'relative',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/components/PlaylistError.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Text, useTheme } from 'react-native-paper'\n\nimport Button from '@/components/common/Button'\n\ninterface PlaylistErrorProps {\n\ttext?: string\n\tonRetry?: () => void\n}\n\nexport function PlaylistError({\n\ttext = '加载失败',\n\tonRetry,\n}: PlaylistErrorProps) {\n\tconst { colors } = useTheme()\n\treturn (\n\t\t<View style={[styles.container, { backgroundColor: colors.background }]}>\n\t\t\t<Text\n\t\t\t\tvariant='titleMedium'\n\t\t\t\tstyle={styles.text}\n\t\t\t>\n\t\t\t\t{text}\n\t\t\t</Text>\n\t\t\t{onRetry && (\n\t\t\t\t<Button\n\t\t\t\t\tonPress={onRetry}\n\t\t\t\t\tmode='contained'\n\t\t\t\t>\n\t\t\t\t\t重试\n\t\t\t\t</Button>\n\t\t\t)}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\ttext: {\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx",
    "content": "import * as Clipboard from 'expo-clipboard'\nimport type { ImageRef } from 'expo-image'\nimport { useRouter } from 'expo-router'\nimport { memo, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { Divider, Text, TouchableRipple } from 'react-native-paper'\nimport type { IconSource } from 'react-native-paper/lib/typescript/components/Icon'\n\nimport Button from '@/components/common/Button'\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport IconButton from '@/components/common/IconButton'\nimport toast from '@/utils/toast'\n\ninterface PlaylistHeaderProps {\n\tcover?: string | undefined | ImageRef\n\ttitle: string | undefined\n\tsubtitles: string | string[] | undefined // 通常格式： \"Author • n Tracks\"\n\tdescription: string | undefined\n\tonClickMainButton?: () => void\n\tmainButtonIcon: IconSource\n\tlinkedPlaylistId?: number\n\tid: string | number\n\tmainButtonText?: string\n\tdisableMainButton?: boolean\n\tsecondaryButtonText?: string\n\tsecondaryButtonIcon?: string\n\tonClickSecondaryButton?: () => void\n\tdisableSecondaryButton?: boolean\n}\n\n/**\n * 可复用的播放列表头部组件。\n */\nexport const PlaylistHeader = memo(function PlaylistHeader({\n\tcover,\n\ttitle,\n\tsubtitles,\n\tdescription,\n\tonClickMainButton,\n\tmainButtonIcon,\n\tmainButtonText,\n\tlinkedPlaylistId,\n\tid,\n\t...props\n}: PlaylistHeaderProps) {\n\tconst router = useRouter()\n\tconst [showFullTitle, setShowFullTitle] = useState(false)\n\tif (!title) return null\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t{/* 收藏夹信息 */}\n\t\t\t<View style={styles.headerContainer}>\n\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\tid={id}\n\t\t\t\t\tcover={cover}\n\t\t\t\t\ttitle={title}\n\t\t\t\t\tsize={120}\n\t\t\t\t/>\n\t\t\t\t<View style={styles.headerTextContainer}>\n\t\t\t\t\t<TouchableRipple\n\t\t\t\t\t\tonPress={() => setShowFullTitle(!showFullTitle)}\n\t\t\t\t\t\tonLongPress={async () => {\n\t\t\t\t\t\t\tconst result = await Clipboard.setStringAsync(title)\n\t\t\t\t\t\t\tif (!result) {\n\t\t\t\t\t\t\t\ttoast.error('复制失败')\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttoast.success('已复制标题到剪贴板')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleLarge'\n\t\t\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\t\t\tnumberOfLines={showFullTitle ? undefined : 2}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t</TouchableRipple>\n\t\t\t\t\t<Text\n\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\tnumberOfLines={Array.isArray(subtitles) ? subtitles.length : 1}\n\t\t\t\t\t>\n\t\t\t\t\t\t{Array.isArray(subtitles) ? subtitles.join('\\n') : subtitles}\n\t\t\t\t\t</Text>\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* 操作按钮 */}\n\t\t\t<View style={styles.actionsContainer}>\n\t\t\t\t{onClickMainButton && (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\ticon={mainButtonIcon}\n\t\t\t\t\t\tonPress={() => onClickMainButton()}\n\t\t\t\t\t\tdisabled={props.disableMainButton}\n\t\t\t\t\t\ttestID='playlist-header-main-button'\n\t\t\t\t\t>\n\t\t\t\t\t\t{mainButtonText ?? (linkedPlaylistId ? '重新同步' : '同步到本地')}\n\t\t\t\t\t</Button>\n\t\t\t\t)}\n\t\t\t\t{props.secondaryButtonText && props.onClickSecondaryButton && (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tmode='outlined'\n\t\t\t\t\t\ticon={props.secondaryButtonIcon}\n\t\t\t\t\t\tonPress={props.onClickSecondaryButton}\n\t\t\t\t\t\tstyle={{ marginLeft: 8 }}\n\t\t\t\t\t\tdisabled={props.disableSecondaryButton}\n\t\t\t\t\t>\n\t\t\t\t\t\t{props.secondaryButtonText}\n\t\t\t\t\t</Button>\n\t\t\t\t)}\n\t\t\t\t{linkedPlaylistId && (\n\t\t\t\t\t<IconButton\n\t\t\t\t\t\tmode='contained'\n\t\t\t\t\t\ticon={'arrow-right'}\n\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\trouter.push({\n\t\t\t\t\t\t\t\tpathname: '/playlist/local/[id]',\n\t\t\t\t\t\t\t\tparams: { id: linkedPlaylistId.toString() },\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t</View>\n\n\t\t\t<Text\n\t\t\t\tvariant='bodyMedium'\n\t\t\t\tstyle={[styles.description, !!description && styles.descriptionMargin]}\n\t\t\t>\n\t\t\t\t{description ?? ''}\n\t\t\t</Text>\n\n\t\t\t<Divider />\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tposition: 'relative',\n\t\tflexDirection: 'column',\n\t},\n\theaderContainer: {\n\t\tflexDirection: 'row',\n\t\tpadding: 16,\n\t\talignItems: 'center',\n\t},\n\theaderTextContainer: {\n\t\tmarginLeft: 16,\n\t\tflex: 1,\n\t\tjustifyContent: 'center',\n\t},\n\ttitle: {\n\t\tfontWeight: 'bold',\n\t},\n\tactionsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'flex-start',\n\t\tmarginHorizontal: 16,\n\t},\n\tdescription: {\n\t\tmargin: 0,\n\t},\n\tdescriptionMargin: {\n\t\tmargin: 16,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/components/PlaylistItem.tsx",
    "content": "import { memo, useRef } from 'react'\nimport { StyleSheet, useColorScheme, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Checkbox, Icon, Surface, Text, useTheme } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport useIsCurrentTrack from '@/hooks/player/useIsCurrentTrack'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport {\n\tLIST_ITEM_BORDER_RADIUS,\n\tLIST_ITEM_COVER_SIZE,\n} from '@/theme/dimensions'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nexport interface TrackMenuItem {\n\ttitle: string\n\tleadingIcon: string\n\tonPress: () => void\n}\n\nexport const TrackMenuItemDividerToken: TrackMenuItem = {\n\ttitle: 'divider',\n\tleadingIcon: '',\n\tonPress: () => void 0,\n}\n\nexport interface TrackNecessaryData {\n\tcover?: string\n\tartistCover?: string\n\ttitle: string\n\tduration: number\n\tid: number\n\tartistName?: string\n\tuniqueKey: string\n\ttitleHtml?: string\n}\n\ninterface TrackListItemProps {\n\tindex: number\n\tonTrackPress: () => void\n\tonMenuPress: (anchor: { x: number; y: number }) => void\n\tshowCoverImage?: boolean\n\tdata: TrackNecessaryData\n\tdisabled?: boolean\n\ttoggleSelected: (id: number) => void\n\tisSelected: boolean\n\tselectMode: boolean\n\tenterSelectMode: (id: number) => void\n}\n\nconst HighlightedText = ({\n\ttext,\n\t...props\n}: Omit<React.ComponentProps<typeof Text>, 'children'> & { text: string }) => {\n\tconst { colors } = useTheme()\n\tconst parts = text.split(/(<em[^>]*>.*?<\\/em>)/g)\n\treturn (\n\t\t<Text {...props}>\n\t\t\t{parts.map((part, index) => {\n\t\t\t\tconst match = /<em[^>]*>(.*?)<\\/em>/.exec(part)\n\t\t\t\tif (match) {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\tstyle={{ fontWeight: 'bold', color: colors.primary }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{match[1]}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\treturn <Text key={index}>{part}</Text>\n\t\t\t})}\n\t\t</Text>\n\t)\n}\n\n/**\n * 可复用的播放列表项目组件。\n */\nexport const TrackListItem = memo(function TrackListItem({\n\tindex,\n\tonTrackPress,\n\tonMenuPress,\n\tshowCoverImage = true,\n\tdata,\n\tdisabled = false,\n\ttoggleSelected,\n\tisSelected,\n\tselectMode,\n\tenterSelectMode,\n}: TrackListItemProps) {\n\tconst { colors } = useTheme()\n\tconst dark = useColorScheme() === 'dark'\n\tconst menuRef = useRef<View>(null)\n\tconst isCurrentTrack = useIsCurrentTrack(data.uniqueKey)\n\n\t// 在非选择模式下，当前播放歌曲高亮；在选择模式下，歌曲被选中时高亮\n\tconst highlighted = (isCurrentTrack && !selectMode) || isSelected\n\n\treturn (\n\t\t<RectButton\n\t\t\tstyle={[\n\t\t\t\tstyles.rectButton,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: highlighted\n\t\t\t\t\t\t? dark\n\t\t\t\t\t\t\t? 'rgba(255, 255, 255, 0.12)'\n\t\t\t\t\t\t\t: 'rgba(0, 0, 0, 0.12)'\n\t\t\t\t\t\t: 'transparent',\n\t\t\t\t},\n\t\t\t]}\n\t\t\tdelayLongPress={500}\n\t\t\tenabled={!disabled}\n\t\t\tonPress={() => {\n\t\t\t\tif (selectMode) {\n\t\t\t\t\ttoggleSelected(data.id)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (isCurrentTrack) return\n\t\t\t\tvoid analyticsService.logPlayerQueueAction('play_item')\n\t\t\t\tonTrackPress()\n\t\t\t}}\n\t\t\tonLongPress={() => {\n\t\t\t\tif (selectMode) return\n\t\t\t\tenterSelectMode(data.id)\n\t\t\t}}\n\t\t\ttestID={`track-item-${data.id}`}\n\t\t>\n\t\t\t<Surface\n\t\t\t\tstyle={styles.surface}\n\t\t\t\televation={0}\n\t\t\t>\n\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t{/* Index Number & Checkbox Container */}\n\t\t\t\t\t<View style={styles.indexContainer}>\n\t\t\t\t\t\t{/* 始终渲染，或许能降低一点性能开销？ */}\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.checkboxContainer,\n\t\t\t\t\t\t\t\t{ opacity: selectMode ? 1 : 0 },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Checkbox status={isSelected ? 'checked' : 'unchecked'} />\n\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t{/* 序号也是 */}\n\t\t\t\t\t\t<View style={{ opacity: selectMode ? 0 : 1 }}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{/* Cover Image */}\n\t\t\t\t\t{showCoverImage ? (\n\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\tid={data.id}\n\t\t\t\t\t\t\tcover={data.cover}\n\t\t\t\t\t\t\ttitle={data.title}\n\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : null}\n\n\t\t\t\t\t{/* Title and Details */}\n\t\t\t\t\t<View style={styles.titleContainer}>\n\t\t\t\t\t\t{data.titleHtml ? (\n\t\t\t\t\t\t\t<HighlightedText\n\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\ttext={data.titleHtml}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<Text variant='bodySmall'>{data.title}</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<View style={styles.detailsContainer}>\n\t\t\t\t\t\t\t{/* Display Artist if available */}\n\t\t\t\t\t\t\t{data.artistName && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{data.artistName ?? '未知'}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tstyle={styles.dotSeparator}\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Display Duration */}\n\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t{data.duration ? formatDurationToHHMMSS(data.duration) : ''}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{/* Context Menu */}\n\t\t\t\t\t{!disabled && (\n\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t// @ts-expect-error -- 不理解\n\t\t\t\t\t\t\tref={menuRef}\n\t\t\t\t\t\t\tstyle={styles.menuButton}\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tmenuRef.current?.measure(\n\t\t\t\t\t\t\t\t\t(_x, _y, _width, _height, pageX, pageY) => {\n\t\t\t\t\t\t\t\t\t\tonMenuPress({ x: pageX, y: pageY })\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tenabled={!selectMode}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tsource='dots-vertical'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\tcolor={selectMode ? colors.onSurfaceDisabled : colors.primary}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</Surface>\n\t\t</RectButton>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 4,\n\t},\n\tsurface: {\n\t\toverflow: 'hidden',\n\t\tborderRadius: LIST_ITEM_BORDER_RADIUS,\n\t\tbackgroundColor: 'transparent',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 6,\n\t},\n\tindexContainer: {\n\t\twidth: 35,\n\t\tmarginRight: 8,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tcheckboxContainer: {\n\t\tposition: 'absolute',\n\t},\n\ttitleContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tmarginRight: 4,\n\t},\n\tdetailsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginTop: 2,\n\t\tflexWrap: 'wrap',\n\t},\n\tdotSeparator: {\n\t\tmarginHorizontal: 4,\n\t},\n\tmenuButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/components/RemoteTrackList.tsx",
    "content": "import type {\n\tFlashListProps,\n\tFlashListRef,\n\tListRenderItem,\n} from '@shopify/flash-list'\nimport { FlashList } from '@shopify/flash-list'\nimport type { RefObject } from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport {\n\tActivityIndicator,\n\tDivider,\n\tMenu,\n\tText,\n\tuseTheme,\n} from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport FunctionalMenu from '@/components/common/FunctionalMenu'\nimport useCurrentTrackId from '@/hooks/player/useCurrentTrackId'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport type {\n\tListRenderItemInfoWithExtraData,\n\tSelectionState,\n} from '@/types/flashlist'\nimport * as Haptics from '@/utils/haptics'\n\nimport { TrackListItem } from './PlaylistItem'\n\ninterface TrackListProps extends Omit<\n\tFlashListProps<BilibiliTrack>,\n\t'data' | 'renderItem' | 'extraData'\n> {\n\t/**\n\t * 要显示的曲目数据数组\n\t */\n\ttracks: BilibiliTrack[]\n\t/**\n\t * 点击曲目时的回调函数\n\t */\n\tplayTrack: (track: BilibiliTrack) => void\n\t/**\n\t * 生成曲目菜单项的函数\n\t */\n\ttrackMenuItems: (\n\t\ttrack: BilibiliTrack,\n\t) => { title: string; leadingIcon: string; onPress: () => void }[]\n\t/**\n\t * 多选状态管理\n\t */\n\tselection: SelectionState\n\t/**\n\t * 是否显示封面图片，默认为 true\n\t */\n\tshowItemCover?: boolean\n\t/**\n\t * 是否正在获取下一页数据\n\t */\n\tisFetchingNextPage?: boolean\n\t/**\n\t * 是否还有下一页数据\n\t */\n\thasNextPage?: boolean\n\t/**\n\t * 自定义渲染列表项的函数（可选）\n\t */\n\trenderCustomItem?: (\n\t\tinfo: ListRenderItemInfoWithExtraData<BilibiliTrack, ExtraData>,\n\t) => React.ReactElement | null\n\t/**\n\t * 列表引用（可选）\n\t */\n\tlistRef?: React.Ref<FlashListRef<BilibiliTrack>>\n}\n\nexport interface ExtraData {\n\tplayTrack: (track: BilibiliTrack) => void\n\thandleMenuPress: (\n\t\ttrack: BilibiliTrack,\n\t\tanchor: { x: number; y: number },\n\t) => void\n\tselection: SelectionState\n\tshowItemCover?: boolean\n\tcurrentTrackIdRef: RefObject<string | undefined>\n}\n\nconst renderItemDefault = ({\n\titem,\n\tindex,\n\textraData,\n}: ListRenderItemInfoWithExtraData<BilibiliTrack, ExtraData>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst {\n\t\tplayTrack,\n\t\thandleMenuPress,\n\t\tselection,\n\t\tshowItemCover,\n\t\tcurrentTrackIdRef,\n\t} = extraData\n\treturn (\n\t\t<TrackListItem\n\t\t\tindex={index}\n\t\t\tonTrackPress={() => {\n\t\t\t\tif (item.uniqueKey === currentTrackIdRef.current) return\n\t\t\t\tplayTrack(item)\n\t\t\t}}\n\t\t\tonMenuPress={(anchor) => handleMenuPress(item, anchor)}\n\t\t\tshowCoverImage={showItemCover ?? true}\n\t\t\tdata={{\n\t\t\t\tcover: item.coverUrl ?? undefined,\n\t\t\t\ttitle: item.title,\n\t\t\t\tduration: item.duration,\n\t\t\t\tid: item.id,\n\t\t\t\tartistName: item.artist?.name,\n\t\t\t\tuniqueKey: item.uniqueKey,\n\t\t\t\ttitleHtml: item.titleHtml,\n\t\t\t}}\n\t\t\ttoggleSelected={() => {\n\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Clock_Tick)\n\t\t\t\tselection.toggle(item.id)\n\t\t\t}}\n\t\t\tisSelected={selection.selected.has(item.id)}\n\t\t\tselectMode={selection.active}\n\t\t\tenterSelectMode={() => {\n\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Long_Press)\n\t\t\t\tselection.enter(item.id)\n\t\t\t}}\n\t\t/>\n\t)\n}\n\nexport function TrackList({\n\ttracks,\n\tplayTrack,\n\ttrackMenuItems,\n\tselection,\n\tshowItemCover,\n\tisFetchingNextPage,\n\thasNextPage,\n\trenderCustomItem,\n\tlistRef,\n\t...flashListProps\n}: TrackListProps) {\n\tconst { colors } = useTheme()\n\tconst currentTrackId = useCurrentTrackId()\n\tconst currentTrackIdRef = useRef(currentTrackId)\n\n\tuseEffect(() => {\n\t\tcurrentTrackIdRef.current = currentTrackId\n\t}, [currentTrackId])\n\tconst insets = useSafeAreaInsets()\n\n\tconst [menuState, setMenuState] = useState<{\n\t\tvisible: boolean\n\t\tanchor: { x: number; y: number }\n\t\ttrack: BilibiliTrack | null\n\t}>({\n\t\tvisible: false,\n\t\tanchor: { x: 0, y: 0 },\n\t\ttrack: null,\n\t})\n\n\tconst handleDismissMenu = useCallback(() => {\n\t\tsetMenuState((prev) => ({ ...prev, visible: false }))\n\t}, [])\n\n\tconst keyExtractor = useCallback((item: BilibiliTrack) => {\n\t\treturn String(item.id)\n\t}, [])\n\n\tconst handleMenuPress = useCallback(\n\t\t(track: BilibiliTrack, anchor: { x: number; y: number }) => {\n\t\t\tsetMenuState({ visible: true, anchor, track })\n\t\t},\n\t\t[],\n\t)\n\n\tconst extraData = useMemo(\n\t\t() => ({\n\t\t\tselection,\n\t\t\tplayTrack,\n\t\t\tshowItemCover,\n\t\t\tcurrentTrackIdRef,\n\t\t\thandleMenuPress,\n\t\t}),\n\t\t[selection, playTrack, showItemCover, handleMenuPress],\n\t)\n\n\tconst renderItem = renderCustomItem ?? renderItemDefault\n\n\treturn (\n\t\t<>\n\t\t\t<FlashList\n\t\t\t\tref={listRef}\n\t\t\t\tdata={tracks}\n\t\t\t\textraData={extraData}\n\t\t\t\trenderItem={renderItem as ListRenderItem<BilibiliTrack>}\n\t\t\t\tItemSeparatorComponent={() => <Divider />}\n\t\t\t\tkeyExtractor={keyExtractor}\n\t\t\t\tshowsVerticalScrollIndicator={false}\n\t\t\t\tcontentContainerStyle={{\n\t\t\t\t\t// 实现一个在 menu 弹出时，列表不可触摸的效果\n\t\t\t\t\tpointerEvents: menuState.visible ? 'none' : 'auto',\n\t\t\t\t\tpaddingBottom: currentTrackId ? 70 + insets.bottom : insets.bottom,\n\t\t\t\t}}\n\t\t\t\tListFooterComponent={\n\t\t\t\t\t(isFetchingNextPage ? (\n\t\t\t\t\t\t<View style={styles.footerLoadingContainer}>\n\t\t\t\t\t\t\t<ActivityIndicator size='small' />\n\t\t\t\t\t\t</View>\n\t\t\t\t\t) : hasNextPage ? (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tvariant='titleMedium'\n\t\t\t\t\t\t\tstyle={styles.footerReachedEnd}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t•\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t) : null) ?? flashListProps.ListFooterComponent\n\t\t\t\t}\n\t\t\t\tListEmptyComponent={\n\t\t\t\t\tflashListProps.ListEmptyComponent ?? (\n\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\tstyle={[styles.emptyList, { color: colors.onSurfaceVariant }]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t什么都没找到哦~\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\t{...flashListProps}\n\t\t\t/>\n\t\t\t{menuState.track && (\n\t\t\t\t<FunctionalMenu\n\t\t\t\t\tvisible={menuState.visible}\n\t\t\t\t\tonDismiss={handleDismissMenu}\n\t\t\t\t\tanchor={menuState.anchor}\n\t\t\t\t\tanchorPosition='bottom'\n\t\t\t\t>\n\t\t\t\t\t{trackMenuItems(menuState.track).map((item) => (\n\t\t\t\t\t\t<Menu.Item\n\t\t\t\t\t\t\tkey={item.title}\n\t\t\t\t\t\t\tleadingIcon={item.leadingIcon}\n\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\titem.onPress()\n\t\t\t\t\t\t\t\thandleDismissMenu()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttitle={item.title}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</FunctionalMenu>\n\t\t\t)}\n\t\t</>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tfooterLoadingContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tpadding: 16,\n\t},\n\tfooterReachedEnd: {\n\t\ttextAlign: 'center',\n\t\tpaddingTop: 10,\n\t},\n\temptyList: {\n\t\tpaddingVertical: 32,\n\t\ttextAlign: 'center',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/hooks/useCheckLinkedToLocalPlaylist.ts",
    "content": "import { useEffect, useState } from 'react'\n\nimport { playlistService } from '@/lib/services/playlistService'\nimport type { Playlist } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\n\n/**\n * 检查某个 remoteId 是否已经被关联到本地播放列表\n * @param remoteId\n * @param type\n * @returns\n */\nexport default function useCheckLinkedToPlaylist(\n\tremoteId: number,\n\ttype: Playlist['type'],\n) {\n\tconst [linkedPlaylistId, setLinkedPlaylistId] = useState<undefined | number>(\n\t\tundefined,\n\t)\n\n\tuseEffect(() => {\n\t\tconst check = async () => {\n\t\t\tconst playlist = await playlistService.findPlaylistByTypeAndRemoteId(\n\t\t\t\ttype,\n\t\t\t\tremoteId,\n\t\t\t)\n\t\t\tif (playlist.isErr()) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\t`查询 ${type}-${remoteId} 是否在本地存在失败`,\n\t\t\t\t\tplaylist.error,\n\t\t\t\t\t'UI.Playlist.Remote',\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetLinkedPlaylistId(playlist.value ? playlist.value.id : undefined)\n\t\t}\n\t\tvoid check()\n\t}, [remoteId, type])\n\n\treturn linkedPlaylistId\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/hooks/usePlaylistMenu.ts",
    "content": "import { usePathname, useRouter } from 'expo-router'\nimport { useCallback } from 'react'\n\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport toast from '@/utils/toast'\n\nexport function usePlaylistMenu(\n\tplayTrack: (track: BilibiliTrack, playNext: boolean) => void,\n) {\n\tconst router = useRouter()\n\tconst pathname = usePathname()\n\tconst openModal = useModalStore((state) => state.open)\n\n\treturn useCallback(\n\t\t(item: BilibiliTrack) => [\n\t\t\t{\n\t\t\t\ttitle: '下一首播放',\n\t\t\t\tleadingIcon: 'skip-next-circle-outline',\n\t\t\t\tonPress: () => playTrack(item, true),\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '查看详细信息',\n\t\t\t\tleadingIcon: 'file-document-outline',\n\t\t\t\tonPress: () => {\n\t\t\t\t\tif (pathname.includes('multipage')) {\n\t\t\t\t\t\ttoast.info('你已经在这里了，没法更深入了！')\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\trouter.push({\n\t\t\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\t\t\tparams: { bvid: item.bilibiliMetadata.bvid },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '添加到本地歌单',\n\t\t\t\tleadingIcon: 'playlist-plus',\n\t\t\t\tonPress: () => {\n\t\t\t\t\topenModal('UpdateTrackLocalPlaylists', { track: item })\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '查看 up 主作品',\n\t\t\t\tleadingIcon: 'account-music',\n\t\t\t\tonPress: () => {\n\t\t\t\t\tif (!item.artist?.remoteId) {\n\t\t\t\t\t\ttoast.error('未找到 up 主信息')\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\trouter.push({\n\t\t\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\t\t\tparams: { mid: item.artist?.remoteId },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t\t[router, openModal, playTrack, pathname],\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/hooks/useRemotePlaylist.ts",
    "content": "import { useCallback } from 'react'\n\nimport { syncFacade } from '@/lib/facades/syncBilibiliPlaylist'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { reportErrorToSentry } from '@/utils/log'\nimport { addToQueue } from '@/utils/player'\n\nexport function useRemotePlaylist() {\n\tconst playTrack = useCallback(\n\t\tasync (track: BilibiliTrack, playNext = false) => {\n\t\t\tconst createIt = await syncFacade.addTrackToLocal(track)\n\t\t\tif (createIt.isErr()) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\t'将 track 录入本地失败',\n\t\t\t\t\tcreateIt.error,\n\t\t\t\t\t'UI.Playlist.Remote',\n\t\t\t\t)\n\t\t\t\treportErrorToSentry(\n\t\t\t\t\tcreateIt.error,\n\t\t\t\t\t'将 track 录入本地失败',\n\t\t\t\t\t'UI.Playlist.Remote',\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tvoid addToQueue({\n\t\t\t\ttracks: [track],\n\t\t\t\tplayNow: !playNext,\n\t\t\t\tclearQueue: false,\n\t\t\t\tplayNext: playNext,\n\t\t\t\tstartFromKey: track.uniqueKey,\n\t\t\t})\n\t\t},\n\t\t[],\n\t)\n\n\treturn { playTrack }\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/hooks/useTrackSelection.ts",
    "content": "import { useCallback, useState } from 'react'\n\nimport usePreventRemove from '@/hooks/router/usePreventRemove'\n\nexport function useTrackSelection() {\n\tconst [selected, setSelected] = useState<Set<number>>(() => new Set())\n\tconst [selectMode, setSelectMode] = useState<boolean>(false)\n\n\tconst toggle = useCallback((id: number) => {\n\t\tsetSelected((prev) => {\n\t\t\tconst next = new Set(prev)\n\t\t\tif (next.has(id)) {\n\t\t\t\tnext.delete(id)\n\t\t\t} else {\n\t\t\t\tnext.add(id)\n\t\t\t}\n\t\t\treturn next\n\t\t})\n\t}, [])\n\n\tconst enterSelectMode = useCallback((id: number) => {\n\t\tsetSelectMode(true)\n\t\tsetSelected(new Set([id]))\n\t}, [])\n\n\tconst exitSelectMode = useCallback(() => {\n\t\tsetSelectMode(false)\n\t\tsetSelected(new Set())\n\t}, [])\n\n\tusePreventRemove(selectMode, () => {\n\t\texitSelectMode()\n\t})\n\n\treturn {\n\t\tselected,\n\t\tselectMode,\n\t\ttoggle,\n\t\tenterSelectMode,\n\t\texitSelectMode,\n\t\tsetSelectMode,\n\t\tsetSelected,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/search-result/constants.ts",
    "content": "const MULTIPAGE_VIDEO_KEYWORDS = [\n\t'分P系列',\n\t'分p系列',\n\t'分P',\n\t'分p',\n\t'OST',\n\t'原声带',\n\t'专辑',\n\t'Original Soundtrack',\n]\n\nexport { MULTIPAGE_VIDEO_KEYWORDS }\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts",
    "content": "import { useRouter } from 'expo-router'\nimport { useCallback } from 'react'\n\nimport { MULTIPAGE_VIDEO_KEYWORDS } from '@/features/playlist/remote/search-result/constants'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { syncFacade } from '@/lib/facades/syncBilibiliPlaylist'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { reportErrorToSentry } from '@/utils/log'\nimport { addToQueue } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nexport function useSearchInteractions() {\n\tconst router = useRouter()\n\tconst openModal = useModalStore((state) => state.open)\n\n\tconst playTrack = useCallback(\n\t\tasync (track: BilibiliTrack, playNext = false) => {\n\t\t\tif (\n\t\t\t\tMULTIPAGE_VIDEO_KEYWORDS.some((keyword) =>\n\t\t\t\t\ttrack.title?.includes(keyword),\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\trouter.push({\n\t\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\t\tparams: { bvid: track.bilibiliMetadata.bvid },\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst createIt = await syncFacade.addTrackToLocal(track)\n\t\t\tif (createIt.isErr()) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\t'将 track 录入本地失败',\n\t\t\t\t\tcreateIt.error,\n\t\t\t\t\t'UI.Playlist.Remote',\n\t\t\t\t)\n\t\t\t\treportErrorToSentry(\n\t\t\t\t\tcreateIt.error,\n\t\t\t\t\t'将 track 录入本地失败',\n\t\t\t\t\t'UI.Playlist.Remote',\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tawait addToQueue({\n\t\t\t\ttracks: [track],\n\t\t\t\tplayNow: !playNext,\n\t\t\t\tclearQueue: false,\n\t\t\t\tplayNext: playNext,\n\t\t\t\tstartFromKey: track.uniqueKey,\n\t\t\t})\n\t\t\tif (playNext) {\n\t\t\t\ttoast.success('添加到下一首播放成功')\n\t\t\t}\n\t\t},\n\t\t[router],\n\t)\n\n\tconst trackMenuItems = useCallback(\n\t\t(item: BilibiliTrack) => [\n\t\t\t{\n\t\t\t\ttitle: '下一首播放',\n\t\t\t\tleadingIcon: 'skip-next-circle-outline',\n\t\t\t\tonPress: () => playTrack(item, true),\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '查看详细信息',\n\t\t\t\tleadingIcon: 'file-document-outline',\n\t\t\t\tonPress: () => {\n\t\t\t\t\trouter.push({\n\t\t\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\t\t\tparams: { bvid: item.bilibiliMetadata.bvid },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '添加到本地歌单',\n\t\t\t\tleadingIcon: 'playlist-plus',\n\t\t\t\tonPress: () => {\n\t\t\t\t\topenModal('UpdateTrackLocalPlaylists', { track: item })\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: '查看 up 主作品',\n\t\t\t\tleadingIcon: 'account-music',\n\t\t\t\tonPress: () => {\n\t\t\t\t\tif (!item.artist?.remoteId) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\trouter.push({\n\t\t\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\t\t\tparams: { mid: item.artist?.remoteId },\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t\t[router, openModal, playTrack],\n\t)\n\n\treturn {\n\t\tplayTrack,\n\t\ttrackMenuItems,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/toview/components/Item.tsx",
    "content": "import { memo, useRef } from 'react'\nimport { StyleSheet, useColorScheme, View } from 'react-native'\nimport { RectButton } from 'react-native-gesture-handler'\nimport { Checkbox, Icon, Surface, Text, useTheme } from 'react-native-paper'\n\nimport CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder'\nimport type { ExtraData } from '@/features/playlist/remote/components/RemoteTrackList'\nimport useIsCurrentTrack from '@/hooks/player/useIsCurrentTrack'\nimport {\n\tLIST_ITEM_BORDER_RADIUS,\n\tLIST_ITEM_COVER_SIZE,\n} from '@/theme/dimensions'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport type { ListRenderItemInfoWithExtraData } from '@/types/flashlist'\nimport * as Haptics from '@/utils/haptics'\nimport { formatDurationToHHMMSS } from '@/utils/time'\n\nimport ProgressRing from './ProgressRing'\n\nexport interface TrackMenuItem {\n\ttitle: string\n\tleadingIcon: string\n\tonPress: () => void\n}\n\nexport const TrackMenuItemDividerToken: TrackMenuItem = {\n\ttitle: 'divider',\n\tleadingIcon: '',\n\tonPress: () => void 0,\n}\n\nexport interface TrackNecessaryData {\n\tcover?: string\n\tartistCover?: string\n\ttitle: string\n\tduration: number\n\tid: number\n\tartistName?: string\n\tuniqueKey: string\n}\n\ninterface TrackListItemProps {\n\tindex: number\n\tonTrackPress: () => void\n\tonMenuPress: (anchor: { x: number; y: number }) => void\n\tshowCoverImage?: boolean\n\tdata: TrackNecessaryData & { progress: number }\n\tdisabled?: boolean\n\ttoggleSelected: (id: number) => void\n\tisSelected: boolean\n\tselectMode: boolean\n\tenterSelectMode: (id: number) => void\n}\n\n/**\n * 可复用的播放列表项目组件。\n */\nexport const ToViewTrackListItem = memo(function ToViewTrackListItem({\n\tindex,\n\tonTrackPress,\n\tonMenuPress,\n\tshowCoverImage = true,\n\tdata,\n\tdisabled = false,\n\ttoggleSelected,\n\tisSelected,\n\tselectMode,\n\tenterSelectMode,\n}: TrackListItemProps) {\n\tconst { colors } = useTheme()\n\tconst menuRef = useRef<View>(null)\n\tconst dark = useColorScheme() === 'dark'\n\tconst isCurrentTrack = useIsCurrentTrack(data.uniqueKey)\n\n\tconst highlighted = (isCurrentTrack && !selectMode) || isSelected\n\n\treturn (\n\t\t<RectButton\n\t\t\tstyle={[\n\t\t\t\tstyles.rectButton,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: highlighted\n\t\t\t\t\t\t? dark\n\t\t\t\t\t\t\t? 'rgba(255, 255, 255, 0.12)'\n\t\t\t\t\t\t\t: 'rgba(0, 0, 0, 0.12)'\n\t\t\t\t\t\t: 'transparent',\n\t\t\t\t},\n\t\t\t]}\n\t\t\tdelayLongPress={500}\n\t\t\tenabled={!disabled}\n\t\t\tonPress={() => {\n\t\t\t\tif (selectMode) {\n\t\t\t\t\ttoggleSelected(data.id)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (isCurrentTrack) return\n\t\t\t\tonTrackPress()\n\t\t\t}}\n\t\t\tonLongPress={() => {\n\t\t\t\tif (selectMode) return\n\t\t\t\tenterSelectMode(data.id)\n\t\t\t}}\n\t\t>\n\t\t\t<Surface\n\t\t\t\tstyle={styles.surface}\n\t\t\t\televation={0}\n\t\t\t>\n\t\t\t\t<View style={styles.itemContainer}>\n\t\t\t\t\t{/* Index Number & Checkbox Container */}\n\t\t\t\t\t<View style={styles.indexContainer}>\n\t\t\t\t\t\t{/* 始终渲染，或许能降低一点性能开销？ */}\n\t\t\t\t\t\t<View\n\t\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\t\tstyles.checkboxContainer,\n\t\t\t\t\t\t\t\t{ opacity: selectMode ? 1 : 0 },\n\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Checkbox status={isSelected ? 'checked' : 'unchecked'} />\n\t\t\t\t\t\t</View>\n\n\t\t\t\t\t\t{/* 序号也是 */}\n\t\t\t\t\t\t<View style={{ opacity: selectMode ? 0 : 1 }}>\n\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\tvariant='bodyMedium'\n\t\t\t\t\t\t\t\tstyle={{ color: colors.onSurfaceVariant }}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t{/* Cover Image */}\n\t\t\t\t\t{showCoverImage ? (\n\t\t\t\t\t\t<CoverWithPlaceHolder\n\t\t\t\t\t\t\tid={data.id}\n\t\t\t\t\t\t\tcover={data.cover}\n\t\t\t\t\t\t\ttitle={data.title}\n\t\t\t\t\t\t\tsize={LIST_ITEM_COVER_SIZE}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : null}\n\n\t\t\t\t\t{/* Title and Details */}\n\t\t\t\t\t<View style={styles.titleContainer}>\n\t\t\t\t\t\t<Text variant='bodySmall'>{data.title}</Text>\n\t\t\t\t\t\t<View style={styles.detailsContainer}>\n\t\t\t\t\t\t\t{/* Display Artist if available */}\n\t\t\t\t\t\t\t{data.artistName && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{data.artistName ?? '未知'}\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t<Text\n\t\t\t\t\t\t\t\t\t\tstyle={styles.dotSeparator}\n\t\t\t\t\t\t\t\t\t\tvariant='bodySmall'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t•\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{/* Display Duration */}\n\t\t\t\t\t\t\t<Text variant='bodySmall'>\n\t\t\t\t\t\t\t\t{data.duration ? formatDurationToHHMMSS(data.duration) : ''}\n\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t</View>\n\t\t\t\t\t</View>\n\n\t\t\t\t\t<ProgressRing\n\t\t\t\t\t\tprogressInSeconds={data.progress}\n\t\t\t\t\t\tdurationInSeconds={data.duration}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{/* Context Menu */}\n\t\t\t\t\t{!disabled && (\n\t\t\t\t\t\t<RectButton\n\t\t\t\t\t\t\t// @ts-expect-error -- 不理解\n\t\t\t\t\t\t\tref={menuRef}\n\t\t\t\t\t\t\tstyle={styles.menuButton}\n\t\t\t\t\t\t\tonPress={() =>\n\t\t\t\t\t\t\t\tmenuRef.current?.measure(\n\t\t\t\t\t\t\t\t\t(_x, _y, _width, _height, pageX, pageY) => {\n\t\t\t\t\t\t\t\t\t\tonMenuPress({ x: pageX, y: pageY })\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tenabled={!selectMode}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Icon\n\t\t\t\t\t\t\t\tsource='dots-vertical'\n\t\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\t\tcolor={selectMode ? colors.onSurfaceDisabled : colors.primary}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</RectButton>\n\t\t\t\t\t)}\n\t\t\t\t</View>\n\t\t\t</Surface>\n\t\t</RectButton>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\trectButton: {\n\t\tpaddingVertical: 4,\n\t},\n\tsurface: {\n\t\toverflow: 'hidden',\n\t\tborderRadius: LIST_ITEM_BORDER_RADIUS,\n\t\tbackgroundColor: 'transparent',\n\t},\n\titemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 8,\n\t\tpaddingVertical: 6,\n\t},\n\tindexContainer: {\n\t\twidth: 35,\n\t\tmarginRight: 8,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\tcheckboxContainer: {\n\t\tposition: 'absolute',\n\t},\n\ttitleContainer: {\n\t\tmarginLeft: 12,\n\t\tflex: 1,\n\t\tmarginRight: 4,\n\t},\n\tdetailsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginTop: 2,\n\t\tflexWrap: 'wrap',\n\t},\n\tdotSeparator: {\n\t\tmarginHorizontal: 4,\n\t},\n\tmenuButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n})\n\nconst renderToViewItem = ({\n\titem,\n\tindex,\n\textraData,\n}: ListRenderItemInfoWithExtraData<\n\tBilibiliTrack & { progress: number },\n\tExtraData\n>) => {\n\tif (!extraData) throw new Error('Extradata 不存在')\n\tconst { playTrack, handleMenuPress, selection, showItemCover } = extraData\n\n\treturn (\n\t\t<ToViewTrackListItem\n\t\t\tindex={index}\n\t\t\tonTrackPress={() => playTrack(item)}\n\t\t\tonMenuPress={(anchor) => handleMenuPress(item, anchor)}\n\t\t\tshowCoverImage={showItemCover ?? true}\n\t\t\tdata={{\n\t\t\t\tcover: item.coverUrl ?? undefined,\n\t\t\t\ttitle: item.title,\n\t\t\t\tduration: item.duration,\n\t\t\t\tid: item.id,\n\t\t\t\tartistName: item.artist?.name,\n\t\t\t\tuniqueKey: item.uniqueKey,\n\t\t\t\tprogress: item.progress,\n\t\t\t}}\n\t\t\ttoggleSelected={() => {\n\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Clock_Tick)\n\t\t\t\tselection.toggle(item.id)\n\t\t\t}}\n\t\t\tisSelected={selection.selected.has(item.id)}\n\t\t\tselectMode={selection.active}\n\t\t\tenterSelectMode={() => {\n\t\t\t\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Long_Press)\n\t\t\t\tselection.enter(item.id)\n\t\t\t}}\n\t\t/>\n\t)\n}\n\nexport default renderToViewItem\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/remote/toview/components/ProgressRing.tsx",
    "content": "import { memo } from 'react'\nimport { StyleSheet, View } from 'react-native'\nimport { useTheme } from 'react-native-paper'\nimport Svg, { Circle, Path } from 'react-native-svg'\n\nexport interface TrackNecessaryData {\n\tcover?: string\n\tartistCover?: string\n\ttitle: string\n\tduration: number\n\tid: number\n\tartistName?: string\n\tuniqueKey: string\n\tprogress: number // -1 为播放完\n}\n\nconst RING_RADIUS = 10\nconst RING_STROKE = 2.5\nconst RING_SIZE = (RING_RADIUS + RING_STROKE) * 2\nconst RING_CENTER = RING_RADIUS + RING_STROKE\nconst RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS\n\ninterface ProgressRingProps {\n\tprogressInSeconds?: number\n\tdurationInSeconds: number\n}\n\n/**\n * 播放进度小圆环\n * 95% 以上显示高亮圆环 + 对钩\n */\nconst ProgressRing = memo(function ProgressRing({\n\tprogressInSeconds,\n\tdurationInSeconds,\n}: ProgressRingProps) {\n\tconst { colors } = useTheme()\n\tconst progress = progressInSeconds ?? 0\n\tconst duration = durationInSeconds || 0\n\n\tif (duration === 0) {\n\t\treturn <View style={styles.progressRingContainer} />\n\t}\n\n\tlet progressRatio = progress / duration\n\tprogressRatio = Math.min(1, Math.max(0, progressRatio))\n\n\tconst isComplete = progress === -1 || progressRatio >= 0.95\n\tconst strokeOffset = RING_CIRCUMFERENCE * (1 - progressRatio)\n\n\tif (isComplete) {\n\t\treturn (\n\t\t\t<View style={styles.progressRingContainer}>\n\t\t\t\t<Svg\n\t\t\t\t\twidth={RING_SIZE}\n\t\t\t\t\theight={RING_SIZE}\n\t\t\t\t\tviewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}\n\t\t\t\t>\n\t\t\t\t\t<Circle\n\t\t\t\t\t\tcx={RING_CENTER}\n\t\t\t\t\t\tcy={RING_CENTER}\n\t\t\t\t\t\tr={RING_RADIUS}\n\t\t\t\t\t\tstroke={colors.primary}\n\t\t\t\t\t\tstrokeWidth={RING_STROKE}\n\t\t\t\t\t\tfill='none'\n\t\t\t\t\t/>\n\t\t\t\t</Svg>\n\t\t\t\t<Svg\n\t\t\t\t\twidth={16}\n\t\t\t\t\theight={16}\n\t\t\t\t\tviewBox='0 0 24 24'\n\t\t\t\t\tstyle={styles.progressRingIcon}\n\t\t\t\t>\n\t\t\t\t\t<Path\n\t\t\t\t\t\td='M 6 12 L 10 16 L 18 8'\n\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\tstroke={colors.primary}\n\t\t\t\t\t\tstrokeWidth={3}\n\t\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\t\tstrokeLinejoin='round'\n\t\t\t\t\t/>\n\t\t\t\t</Svg>\n\t\t\t</View>\n\t\t)\n\t}\n\n\treturn (\n\t\t<View style={styles.progressRingContainer}>\n\t\t\t<Svg\n\t\t\t\twidth={RING_SIZE}\n\t\t\t\theight={RING_SIZE}\n\t\t\t\tviewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}\n\t\t\t>\n\t\t\t\t<Circle\n\t\t\t\t\tcx={RING_CENTER}\n\t\t\t\t\tcy={RING_CENTER}\n\t\t\t\t\tr={RING_RADIUS}\n\t\t\t\t\tstroke={colors.elevation.level3}\n\t\t\t\t\tstrokeWidth={RING_STROKE}\n\t\t\t\t\tfill='none'\n\t\t\t\t/>\n\t\t\t\t<Circle\n\t\t\t\t\tcx={RING_CENTER}\n\t\t\t\t\tcy={RING_CENTER}\n\t\t\t\t\tr={RING_RADIUS}\n\t\t\t\t\tstroke={colors.primary}\n\t\t\t\t\tstrokeWidth={RING_STROKE}\n\t\t\t\t\tfill='none'\n\t\t\t\t\tstrokeDasharray={RING_CIRCUMFERENCE}\n\t\t\t\t\tstrokeDashoffset={strokeOffset}\n\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\ttransform={`rotate(-90 ${RING_CENTER} ${RING_CENTER})`}\n\t\t\t\t/>\n\t\t\t</Svg>\n\t\t</View>\n\t)\n})\n\nconst styles = StyleSheet.create({\n\tmenuButton: {\n\t\tborderRadius: 99999,\n\t\tpadding: 10,\n\t},\n\n\tprogressRingContainer: {\n\t\twidth: RING_SIZE,\n\t\theight: RING_SIZE,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t\tmarginRight: 2,\n\t\tmarginLeft: 8,\n\t},\n\n\tprogressRingIcon: {\n\t\tposition: 'absolute',\n\t},\n})\n\nexport default ProgressRing\n"
  },
  {
    "path": "apps/mobile/src/features/playlist/skeletons/PlaylistSkeleton.tsx",
    "content": "import { StyleSheet, View } from 'react-native'\nimport { Shimmer } from 'react-native-fast-shimmer'\nimport { useTheme } from 'react-native-paper'\nimport { useSafeAreaInsets } from 'react-native-safe-area-context'\n\nimport { LIST_ITEM_COVER_SIZE, SQUIRCLE_RADIUS_RATIO } from '@/theme/dimensions'\n\nexport function PlaylistPageSkeleton() {\n\tconst { colors } = useTheme()\n\tconst insets = useSafeAreaInsets()\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[\n\t\t\t\tstyles.container,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: colors.background,\n\t\t\t\t\tpaddingTop: insets.top + 64, // Margin for Appbar\n\t\t\t\t},\n\t\t\t]}\n\t\t>\n\t\t\t<View style={styles.contentContainer}>\n\t\t\t\t<PlaylistHeaderSkeleton />\n\t\t\t\t<View style={styles.trackList}>\n\t\t\t\t\t{Array.from({ length: 15 }, (_, index) => (\n\t\t\t\t\t\t<TrackListItemSkeleton key={index} />\n\t\t\t\t\t))}\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nexport function PlaylistTrackListSkeleton() {\n\tconst { colors } = useTheme()\n\tconst insets = useSafeAreaInsets()\n\n\treturn (\n\t\t<View\n\t\t\tstyle={[\n\t\t\t\tstyles.container,\n\t\t\t\t{\n\t\t\t\t\tbackgroundColor: colors.background,\n\t\t\t\t\tpaddingTop: insets.top + 64, // Margin for Appbar\n\t\t\t\t},\n\t\t\t]}\n\t\t>\n\t\t\t<View style={styles.contentContainer}>\n\t\t\t\t<View style={styles.trackList}>\n\t\t\t\t\t{Array.from({ length: 20 }, (_, index) => (\n\t\t\t\t\t\t<TrackListItemSkeleton key={index} />\n\t\t\t\t\t))}\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nexport function PlaylistHeaderSkeleton() {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View style={styles.headerContainer}>\n\t\t\t{/* Top Section: Cover + Text */}\n\t\t\t<View style={styles.headerTopSection}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.coverSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.headerTextSection}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.titleSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.subtitleSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.subtitleSkeleton,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tbackgroundColor: colors.surfaceVariant,\n\t\t\t\t\t\t\t\twidth: '40%',\n\t\t\t\t\t\t\t\tmarginTop: 4,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* Action Buttons */}\n\t\t\t<View style={styles.actionButtonsContainer}>\n\t\t\t\t{/* Play All Button (Pill) */}\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.playAllButtonSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t{/* Icon Buttons */}\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.actionIconSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.actionIconSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.actionIconSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nexport function TrackListItemSkeleton() {\n\tconst { colors } = useTheme()\n\n\treturn (\n\t\t<View style={styles.trackItemContainer}>\n\t\t\t{/* Index */}\n\t\t\t<View style={styles.trackIndexContainer}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.trackIndexSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* Cover */}\n\t\t\t<View\n\t\t\t\tstyle={[\n\t\t\t\t\tstyles.trackCoverSkeleton,\n\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t]}\n\t\t\t>\n\t\t\t\t<Shimmer />\n\t\t\t</View>\n\n\t\t\t{/* Info */}\n\t\t\t<View style={styles.trackInfoContainer}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.trackTitleSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.trackSubtitleRow}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.trackArtistSkeleton,\n\t\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t\t]}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Shimmer />\n\t\t\t\t\t</View>\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* Menu */}\n\t\t\t<View style={styles.trackMenuContainer}>\n\t\t\t\t<View\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.trackMenuSkeleton,\n\t\t\t\t\t\t{ backgroundColor: colors.surfaceVariant },\n\t\t\t\t\t]}\n\t\t\t\t>\n\t\t\t\t\t<Shimmer />\n\t\t\t\t</View>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t},\n\tcontentContainer: {\n\t\tflex: 1,\n\t},\n\theaderContainer: {\n\t\tpaddingHorizontal: 16,\n\t\tpaddingTop: 16,\n\t\tpaddingBottom: 12,\n\t},\n\theaderTopSection: {\n\t\tflexDirection: 'row',\n\t\tmarginBottom: 16,\n\t},\n\tcoverSkeleton: {\n\t\twidth: 120,\n\t\theight: 120,\n\t\tborderRadius: 120 * SQUIRCLE_RADIUS_RATIO,\n\t\toverflow: 'hidden',\n\t},\n\theaderTextSection: {\n\t\tflex: 1,\n\t\tmarginLeft: 16,\n\t\tjustifyContent: 'center',\n\t\tpaddingVertical: 8,\n\t},\n\ttitleSkeleton: {\n\t\theight: 24,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t\twidth: '90%',\n\t\tmarginBottom: 12,\n\t},\n\tsubtitleSkeleton: {\n\t\theight: 14,\n\t\twidth: '70%',\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t\tmarginBottom: 4,\n\t},\n\tactionButtonsContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tmarginHorizontal: 16,\n\t\tgap: 8, // Gap between buttons\n\t},\n\tplayAllButtonSkeleton: {\n\t\twidth: 120,\n\t\theight: 40,\n\t\tborderRadius: 20,\n\t\toverflow: 'hidden',\n\t},\n\tactionIconSkeleton: {\n\t\twidth: 40,\n\t\theight: 40,\n\t\tborderRadius: 20,\n\t\toverflow: 'hidden',\n\t},\n\ttrackList: {\n\t\tpaddingHorizontal: 0,\n\t},\n\ttrackItemContainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t\tpaddingHorizontal: 16,\n\t\tpaddingVertical: 8,\n\t},\n\ttrackIndexContainer: {\n\t\twidth: 35,\n\t\tmarginRight: 8,\n\t\talignItems: 'center',\n\t\tjustifyContent: 'center',\n\t},\n\ttrackIndexSkeleton: {\n\t\twidth: 16,\n\t\theight: 16,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t},\n\ttrackCoverSkeleton: {\n\t\twidth: LIST_ITEM_COVER_SIZE,\n\t\theight: LIST_ITEM_COVER_SIZE,\n\t\tborderRadius: LIST_ITEM_COVER_SIZE * SQUIRCLE_RADIUS_RATIO,\n\t\toverflow: 'hidden',\n\t},\n\ttrackInfoContainer: {\n\t\tflex: 1,\n\t\tmarginLeft: 12,\n\t\tmarginRight: 4,\n\t\tjustifyContent: 'center',\n\t},\n\ttrackTitleSkeleton: {\n\t\theight: 16,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t\twidth: '80%',\n\t\tmarginBottom: 6,\n\t},\n\ttrackSubtitleRow: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'center',\n\t},\n\ttrackArtistSkeleton: {\n\t\twidth: '50%',\n\t\theight: 12,\n\t\tborderRadius: 4,\n\t\toverflow: 'hidden',\n\t},\n\ttrackMenuContainer: {\n\t\tpadding: 10,\n\t},\n\ttrackMenuSkeleton: {\n\t\twidth: 24,\n\t\theight: 24,\n\t\tborderRadius: 12,\n\t\toverflow: 'hidden',\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/hooks/analytics/useFeatureTracking.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useEffect } from 'react'\n\nimport { useAppStore } from '@/hooks/stores/useAppStore'\nimport { analyticsService } from '@/lib/services/analyticsService'\n\n/**\n * Syncs feature flags and user settings to Analytics User Properties.\n * This allows segmenting users based on their configuration.\n */\nexport function useFeatureTracking() {\n\tconst settings = useAppStore((state) => state.settings)\n\tconst { enableDataCollection } = settings\n\n\tuseEffect(() => {\n\t\tvoid analyticsService.setAnalyticsCollectionEnabled(enableDataCollection)\n\n\t\tif (!enableDataCollection) return\n\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_lyric_source',\n\t\t\tsettings.lyricSource,\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_player_bg_style',\n\t\t\tsettings.playerBackgroundStyle,\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_now_playing_bar_style',\n\t\t\tsettings.nowPlayingBarStyle,\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_danmaku_enable',\n\t\t\tString(settings.enableDanmaku),\n\t\t)\n\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_desktop_lyric',\n\t\t\tString(Orpheus.isDesktopLyricsShown),\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_loudness_norm',\n\t\t\tString(Orpheus.loudnessNormalizationEnabled),\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_autoplay',\n\t\t\tString(Orpheus.autoplayOnStartEnabled),\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_send_history',\n\t\t\tString(settings.sendPlayHistory),\n\t\t)\n\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_visualizer',\n\t\t\tString(settings.enableSpectrumVisualizer),\n\t\t)\n\t\tvoid analyticsService.setUserProperty(\n\t\t\t'setting_persist_pos',\n\t\t\tString(Orpheus.restorePlaybackPositionEnabled),\n\t\t)\n\t}, [settings, enableDataCollection])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/app/useCheckUpdate.tsx",
    "content": "import { useEffect } from 'react'\n\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { checkForAppUpdate } from '@/lib/services/updateService'\nimport { storage } from '@/utils/mmkv'\n\nexport default function useCheckUpdate() {\n\tconst open = useModalStore((state) => state.open)\n\tuseEffect(() => {\n\t\tif (__DEV__) {\n\t\t\treturn\n\t\t}\n\t\tlet isMounted = true\n\t\tconst run = async () => {\n\t\t\tconst skipped = storage.getString('skip_version') ?? ''\n\t\t\tconst result = await checkForAppUpdate()\n\t\t\tif (!isMounted) return\n\t\t\tif (result.isErr()) return\n\t\t\tconst { update } = result.value\n\t\t\tif (!update) return\n\t\t\tif (skipped && skipped === update.version) return\n\t\t\tif (update.forced) {\n\t\t\t\topen('UpdateApp', update, { dismissible: false })\n\t\t\t} else {\n\t\t\t\topen('UpdateApp', update, { dismissible: true })\n\t\t\t}\n\t\t}\n\t\tvoid run()\n\t\treturn () => {\n\t\t\tisMounted = false\n\t\t}\n\t}, [open])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/app/useFastMigrations.ts",
    "content": "import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite/driver'\nimport { migrate } from 'drizzle-orm/expo-sqlite/migrator'\nimport { generateKeyBetween } from 'fractional-indexing'\nimport { useEffect, useReducer } from 'react'\n\nimport { expoDb } from '@/lib/db/db'\nimport log from '@/utils/log'\nimport { storage } from '@/utils/mmkv'\n\nconst logger = log.extend('useFastMigrations')\nconst SCHEMA_VERSION_KEY = 'db_schema_version'\n\nconst SORT_KEY_MIGRATED_V2_KEY = 'sort_key_migrated_v2' // gitleaks:allow\nconst SORT_KEY_MIGRATED_V3_KEY = 'sort_key_migrated_v3' // gitleaks:allow\nconst PLAY_HISTORY_MIGRATED_V1_KEY = 'play_history_migrated_v1' // gitleaks:allow\n\ninterface MigrationConfig {\n\tjournal: {\n\t\tentries: { idx: number; when: number; tag: string; breakpoints: boolean }[]\n\t}\n\tmigrations: Record<string, string>\n}\n\ninterface State {\n\tsuccess: boolean\n\terror?: Error\n}\n\ntype Action =\n\t| { type: 'migrating' }\n\t| { type: 'migrated'; payload: true }\n\t| { type: 'error'; payload: Error }\n\nfunction migrateSortKeysV2(): void {\n\tif (storage.getBoolean(SORT_KEY_MIGRATED_V2_KEY)) return\n\n\ttry {\n\t\tconst tableInfo = expoDb.getAllSync<{ name: string }>(\n\t\t\t`PRAGMA table_info(playlist_tracks)`,\n\t\t)\n\t\tconst hasOrderColumn = tableInfo.some((col) => col.name === 'order')\n\n\t\tif (!hasOrderColumn) {\n\t\t\tlogger.info('[v2] 物理表中已无 order 字段，无需执行数据迁移与删除操作')\n\t\t\tstorage.set(SORT_KEY_MIGRATED_V2_KEY, true)\n\t\t\treturn\n\t\t}\n\n\t\texpoDb.withTransactionSync(() => {\n\t\t\t// 1. 读取需要迁移的数据\n\t\t\ttype Row = { playlist_id: number; track_id: number }\n\t\t\tconst rows = expoDb.getAllSync<Row>(\n\t\t\t\t`SELECT playlist_id, track_id\n                 FROM playlist_tracks\n                 WHERE sort_key = '' OR sort_key IS NULL\n                 ORDER BY playlist_id ASC, \"order\" ASC, rowid ASC`,\n\t\t\t)\n\n\t\t\tif (rows.length > 0) {\n\t\t\t\t// 2. 读取当前各个歌单的最大 sort_key 作为接力起点\n\t\t\t\ttype MaxKeyRow = { playlist_id: number; max_key: string }\n\t\t\t\tconst maxKeys = expoDb.getAllSync<MaxKeyRow>(\n\t\t\t\t\t`SELECT playlist_id, MAX(sort_key) as max_key\n                     FROM playlist_tracks\n                     WHERE sort_key != '' AND sort_key IS NOT NULL\n                     GROUP BY playlist_id`,\n\t\t\t\t)\n\n\t\t\t\tconst maxKeyMap = new Map<number, string>()\n\t\t\t\tfor (const row of maxKeys) {\n\t\t\t\t\tmaxKeyMap.set(row.playlist_id, row.max_key)\n\t\t\t\t}\n\n\t\t\t\t// 按 playlist 分组\n\t\t\t\tconst grouped = new Map<number, number[]>()\n\t\t\t\tfor (const row of rows) {\n\t\t\t\t\tconst arr = grouped.get(row.playlist_id) ?? []\n\t\t\t\t\tarr.push(row.track_id)\n\t\t\t\t\tgrouped.set(row.playlist_id, arr)\n\t\t\t\t}\n\n\t\t\t\t// 3. 执行更新操作\n\t\t\t\tfor (const [playlistId, trackIds] of grouped) {\n\t\t\t\t\tlet prevKey: string | null = maxKeyMap.get(playlistId) || null\n\n\t\t\t\t\tfor (const trackId of trackIds) {\n\t\t\t\t\t\tconst sortKey = generateKeyBetween(prevKey, null)\n\t\t\t\t\t\tprevKey = sortKey\n\t\t\t\t\t\texpoDb.runSync(\n\t\t\t\t\t\t\t`UPDATE playlist_tracks SET sort_key = ? WHERE playlist_id = ? AND track_id = ?`,\n\t\t\t\t\t\t\t[sortKey, playlistId, trackId],\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlogger.info(`[v2] sort_key 数据迁移接力完成，共处理 ${rows.length} 行`)\n\t\t\t}\n\n\t\t\texpoDb.runSync(`ALTER TABLE playlist_tracks DROP COLUMN \"order\"`)\n\t\t\tlogger.info('[v2] 已成功从物理表中删除 order 字段')\n\t\t})\n\n\t\tstorage.set(SORT_KEY_MIGRATED_V2_KEY, true)\n\t} catch (error) {\n\t\tlogger.error('[v2] 迁移过程中发生错误，事务已回滚:', error)\n\t}\n}\n\n/**\n * V3 迁移：将非 local 播放列表的 sort_key 翻转。\n */\nfunction migrateSortKeysV3(): void {\n\tif (storage.getBoolean(SORT_KEY_MIGRATED_V3_KEY)) return\n\n\ttry {\n\t\texpoDb.withTransactionSync(() => {\n\t\t\ttype PlaylistRow = { id: number }\n\t\t\tconst playlists = expoDb.getAllSync<PlaylistRow>(\n\t\t\t\t`SELECT id FROM playlists WHERE type != 'local'`,\n\t\t\t)\n\n\t\t\tlet totalUpdated = 0\n\n\t\t\tfor (const playlist of playlists) {\n\t\t\t\ttype TrackRow = { track_id: number }\n\t\t\t\tconst tracks = expoDb.getAllSync<TrackRow>(\n\t\t\t\t\t`SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY sort_key ASC`,\n\t\t\t\t\t[playlist.id],\n\t\t\t\t)\n\n\t\t\t\tif (tracks.length === 0) continue\n\n\t\t\t\t// 倒序分配新 sort_key：原来 position 1 的 track 获得最大的 sort_key\n\t\t\t\t// 改为 DESC 查询后显示顺序维持不变\n\t\t\t\tconst reversed = [...tracks].toReversed()\n\t\t\t\tlet prevKey: string | null = null\n\t\t\t\tconst newKeys = new Map<number, string>()\n\t\t\t\tfor (const track of reversed) {\n\t\t\t\t\tconst sortKey = generateKeyBetween(prevKey, null)\n\t\t\t\t\tprevKey = sortKey\n\t\t\t\t\tnewKeys.set(track.track_id, sortKey)\n\t\t\t\t}\n\n\t\t\t\tfor (const [trackId, sortKey] of newKeys) {\n\t\t\t\t\texpoDb.runSync(\n\t\t\t\t\t\t`UPDATE playlist_tracks SET sort_key = ? WHERE playlist_id = ? AND track_id = ?`,\n\t\t\t\t\t\t[sortKey, playlist.id, trackId],\n\t\t\t\t\t)\n\t\t\t\t\ttotalUpdated++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogger.info(\n\t\t\t\t`[v3] 非 local 播放列表 sort_key 翻转迁移完成，共处理 ${totalUpdated} 行`,\n\t\t\t)\n\t\t})\n\t\tstorage.set(SORT_KEY_MIGRATED_V3_KEY, true)\n\t} catch (error) {\n\t\tlogger.error('[v3] 迁移过程中发生错误，事务已回滚:', error)\n\t}\n}\n\n/**\n * 迁移播放历史数据：从 tracks 表的 JSON 迁移到 play_history 表。\n */\nfunction migratePlayHistory(): void {\n\tif (storage.getBoolean(PLAY_HISTORY_MIGRATED_V1_KEY)) return\n\n\ttry {\n\t\t// 1. 检查 tracks 表是否还有 play_history 列\n\t\tconst tracksTableInfo = expoDb.getAllSync<{ name: string }>(\n\t\t\t`PRAGMA table_info(tracks)`,\n\t\t)\n\t\tconst hasOldColumn = tracksTableInfo.some(\n\t\t\t(col) => col.name === 'play_history',\n\t\t)\n\n\t\tif (!hasOldColumn) {\n\t\t\tlogger.info(\n\t\t\t\t'[play_history] tracks 表中无 play_history 字段，无需执行数据迁移',\n\t\t\t)\n\t\t\tstorage.set(PLAY_HISTORY_MIGRATED_V1_KEY, true)\n\t\t\treturn\n\t\t}\n\n\t\t// 2. 检查 play_history 表是否已经创建\n\t\tconst masterInfo = expoDb.getAllSync<{ name: string }>(\n\t\t\t`SELECT name FROM sqlite_master WHERE type='table' AND name='play_history'`,\n\t\t)\n\t\tif (masterInfo.length === 0) {\n\t\t\tlogger.warning('[play_history] play_history 表尚未创建，跳过本次数据迁移')\n\t\t\treturn\n\t\t}\n\n\t\texpoDb.withTransactionSync(() => {\n\t\t\ttype Row = { id: number; play_history: string }\n\t\t\tconst rows = expoDb.getAllSync<Row>(\n\t\t\t\t`SELECT id, play_history FROM tracks WHERE play_history IS NOT NULL AND play_history != '[]'`,\n\t\t\t)\n\n\t\t\tif (rows.length > 0) {\n\t\t\t\tlogger.info(\n\t\t\t\t\t`[play_history] 发现 ${rows.length} 个带有播放记录的歌曲，准备迁移...`,\n\t\t\t\t)\n\t\t\t\tfor (const row of rows) {\n\t\t\t\t\tconst history = JSON.parse(row.play_history)\n\t\t\t\t\tif (Array.isArray(history)) {\n\t\t\t\t\t\tfor (const record of history) {\n\t\t\t\t\t\t\texpoDb.runSync(\n\t\t\t\t\t\t\t\t`INSERT INTO play_history (track_id, start_time, duration_played, completed, created_at) \n\t\t\t\t\t\t\t\t VALUES (?, ?, ?, ?, (unixepoch() * 1000))`,\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\trow.id,\n\t\t\t\t\t\t\t\t\trecord.startTime,\n\t\t\t\t\t\t\t\t\trecord.durationPlayed,\n\t\t\t\t\t\t\t\t\trecord.completed ? 1 : 0,\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlogger.info(\n\t\t\t\t\t`[play_history] 播放记录迁移完成，共处理 ${rows.length} 条歌曲记录`,\n\t\t\t\t)\n\t\t\t}\n\t\t})\n\n\t\tstorage.set(PLAY_HISTORY_MIGRATED_V1_KEY, true)\n\t} catch (error) {\n\t\t// 这里不吃掉错误，而是让它打印出来，并且不设置 storage 标记，下次启动还会重试\n\t\tlogger.error('[play_history] 迁移过程中发生致命错误:', error)\n\t}\n}\n\nexport const useFastMigrations = (\n\tdb: ExpoSQLiteDatabase<Record<string, unknown>>,\n\tmigrations: MigrationConfig,\n): State => {\n\tconst initialState: State = {\n\t\tsuccess: false,\n\t\terror: undefined,\n\t}\n\n\tconst fetchReducer = (state: State, action: Action): State => {\n\t\tswitch (action.type) {\n\t\t\tcase 'migrating': {\n\t\t\t\treturn { ...initialState }\n\t\t\t}\n\t\t\tcase 'migrated': {\n\t\t\t\treturn { ...initialState, success: action.payload }\n\t\t\t}\n\t\t\tcase 'error': {\n\t\t\t\treturn { ...initialState, error: action.payload }\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\treturn state\n\t\t\t}\n\t\t}\n\t}\n\n\tconst [state, dispatch] = useReducer(fetchReducer, initialState)\n\n\tuseEffect(() => {\n\t\tconst runMigration = async () => {\n\t\t\tconst cachedVersion = storage.getNumber(SCHEMA_VERSION_KEY)\n\t\t\tconst latestVersion = migrations.journal.entries.at(-1)?.when ?? 0\n\n\t\t\tif (cachedVersion === latestVersion) {\n\t\t\t\t// SQL 迁移已是最新，检查/执行 JS 层迁移\n\t\t\t\tmigrateSortKeysV2()\n\t\t\t\tmigrateSortKeysV3()\n\t\t\t\tmigratePlayHistory()\n\t\t\t\tdispatch({ type: 'migrated', payload: true })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdispatch({ type: 'migrating' })\n\n\t\t\ttry {\n\t\t\t\tawait migrate(db, migrations)\n\t\t\t\t// SQL 迁移完成后立刻检查/执行 JS 层迁移\n\t\t\t\tmigrateSortKeysV2()\n\t\t\t\tmigrateSortKeysV3()\n\t\t\t\tmigratePlayHistory()\n\n\t\t\t\tstorage.set(SCHEMA_VERSION_KEY, latestVersion)\n\t\t\t\tdispatch({ type: 'migrated', payload: true })\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('迁移失败:', error)\n\t\t\t\tdispatch({ type: 'error', payload: error as Error })\n\t\t\t}\n\t\t}\n\n\t\tvoid runMigration()\n\t}, [db, migrations])\n\n\treturn state\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/auth/useGeetest.ts",
    "content": "import { useCallback } from 'react'\nimport type { WebViewMessageEvent } from 'react-native-webview'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\ninterface CaptchaParams {\n\ttoken: string\n\tgt: string\n\tchallenge: string\n\ttel: string\n\tcid: string\n}\n\ninterface UseGeetestProps {\n\tcaptchaParams: CaptchaParams | null\n\tonSuccess: (captchaKey: string) => void\n\tonFail: (errorMsg: string) => void\n\tonStartRequest: () => void\n}\n\nexport function useGeetest({\n\tcaptchaParams,\n\tonSuccess,\n\tonFail,\n\tonStartRequest,\n}: UseGeetestProps) {\n\tconst handleGeetestMessage = useCallback(\n\t\tasync (event: WebViewMessageEvent) => {\n\t\t\tif (!captchaParams) return\n\n\t\t\tlet parsed: { validate?: string; seccode?: string; challenge?: string }\n\t\t\ttry {\n\t\t\t\tparsed = JSON.parse(event.nativeEvent.data) as typeof parsed\n\t\t\t} catch {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst { validate, seccode, challenge } = parsed\n\t\t\tif (!validate || !seccode || !challenge) return\n\n\t\t\tonStartRequest()\n\t\t\ttry {\n\t\t\t\tconst smsResult = await bilibiliApi.sendPhoneLoginSms(\n\t\t\t\t\tcaptchaParams.tel,\n\t\t\t\t\tcaptchaParams.cid,\n\t\t\t\t\tcaptchaParams.token,\n\t\t\t\t\tchallenge,\n\t\t\t\t\tvalidate,\n\t\t\t\t\tseccode,\n\t\t\t\t)\n\t\t\t\tif (smsResult.isErr()) {\n\t\t\t\t\tconst errCode = smsResult.error.data.msgCode\n\t\t\t\t\tlet errorMsg = smsResult.error.message || '发送验证码失败，请稍后重试'\n\t\t\t\t\tif (errCode === 86211 || errCode === -105) {\n\t\t\t\t\t\terrorMsg = '图形验证已过期，请重新获取验证码'\n\t\t\t\t\t}\n\t\t\t\t\tonFail(errorMsg)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tonSuccess(smsResult.value.captcha_key)\n\t\t\t\ttoast.success('验证码已发送', { id: 'phone-login-sms-sent' })\n\t\t\t} catch (error) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\t'发送验证码失败',\n\t\t\t\t\terror,\n\t\t\t\t\t'useGeetest.handleGeetestMessage',\n\t\t\t\t)\n\t\t\t\tonFail('发送验证码失败')\n\t\t\t}\n\t\t},\n\t\t[captchaParams, onFail, onStartRequest, onSuccess],\n\t)\n\n\treturn {\n\t\thandleGeetestMessage,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/auth/usePhoneLogin.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useCallback, useReducer } from 'react'\nimport * as setCookieParser from 'set-cookie-parser'\n\nimport { favoriteListQueryKeys } from '@/hooks/queries/bilibili/favorite'\nimport { userQueryKeys } from '@/hooks/queries/bilibili/user'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nimport { useGeetest } from './useGeetest'\n\nexport type Step = 'input_phone' | 'geetest_verify' | 'input_code' | 'success'\n\ninterface CaptchaParams {\n\ttoken: string\n\tgt: string\n\tchallenge: string\n\ttel: string\n\tcid: string\n}\n\nconst COUNTRY_CODE = '86'\n\nexport const phoneFormModel = {\n\ttel: {\n\t\tvalidate(v: string): string {\n\t\t\tconst trimmed = v.trim()\n\t\t\tif (!trimmed) return '请输入手机号'\n\t\t\tif (!/^\\d{5,15}$/.test(trimmed)) return '手机号格式不正确'\n\t\t\treturn ''\n\t\t},\n\t},\n\tsmsCode: {\n\t\tvalidate(v: string): string {\n\t\t\tconst trimmed = v.trim()\n\t\t\tif (!trimmed) return '请输入验证码'\n\t\t\tif (!/^\\d{4,8}$/.test(trimmed)) return '验证码格式不正确'\n\t\t\treturn ''\n\t\t},\n\t},\n}\n\ntype LoginStatus = 'idle' | 'loading' | 'success'\n\ninterface LoginState {\n\tstep: Step\n\tstatus: LoginStatus\n\ttel: string\n\tsmsCode: string\n\tcaptchaKey: string\n\tcaptchaParams: CaptchaParams | null\n\tphoneError: string\n\tcodeError: string\n}\n\ntype LoginAction =\n\t| { type: 'SET_TEL'; payload: string }\n\t| { type: 'SET_SMS_CODE'; payload: string }\n\t| { type: 'START_REQUEST' }\n\t| { type: 'SET_CAPTCHA_PARAMS'; payload: CaptchaParams }\n\t| { type: 'REQUEST_FAIL'; payload?: string }\n\t| { type: 'SET_SMS_SENT'; payload: string }\n\t| { type: 'LOGIN_SUCCESS' }\n\t| { type: 'LOGIN_FAIL'; payload: string }\n\t| { type: 'RESET_STEP' }\n\t| { type: 'SET_PHONE_ERROR'; payload: string }\n\t| { type: 'SET_CODE_ERROR'; payload: string }\n\nconst initialState: LoginState = {\n\tstep: 'input_phone',\n\tstatus: 'idle',\n\ttel: '',\n\tsmsCode: '',\n\tcaptchaKey: '',\n\tcaptchaParams: null,\n\tphoneError: '',\n\tcodeError: '',\n}\n\nfunction loginReducer(state: LoginState, action: LoginAction): LoginState {\n\tswitch (action.type) {\n\t\tcase 'SET_TEL':\n\t\t\treturn { ...state, tel: action.payload, phoneError: '' }\n\t\tcase 'SET_SMS_CODE':\n\t\t\treturn { ...state, smsCode: action.payload, codeError: '' }\n\t\tcase 'START_REQUEST':\n\t\t\treturn { ...state, status: 'loading', phoneError: '', codeError: '' }\n\t\tcase 'SET_CAPTCHA_PARAMS':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tstatus: 'idle',\n\t\t\t\tstep: 'geetest_verify',\n\t\t\t\tcaptchaParams: action.payload,\n\t\t\t}\n\t\tcase 'REQUEST_FAIL':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tstatus: 'idle',\n\t\t\t\tstep: 'input_phone',\n\t\t\t\tphoneError: action.payload || state.phoneError,\n\t\t\t}\n\t\tcase 'SET_SMS_SENT':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tstatus: 'idle',\n\t\t\t\tstep: 'input_code',\n\t\t\t\tcaptchaKey: action.payload,\n\t\t\t}\n\t\tcase 'LOGIN_SUCCESS':\n\t\t\treturn { ...state, status: 'success', step: 'success' }\n\t\tcase 'LOGIN_FAIL':\n\t\t\treturn { ...state, status: 'idle', codeError: action.payload }\n\t\tcase 'RESET_STEP':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tstep: 'input_phone',\n\t\t\t\tstatus: 'idle',\n\t\t\t\tsmsCode: '',\n\t\t\t\tcodeError: '',\n\t\t\t\tcaptchaKey: '',\n\t\t\t\tcaptchaParams: null,\n\t\t\t}\n\t\tcase 'SET_PHONE_ERROR':\n\t\t\treturn { ...state, phoneError: action.payload }\n\t\tcase 'SET_CODE_ERROR':\n\t\t\treturn { ...state, codeError: action.payload }\n\t\tdefault:\n\t\t\treturn state\n\t}\n}\n\nexport function usePhoneLogin() {\n\tconst queryClient = useQueryClient()\n\tconst setCookie = useAppStore((state) => state.updateBilibiliCookie)\n\tconst _close = useModalStore((state) => state.close)\n\tconst close = useCallback(() => _close('PhoneLogin'), [_close])\n\n\tconst [state, dispatch] = useReducer(loginReducer, initialState)\n\n\tconst { handleGeetestMessage } = useGeetest({\n\t\tcaptchaParams: state.captchaParams,\n\t\tonStartRequest: () => dispatch({ type: 'START_REQUEST' }),\n\t\tonSuccess: (captchaKey) =>\n\t\t\tdispatch({ type: 'SET_SMS_SENT', payload: captchaKey }),\n\t\tonFail: (errorMsg) => dispatch({ type: 'REQUEST_FAIL', payload: errorMsg }),\n\t})\n\n\tconst handleRequestCode = async () => {\n\t\tconst telError = phoneFormModel.tel.validate(state.tel)\n\t\tif (telError) {\n\t\t\tdispatch({ type: 'SET_PHONE_ERROR', payload: telError })\n\t\t\treturn\n\t\t}\n\n\t\tdispatch({ type: 'START_REQUEST' })\n\t\ttry {\n\t\t\tconst captchaResult = await bilibiliApi.getPhoneLoginCaptchaToken()\n\t\t\tif (captchaResult.isErr()) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\t'获取验证码失败',\n\t\t\t\t\tcaptchaResult.error,\n\t\t\t\t\t'usePhoneLogin.getPhoneLoginCaptchaToken',\n\t\t\t\t)\n\t\t\t\tdispatch({ type: 'REQUEST_FAIL' })\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst captcha = captchaResult.value\n\t\t\tdispatch({\n\t\t\t\ttype: 'SET_CAPTCHA_PARAMS',\n\t\t\t\tpayload: {\n\t\t\t\t\ttoken: captcha.token,\n\t\t\t\t\tgt: captcha.geetest.gt,\n\t\t\t\t\tchallenge: captcha.geetest.challenge,\n\t\t\t\t\ttel: state.tel.trim(),\n\t\t\t\t\tcid: COUNTRY_CODE,\n\t\t\t\t},\n\t\t\t})\n\t\t} catch (error) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'获取验证码失败',\n\t\t\t\terror,\n\t\t\t\t'usePhoneLogin.handleRequestCode',\n\t\t\t)\n\t\t\tdispatch({ type: 'REQUEST_FAIL' })\n\t\t}\n\t}\n\n\tconst handleLogin = async () => {\n\t\tconst codeErr = phoneFormModel.smsCode.validate(state.smsCode)\n\t\tif (codeErr) {\n\t\t\tdispatch({ type: 'SET_CODE_ERROR', payload: codeErr })\n\t\t\treturn\n\t\t}\n\n\t\tdispatch({ type: 'START_REQUEST' })\n\t\ttry {\n\t\t\tconst loginResult = await bilibiliApi.loginWithPhoneSmsCode(\n\t\t\t\tstate.tel.trim(),\n\t\t\t\tCOUNTRY_CODE,\n\t\t\t\tstate.smsCode.trim(),\n\t\t\t\tstate.captchaKey,\n\t\t\t)\n\t\t\tif (loginResult.isErr()) {\n\t\t\t\tdispatch({\n\t\t\t\t\ttype: 'LOGIN_FAIL',\n\t\t\t\t\tpayload: loginResult.error.message || '登录失败，请检查验证码',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst splitCookies = setCookieParser.splitCookiesString(loginResult.value)\n\t\t\tconst parsedCookie = setCookieParser.parse(splitCookies)\n\t\t\tconst finalCookieObject = Object.fromEntries(\n\t\t\t\tparsedCookie.map((c) => [c.name, c.value]),\n\t\t\t)\n\t\t\tconst result = setCookie(finalCookieObject)\n\t\t\tif (result.isErr()) {\n\t\t\t\ttoast.error('保存 Cookie 失败：' + result.error.message)\n\t\t\t\tSentry.captureException(result.error, {\n\t\t\t\t\ttags: { Hook: 'usePhoneLogin' },\n\t\t\t\t})\n\t\t\t\tdispatch({ type: 'LOGIN_FAIL', payload: '保存 Cookie 失败' })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdispatch({ type: 'LOGIN_SUCCESS' })\n\t\t\ttoast.success('登录成功', { id: 'phone-login-success' })\n\t\t\tawait queryClient.cancelQueries()\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: favoriteListQueryKeys.all,\n\t\t\t})\n\t\t\tawait queryClient.invalidateQueries({ queryKey: userQueryKeys.all })\n\t\t\tsetTimeout(() => close(), 1000)\n\t\t} catch (error) {\n\t\t\ttoastAndLogError('登录失败', error, 'usePhoneLogin.handleLogin')\n\t\t\tdispatch({ type: 'LOGIN_FAIL', payload: '登录失败' })\n\t\t}\n\t}\n\n\treturn {\n\t\t...state,\n\t\tsetTel: (payload: string) => dispatch({ type: 'SET_TEL', payload }),\n\t\tsetSmsCode: (payload: string) =>\n\t\t\tdispatch({ type: 'SET_SMS_CODE', payload }),\n\t\tisSendingCode: state.step === 'input_phone' && state.status === 'loading',\n\t\tisLoggingIn: state.step === 'input_code' && state.status === 'loading',\n\t\tclose,\n\t\thandleRequestCode,\n\t\thandleGeetestMessage,\n\t\thandleLogin,\n\t\tcancelGeetest: () => dispatch({ type: 'RESET_STEP' }),\n\t\tprevStep: () => dispatch({ type: 'RESET_STEP' }),\n\t\tsetPhoneError: (payload: string) =>\n\t\t\tdispatch({ type: 'SET_PHONE_ERROR', payload }),\n\t\tsetCodeError: (payload: string) =>\n\t\t\tdispatch({ type: 'SET_CODE_ERROR', payload }),\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/bilibili/comments.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const useLikeComment = () => {\n\treturn useMutation({\n\t\tmutationFn: async (params: {\n\t\t\tbvid: string\n\t\t\trpid: number\n\t\t\tnewAction: 0 | 1\n\t\t}) => {\n\t\t\tconst { bvid, rpid, newAction } = params\n\t\t\treturn await returnOrThrowAsync(\n\t\t\t\tbilibiliApi.likeComment(bvid, rpid, newAction),\n\t\t\t)\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/bilibili/favorite.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\n\nimport { favoriteListQueryKeys } from '@/hooks/queries/bilibili/favorite'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport log from '@/utils/log'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\nimport toast from '@/utils/toast'\n\nconst logger = log.extend('Mutation.Bilibili.Favorite')\n\n/**\n * 单个视频添加/删除到多个收藏夹\n */\nexport const useDealFavoriteForOneVideo = () => {\n\tconst queryClient = useQueryClient()\n\n\treturn useMutation({\n\t\tmutationFn: async (params: {\n\t\t\tbvid: string\n\t\t\taddToFavoriteIds: string[]\n\t\t\tdelInFavoriteIds: string[]\n\t\t}) =>\n\t\t\tawait returnOrThrowAsync(\n\t\t\t\tbilibiliApi.dealFavoriteForOneVideo(\n\t\t\t\t\tparams.bvid,\n\t\t\t\t\tparams.addToFavoriteIds,\n\t\t\t\t\tparams.delInFavoriteIds,\n\t\t\t\t),\n\t\t\t),\n\t\tonSuccess: async (_data, _value) => {\n\t\t\ttoast.success('操作成功', {\n\t\t\t\tdescription:\n\t\t\t\t\t_data.toast_msg.length > 0\n\t\t\t\t\t\t? `api 返回消息：${_data.toast_msg}`\n\t\t\t\t\t\t: undefined,\n\t\t\t})\n\t\t\t// 只刷新当前显示的收藏夹\n\t\t\tawait queryClient.refetchQueries({\n\t\t\t\tqueryKey: ['bilibili', 'favoriteList', 'infiniteFavoriteList'],\n\t\t\t\ttype: 'active',\n\t\t\t})\n\t\t},\n\t\tonError: (error) => {\n\t\t\tlet errorMessage = '删除失败，请稍后重试'\n\t\t\tif (error instanceof BilibiliApiError) {\n\t\t\t\tif (error.type === 'CsrfError') {\n\t\t\t\t\terrorMessage = '删除失败：csrf token 过期，请检查 cookie 后重试'\n\t\t\t\t} else {\n\t\t\t\t\terrorMessage = `删除失败：${error.message} (${error.data.msgCode})`\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttoast.error('操作失败', {\n\t\t\t\tdescription: errorMessage,\n\t\t\t\tduration: Number.POSITIVE_INFINITY,\n\t\t\t})\n\t\t\tlogger.error('删除收藏夹内容失败:', error)\n\t\t},\n\t})\n}\n\n/**\n * 删除收藏夹内容\n */\nexport const useBatchDeleteFavoriteListContents = () => {\n\tconst queryClient = useQueryClient()\n\n\treturn useMutation({\n\t\tmutationFn: (params: { bvids: string[]; favoriteId: number }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.batchDeleteFavoriteListContents(\n\t\t\t\t\tparams.favoriteId,\n\t\t\t\t\tparams.bvids,\n\t\t\t\t),\n\t\t\t),\n\t\tonSuccess: async (_data, variables) => {\n\t\t\ttoast.success('删除成功')\n\t\t\tawait queryClient.refetchQueries({\n\t\t\t\tqueryKey: favoriteListQueryKeys.infiniteFavoriteList(\n\t\t\t\t\tvariables.favoriteId,\n\t\t\t\t),\n\t\t\t})\n\t\t},\n\t\tonError: (error) => {\n\t\t\tlet errorMessage = '删除失败，请稍后重试'\n\t\t\tif (error instanceof BilibiliApiError) {\n\t\t\t\tif (error.type === 'CsrfError') {\n\t\t\t\t\terrorMessage = '删除失败：csrf token 过期，请检查 cookie 后重试'\n\t\t\t\t} else {\n\t\t\t\t\terrorMessage = `删除失败：${error.message} (${error.data.msgCode})`\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttoastAndLogError(errorMessage, error, 'Query.Bilibili.Favorite')\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/bilibili/video.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\n\nimport { videoDataQueryKeys } from '@/hooks/queries/bilibili/video'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { queryClient } from '@/lib/config/queryClient'\nimport type { BilibiliToViewVideoList } from '@/types/apis/bilibili'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\nimport toast from '@/utils/toast'\n\nexport const useThumbUpVideo = () => {\n\treturn useMutation({\n\t\tmutationFn: ({ bvid, like }: { bvid: string; like: boolean }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.thumbUpVideo(bvid, like).map((res) => res ?? undefined),\n\t\t\t),\n\t\tonSuccess: (_, { bvid, like }) => {\n\t\t\tqueryClient.setQueryData(\n\t\t\t\tvideoDataQueryKeys.getVideoIsThumbUp(bvid),\n\t\t\t\tlike ? 1 : 0,\n\t\t\t)\n\t\t\ttoast.success(`${like ? '点赞' : '取消点赞'}成功`)\n\t\t},\n\t\tonError: (err, { like }) => {\n\t\t\ttoastAndLogError(`${like ? '点赞' : '取消点赞'}失败`, err, 'UI.Player')\n\t\t},\n\t})\n}\n\nexport const useDeleteToViewVideo = () => {\n\treturn useMutation({\n\t\tmutationFn: ({\n\t\t\tdeleteAllViewed,\n\t\t\tavid,\n\t\t}: {\n\t\t\tdeleteAllViewed?: boolean\n\t\t\tavid?: number\n\t\t}) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.deleteToViewVideo(deleteAllViewed, avid).map(() => true),\n\t\t\t),\n\t\tonMutate: async ({ deleteAllViewed, avid }, context) => {\n\t\t\tawait context.client.cancelQueries({\n\t\t\t\tqueryKey: videoDataQueryKeys.getToViewVideoList(),\n\t\t\t})\n\t\t\tconst previousData = context.client.getQueryData(\n\t\t\t\tvideoDataQueryKeys.getToViewVideoList(),\n\t\t\t)\n\t\t\tcontext.client.setQueryData(\n\t\t\t\tvideoDataQueryKeys.getToViewVideoList(),\n\t\t\t\t(oldData: BilibiliToViewVideoList) => {\n\t\t\t\t\tif (!oldData) return undefined\n\t\t\t\t\tif (deleteAllViewed) {\n\t\t\t\t\t\tconst newItems = oldData.list.filter((item) => item.progress !== -1)\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcount: newItems.length,\n\t\t\t\t\t\t\tlist: newItems,\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst newItems = oldData.list.filter((item) => item.aid !== avid)\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcount: newItems.length,\n\t\t\t\t\t\t\tlist: newItems,\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t)\n\t\t\treturn { previousData }\n\t\t},\n\t\tonSettled: async (\n\t\t\t_d,\n\t\t\terror,\n\t\t\t{ deleteAllViewed },\n\t\t\tonMutateResult,\n\t\t\tcontext,\n\t\t) => {\n\t\t\tif (error) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\tdeleteAllViewed ? '清除稍后再看列表失败' : '删除失败',\n\t\t\t\t\terror,\n\t\t\t\t\t'Mutation.Bilibili.Video',\n\t\t\t\t)\n\t\t\t\tcontext.client.setQueryData(\n\t\t\t\t\tvideoDataQueryKeys.getToViewVideoList(),\n\t\t\t\t\tonMutateResult?.previousData,\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\ttoast.success(deleteAllViewed ? '清除稍后再看列表成功' : '删除成功')\n\t\t\t}\n\t\t\tawait context.client.invalidateQueries({\n\t\t\t\tqueryKey: videoDataQueryKeys.getToViewVideoList(),\n\t\t\t})\n\t\t},\n\t})\n}\n\nexport const useClearToViewVideoList = () => {\n\treturn useMutation({\n\t\tmutationFn: () =>\n\t\t\treturnOrThrowAsync(bilibiliApi.clearToViewVideoList().map(() => true)),\n\t\t// onSuccess: () => {\n\t\t// \tqueryClient.setQueryData(videoDataQueryKeys.getToViewVideoList(), {\n\t\t// \t\tcount: 0,\n\t\t// \t\tlist: [],\n\t\t// \t})\n\t\t// \ttoast.success('清除稍后再看列表成功')\n\t\t// },\n\t\t// onError: (err) => {\n\t\t// \ttoastAndLogError('清除稍后再看列表失败', err, 'UI.Player')\n\t\t// },\n\t\tonMutate: async (_, context) => {\n\t\t\tawait context.client.cancelQueries({\n\t\t\t\tqueryKey: videoDataQueryKeys.getToViewVideoList(),\n\t\t\t})\n\t\t\tconst previousData = context.client.getQueryData(\n\t\t\t\tvideoDataQueryKeys.getToViewVideoList(),\n\t\t\t)\n\t\t\tcontext.client.setQueryData(videoDataQueryKeys.getToViewVideoList(), {\n\t\t\t\tcount: 0,\n\t\t\t\tlist: [],\n\t\t\t})\n\t\t\treturn { previousData }\n\t\t},\n\t\tonSettled: async (_d, error, _v, onMutateResult, context) => {\n\t\t\tif (error) {\n\t\t\t\ttoastAndLogError(\n\t\t\t\t\t'清除稍后再看列表失败',\n\t\t\t\t\terror,\n\t\t\t\t\t'Mutation.Bilibili.Video',\n\t\t\t\t)\n\t\t\t\tcontext.client.setQueryData(\n\t\t\t\t\tvideoDataQueryKeys.getToViewVideoList(),\n\t\t\t\t\tonMutateResult?.previousData,\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\ttoast.success('清除稍后再看列表成功')\n\t\t\t}\n\t\t\tawait context.client.invalidateQueries({\n\t\t\t\tqueryKey: videoDataQueryKeys.getToViewVideoList(),\n\t\t\t})\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/db/playlist.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\nimport { useRouter } from 'expo-router'\n\nimport { playlistKeys } from '@/hooks/queries/db/playlist'\nimport useAppStore, { serializeCookieObject } from '@/hooks/stores/useAppStore'\nimport { api as bbplayerApi } from '@/lib/api/bbplayer/client'\nimport { queryClient } from '@/lib/config/queryClient'\nimport { CustomError } from '@/lib/errors'\nimport { playlistFacade } from '@/lib/facades/playlist'\nimport { sharedPlaylistFacade } from '@/lib/facades/sharedPlaylist'\nimport type { FavoriteSyncProgress } from '@/lib/facades/syncBilibiliPlaylist'\nimport { syncFacade } from '@/lib/facades/syncBilibiliPlaylist'\nimport { playlistService } from '@/lib/services/playlistService'\nimport type { Playlist } from '@/types/core/media'\nimport type { CreateArtistPayload } from '@/types/services/artist'\nimport type { UpdatePlaylistPayload } from '@/types/services/playlist'\nimport type { CreateTrackPayload } from '@/types/services/track'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\n/** 若当前无 BBPlayer JWT，尝试用 Bilibili Cookie 自动换取。无 cookie 时抛出异常。 */\nasync function ensureBBPlayerToken(): Promise<void> {\n\tconst store = useAppStore.getState()\n\tif (store.bbplayerToken) return\n\n\tconst cookie = store.bilibiliCookie\n\tif (!cookie || Object.keys(cookie).length === 0) {\n\t\tthrow new Error('请先登录 Bilibili，才能使用共享功能')\n\t}\n\n\tconst cookieStr = serializeCookieObject(cookie)\n\tconst resp = await bbplayerApi.auth.login.$post({\n\t\tjson: { cookie: cookieStr },\n\t})\n\tif (!resp.ok) {\n\t\tconst body = await resp.json().catch(() => ({}))\n\t\tthrow new Error(\n\t\t\t`BBPlayer 身份验证失败（${resp.status}）：${JSON.stringify(body)}`,\n\t\t)\n\t}\n\tconst data = (await resp.json()) as { token: string }\n\tstore.setBbplayerToken(data.token)\n}\n\nconst SCOPE = 'Mutation.DB.Playlist'\n\nqueryClient.setMutationDefaults(['db', 'playlist'], {\n\tretry: false,\n\tnetworkMode: 'always',\n})\n\n// React Query 的 invalidateQueries 会直接在后台刷新当前页面活跃的查询，能满足咱们的需求。\n// 只有当我们需要在 mutate 之后要跳转到另一个页面时，才需要去 invalidateQueries\nexport const usePlaylistSync = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'sync'],\n\t\tmutationFn: async ({\n\t\t\tremoteSyncId,\n\t\t\ttype,\n\t\t\tonProgress,\n\t\t}: {\n\t\t\tremoteSyncId: number\n\t\t\ttype: Playlist['type']\n\t\t\ttoastId?: string\n\t\t\tonProgress?: (progress: FavoriteSyncProgress) => void\n\t\t}) => {\n\t\t\tconst result = await syncFacade.sync(remoteSyncId, type, onProgress)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (id, { toastId }) => {\n\t\t\ttoast.success('同步成功', { id: toastId })\n\t\t\tif (!id) return\n\t\t\tawait Promise.all([\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistContents(id),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(id),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t])\n\t\t},\n\t\tonError: (error, { remoteSyncId, type, toastId }) => {\n\t\t\tif (toastId) {\n\t\t\t\ttoast.dismiss(toastId)\n\t\t\t}\n\t\t\ttoastAndLogError(\n\t\t\t\t`同步播放列表失败: remoteSyncId=${remoteSyncId}, type=${type}`,\n\t\t\t\terror,\n\t\t\t\tSCOPE,\n\t\t\t)\n\t\t},\n\t})\n}\n\n/**\n * 针对单个音轨，批量更新它所在的本地播放列表。\n * 当该 track 不存在时，会自动创建\n * 你可能并不需要直接使用此 mutation，请去使用 <AddVideoToLocalPlaylistModal /> 组件\n * @returns\n */\nexport const useUpdateTrackLocalPlaylists = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'updateTrackLocalPlaylists'],\n\t\tmutationFn: async (args: {\n\t\t\ttoAddPlaylistIds: number[]\n\t\t\ttoRemovePlaylistIds: number[]\n\t\t\ttrackPayload: CreateTrackPayload\n\t\t\tartistPayload?: CreateArtistPayload | null\n\t\t}) => {\n\t\t\tconst res = await playlistFacade.updateTrackLocalPlaylists(args)\n\t\t\tif (res.isErr()) throw res.error\n\t\t\treturn res.value\n\t\t},\n\t\tonSuccess: async (trackId, { toAddPlaylistIds, toRemovePlaylistIds }) => {\n\t\t\ttoast.success('操作成功')\n\t\t\tconst promises: Promise<unknown>[] = []\n\t\t\tpromises.push(\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistsContainingTrack(trackId),\n\t\t\t\t}),\n\t\t\t)\n\t\t\tpromises.push(\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t)\n\t\t\tfor (const id of [...toAddPlaylistIds, ...toRemovePlaylistIds]) {\n\t\t\t\tpromises.push(\n\t\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: playlistKeys.playlistContents(id),\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tpromises.push(\n\t\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(id),\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t}\n\t\t\tawait Promise.all(promises)\n\t\t},\n\t\tonError: (error, { trackPayload }) =>\n\t\t\ttoastAndLogError(\n\t\t\t\t`操作音频收藏位置失败: trackTitle=${trackPayload.title}`,\n\t\t\t\terror,\n\t\t\t\tSCOPE,\n\t\t\t),\n\t})\n}\n\nexport const useDuplicatePlaylist = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'duplicatePlaylist'],\n\t\tmutationFn: async ({\n\t\t\tplaylistId,\n\t\t\tname,\n\t\t}: {\n\t\t\tplaylistId: number\n\t\t\tname: string\n\t\t}) => {\n\t\t\tconst result = await playlistFacade.duplicatePlaylist(playlistId, name)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async () => {\n\t\t\ttoast.success('复制成功')\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t})\n\t\t},\n\t\tonError: (error, { playlistId, name }) =>\n\t\t\ttoastAndLogError(\n\t\t\t\t`复制播放列表失败: playlistId=${playlistId}, name=${name}`,\n\t\t\t\terror,\n\t\t\t\tSCOPE,\n\t\t\t),\n\t})\n}\n\nexport const useEditPlaylistMetadata = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'editPlaylistMetadata'],\n\t\tmutationFn: async ({\n\t\t\tplaylistId,\n\t\t\tpayload,\n\t\t}: {\n\t\t\tplaylistId: number\n\t\t\tpayload: UpdatePlaylistPayload\n\t\t}) => {\n\t\t\tif (playlistId === 0) return\n\t\t\tconst result = await playlistFacade.updatePlaylistMetadata(\n\t\t\t\tplaylistId,\n\t\t\t\tpayload,\n\t\t\t)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (_, variables) => {\n\t\t\ttoast.success('操作成功')\n\t\t\tawait Promise.all([\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(variables.playlistId),\n\t\t\t\t}),\n\t\t\t])\n\t\t},\n\t\tonError: (error, { playlistId }) =>\n\t\t\ttoastAndLogError(\n\t\t\t\t`修改播放列表信息失败：playlistId=${playlistId}`,\n\t\t\t\terror,\n\t\t\t\tSCOPE,\n\t\t\t),\n\t})\n}\n\nexport const useDeletePlaylist = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'deletePlaylist'],\n\t\tmutationFn: async ({ playlistId }: { playlistId: number }) => {\n\t\t\t// 共享歌单需要有效 token；本地歌单无需，若获取失败静默忽略\n\t\t\ttry {\n\t\t\t\tawait ensureBBPlayerToken()\n\t\t\t} catch {\n\t\t\t\t// local 歌单不需要 token，继续执行\n\t\t\t}\n\t\t\tconst result = await playlistFacade.deletePlaylist(playlistId)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async () => {\n\t\t\ttoast.success('删除成功')\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t})\n\t\t},\n\t\tonError: (error, { playlistId }) =>\n\t\t\ttoastAndLogError(\n\t\t\t\t`删除播放列表失败: playlistId=${playlistId}`,\n\t\t\t\terror,\n\t\t\t\tSCOPE,\n\t\t\t),\n\t})\n}\n\nexport const useBatchDeleteTracksFromLocalPlaylist = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'batchDeleteTracksFromLocalPlaylist'],\n\t\tmutationFn: async ({\n\t\t\ttrackIds,\n\t\t\tplaylistId,\n\t\t}: {\n\t\t\ttrackIds: number[]\n\t\t\tplaylistId: number\n\t\t}) => {\n\t\t\tconst result = await playlistFacade.removeTracksFromPlaylist(\n\t\t\t\tplaylistId,\n\t\t\t\ttrackIds,\n\t\t\t)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (data, variables) => {\n\t\t\ttoast.success('删除成功', {\n\t\t\t\tdescription:\n\t\t\t\t\tdata.missingTrackIds.length !== 0\n\t\t\t\t\t\t? `但缺少了: ${data.missingTrackIds.toString()} (理论来说不应该出现此错误)`\n\t\t\t\t\t\t: undefined,\n\t\t\t})\n\t\t\tconst promises = [\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistContents(variables.playlistId),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(variables.playlistId),\n\t\t\t\t}),\n\t\t\t]\n\t\t\tfor (const id of data.removedTrackIds) {\n\t\t\t\tpromises.push(\n\t\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: playlistKeys.playlistsContainingTrack(id),\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t}\n\t\t\tawait Promise.all(promises)\n\t\t},\n\t\tonError: (error) =>\n\t\t\ttoastAndLogError('从播放列表中删除 track 失败', error, SCOPE),\n\t})\n}\n\nexport const useCreateNewLocalPlaylist = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'createNewLocalPlaylist'],\n\t\tmutationFn: async (payload: {\n\t\t\ttitle: string\n\t\t\tdescription?: string\n\t\t\tcoverUrl?: string\n\t\t}) => {\n\t\t\tconst result = await playlistService.createPlaylist({\n\t\t\t\t...payload,\n\t\t\t\ttype: 'local',\n\t\t\t})\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (playlist) => {\n\t\t\ttoast.success('创建播放列表成功')\n\t\t\tawait Promise.all([\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistContents(playlist.id),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(playlist.id),\n\t\t\t\t}),\n\t\t\t])\n\t\t},\n\t\tonError: (error) => toastAndLogError('创建播放列表失败', error, SCOPE),\n\t})\n}\n\nexport const useMergePlaylists = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'mergePlaylists'],\n\t\tmutationFn: async ({\n\t\t\tsourcePlaylistIds,\n\t\t\ttitle,\n\t\t}: {\n\t\t\tsourcePlaylistIds: number[]\n\t\t\ttitle: string\n\t\t}) => {\n\t\t\tconst result = await playlistFacade.mergePlaylists(\n\t\t\t\tsourcePlaylistIds,\n\t\t\t\ttitle,\n\t\t\t)\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (newPlaylistId) => {\n\t\t\ttoast.success('动态合并歌单已创建')\n\t\t\tawait Promise.all([\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistContents(newPlaylistId),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(newPlaylistId),\n\t\t\t\t}),\n\t\t\t])\n\t\t},\n\t\tonError: (error) => toastAndLogError('创建动态合并歌单失败', error, SCOPE),\n\t})\n}\n\nexport const useReorderLocalPlaylistTrack = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'reorderLocalPlaylistTrack'],\n\t\tmutationFn: async ({\n\t\t\tplaylistId,\n\t\t\ttrackId,\n\t\t\tprevSortKey,\n\t\t\tnextSortKey,\n\t\t}: {\n\t\t\tplaylistId: number\n\t\t\ttrackId: number\n\t\t\tprevSortKey: string | null\n\t\t\tnextSortKey: string | null\n\t\t}) => {\n\t\t\tconst result = await playlistFacade.reorderLocalPlaylistTrack(\n\t\t\t\tplaylistId,\n\t\t\t\t{\n\t\t\t\t\ttrackId,\n\t\t\t\t\tprevSortKey,\n\t\t\t\t\tnextSortKey,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (_, { playlistId }) => {\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistContents(playlistId),\n\t\t\t})\n\t\t},\n\t\tonError: (error) => toastAndLogError('重排序歌曲位置失败', error, SCOPE),\n\t})\n}\n\n/**\n * 批量添加 tracks 到本地播放列表\n * @param playlistId\n * @param payloads 应包含 track 和 artist，**artist 只能为 remote 来源**\n * @returns\n */\nexport const useBatchAddTracksToLocalPlaylist = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'batchAddTracksToLocalPlaylist'],\n\t\tmutationFn: async ({\n\t\t\tplaylistId,\n\t\t\tpayloads,\n\t\t}: {\n\t\t\tplaylistId: number\n\t\t\tpayloads: { track: CreateTrackPayload; artist: CreateArtistPayload }[]\n\t\t}) => {\n\t\t\tconst result = await playlistFacade.batchAddTracksToLocalPlaylist(\n\t\t\t\tplaylistId,\n\t\t\t\tpayloads,\n\t\t\t)\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (trackIds, { playlistId }) => {\n\t\t\ttoast.success('添加成功')\n\t\t\tconst promises = [\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistContents(playlistId),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(playlistId),\n\t\t\t\t}),\n\t\t\t]\n\t\t\tfor (const id of trackIds) {\n\t\t\t\tpromises.push(\n\t\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\t\tqueryKey: playlistKeys.playlistsContainingTrack(id),\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t}\n\t\t\tawait Promise.all(promises)\n\t\t},\n\t\tonError: (error) =>\n\t\t\ttoastAndLogError('批量添加歌曲到播放列表失败', error, SCOPE),\n\t})\n}\n\n/**\n * 将本地歌单升级为共享歌单（启用分享）\n */\nexport const useEnableSharing = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'enableSharing'],\n\t\tmutationFn: async ({ playlistId }: { playlistId: number }) => {\n\t\t\tawait ensureBBPlayerToken()\n\t\t\tconst result = await sharedPlaylistFacade.enableSharing(playlistId)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (_, { playlistId }) => {\n\t\t\ttoast.success('已开启共享')\n\t\t\tawait Promise.all([\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(playlistId),\n\t\t\t\t}),\n\t\t\t])\n\t\t},\n\t\tonError: (error) => toastAndLogError('启用共享歌单失败', error, SCOPE),\n\t})\n}\n\n/**\n * 通过 shareId 订阅一个共享歌单\n */\nexport const useSubscribeToSharedPlaylist = () => {\n\tconst router = useRouter()\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'subscribeToSharedPlaylist'],\n\t\tmutationFn: async ({\n\t\t\tshareId,\n\t\t\tinviteCode,\n\t\t}: {\n\t\t\tshareId: string\n\t\t\tinviteCode?: string\n\t\t}) => {\n\t\t\tawait ensureBBPlayerToken()\n\t\t\tconst result = await sharedPlaylistFacade.subscribeToPlaylist({\n\t\t\t\tshareId,\n\t\t\t\tinviteCode,\n\t\t\t})\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async ({ localPlaylistId }) => {\n\t\t\ttoast.success('订阅成功')\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t})\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/local/[id]',\n\t\t\t\tparams: { id: String(localPlaylistId) },\n\t\t\t})\n\t\t},\n\t\tonError: (error) => toastAndLogError('订阅共享歌单失败', error, SCOPE),\n\t})\n}\n\n/**\n * 拉取共享歌单的增量变更\n */\nexport const usePullSharedPlaylist = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'pullSharedPlaylist'],\n\t\tmutationFn: async ({ playlistId }: { playlistId: number }) => {\n\t\t\tawait ensureBBPlayerToken()\n\t\t\tconst result = await sharedPlaylistFacade.pullChanges(playlistId)\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (_, { playlistId }) => {\n\t\t\tawait Promise.all([\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistContents(playlistId),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistMetadata(playlistId),\n\t\t\t\t}),\n\t\t\t\tqueryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: playlistKeys.playlistLists(),\n\t\t\t\t}),\n\t\t\t])\n\t\t},\n\t\tonError: (error, { playlistId }) => {\n\t\t\tif (\n\t\t\t\terror instanceof CustomError &&\n\t\t\t\terror.type === 'SharedPlaylistDeleted'\n\t\t\t) {\n\t\t\t\t// 交由调用方处理删除逻辑，这里静默\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoastAndLogError(\n\t\t\t\t`拉取共享歌单失败: playlistId=${playlistId}`,\n\t\t\t\terror,\n\t\t\t\tSCOPE,\n\t\t\t)\n\t\t},\n\t})\n}\n\nexport const useRotateEditorInviteCode = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'playlist', 'editorInvite', 'rotate'],\n\t\tmutationFn: async ({ shareId }: { shareId: string }) => {\n\t\t\tconst result = await sharedPlaylistFacade.rotateEditorInviteCode(shareId)\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async (data, { shareId }) => {\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: playlistKeys.editorInviteCode(shareId),\n\t\t\t})\n\t\t\treturn data\n\t\t},\n\t\tonError: (error) => toastAndLogError('生成编辑者邀请码失败', error, SCOPE),\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/db/track.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\n\nimport { playlistKeys } from '@/hooks/queries/db/playlist'\nimport { queryClient } from '@/lib/config/queryClient'\nimport { trackService } from '@/lib/services/trackService'\nimport type { Track } from '@/types/core/media'\n\nqueryClient.setMutationDefaults(['db', 'track'], {\n\tretry: false,\n\tnetworkMode: 'always',\n})\n\nexport const useRenameTrack = () => {\n\treturn useMutation({\n\t\tmutationKey: ['db', 'track', 'rename'],\n\t\tmutationFn: async ({\n\t\t\ttrackId,\n\t\t\tnewTitle,\n\t\t\tsource,\n\t\t}: {\n\t\t\ttrackId: number\n\t\t\tnewTitle: string\n\t\t\tsource: Track['source']\n\t\t}) => {\n\t\t\tconst result = await trackService.updateTrack({\n\t\t\t\tid: trackId,\n\t\t\t\ttitle: newTitle,\n\t\t\t\tsource,\n\t\t\t})\n\t\t\tif (result.isErr()) throw result.error\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: async () => {\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: [...playlistKeys.all, 'playlistContents'],\n\t\t\t})\n\t\t},\n\t\tonError: () => {},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/lyrics/index.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\n\nimport { lyricsQueryKeys } from '@/hooks/queries/lyrics'\nimport { queryClient } from '@/lib/config/queryClient'\nimport lyricService from '@/lib/services/lyricService'\nimport type { LyricSearchResult } from '@/types/player/lyrics'\nimport toast from '@/utils/toast'\n\nexport const useFetchLyrics = () => {\n\treturn useMutation({\n\t\tmutationKey: ['lyrics', 'fetchLyrics'],\n\t\tmutationFn: async ({\n\t\t\tuniqueKey,\n\t\t\titem,\n\t\t}: {\n\t\t\tuniqueKey: string\n\t\t\titem: LyricSearchResult[0]\n\t\t}) => {\n\t\t\tconst result = await lyricService.fetchLyrics(item, uniqueKey)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tonSuccess: (_, { uniqueKey }) => {\n\t\t\ttoast.show('歌词获取成功')\n\t\t\tvoid queryClient.invalidateQueries({\n\t\t\t\tqueryKey: lyricsQueryKeys.smartFetchLyrics(uniqueKey),\n\t\t\t})\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/mutations/orpheus/index.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useMutation } from '@tanstack/react-query'\n\nimport { orpheusQueryKeys } from '@/hooks/queries/orpheus'\nimport { queryClient } from '@/lib/config/queryClient'\n\nqueryClient.setMutationDefaults(['orpheus'], {\n\tretry: false,\n\tnetworkMode: 'always',\n})\n\nexport function useRemoveDownloadsMutation() {\n\treturn useMutation({\n\t\tmutationFn: async (ids: string[]) => {\n\t\t\tawait Orpheus.removeDownloads(ids)\n\t\t},\n\t\tmutationKey: ['orpheus', 'removeDownloads'],\n\t\tonSuccess: async () => {\n\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\tqueryKey: orpheusQueryKeys.allDownloads(),\n\t\t\t})\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/player/useCurrentTrack.ts",
    "content": "import { useShallow } from 'zustand/react/shallow'\n\nimport { usePlayerStore } from '@/hooks/stores/usePlayerStore'\n\nexport function useCurrentTrack() {\n\treturn usePlayerStore(useShallow((state) => state.internalTrack))\n}\n\nexport default useCurrentTrack\n"
  },
  {
    "path": "apps/mobile/src/hooks/player/useCurrentTrackId.ts",
    "content": "import { usePlayerStore } from '@/hooks/stores/usePlayerStore'\n\nexport function useCurrentTrackId() {\n\treturn usePlayerStore((state) => state.orpheusTrack?.id)\n}\n\nexport default useCurrentTrackId\n"
  },
  {
    "path": "apps/mobile/src/hooks/player/useIsCurrentTrack.ts",
    "content": "import { useShallow } from 'zustand/react/shallow'\n\nimport { usePlayerStore } from '@/hooks/stores/usePlayerStore'\n\nexport function useIsCurrentTrack(trackUniqueKey: string) {\n\treturn usePlayerStore(\n\t\tuseShallow((state) => state.orpheusTrack?.id === trackUniqueKey),\n\t)\n}\n\nexport default useIsCurrentTrack\n"
  },
  {
    "path": "apps/mobile/src/hooks/player/useLocalCover.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { Platform } from 'react-native'\n\n/**\n * 尝试获取本地已下载的封面 URI，如果不存在则返回原始 coverUrl。\n * 仅在 Android 上生效（iOS 暂不支持下载）。\n */\nexport function resolveTrackCover(\n\tuniqueKey: string | undefined,\n\tremoteCoverUrl: string | null | undefined,\n): string | null | undefined {\n\tif (Platform.OS !== 'android' || !uniqueKey) return remoteCoverUrl\n\treturn Orpheus.getDownloadedCoverUri(uniqueKey) ?? remoteCoverUrl\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/player/useSmoothProgress.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useCallback, useEffect } from 'react'\nimport { AppState } from 'react-native'\nimport { useFrameCallback, useSharedValue } from 'react-native-reanimated'\n\nimport playerProgressEmitter from '@/lib/player/progressListener'\n\n/**\n * 获取平滑的播放进度 (SharedValue)\n *\n * @param background 是否在后台保持监听事件更新（默认 false）\n */\nexport default function useSmoothProgress(background = false) {\n\tconst position = useSharedValue(0)\n\tconst duration = useSharedValue(0)\n\tconst buffered = useSharedValue(0)\n\tconst isPlaying = useSharedValue(false)\n\tconst isAppActive = useSharedValue(true)\n\n\tuseFrameCallback(\n\t\tuseCallback(\n\t\t\t(frameInfo) => {\n\t\t\t\tif (\n\t\t\t\t\t!isAppActive.value ||\n\t\t\t\t\t!isPlaying.value ||\n\t\t\t\t\t!frameInfo.timeSincePreviousFrame\n\t\t\t\t) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tposition.value =\n\t\t\t\t\tposition.value + frameInfo.timeSincePreviousFrame / 1000\n\t\t\t},\n\t\t\t[isAppActive, isPlaying, position],\n\t\t),\n\t)\n\n\tuseEffect(() => {\n\t\tconst syncState = () => {\n\t\t\tvoid Promise.all([\n\t\t\t\tOrpheus.getPosition(),\n\t\t\t\tOrpheus.getDuration(),\n\t\t\t\tOrpheus.getBuffered(),\n\t\t\t\tOrpheus.getIsPlaying(),\n\t\t\t]).then(([pos, dur, buf, playing]) => {\n\t\t\t\tposition.set(pos)\n\t\t\t\tduration.set(dur)\n\t\t\t\tbuffered.set(buf)\n\t\t\t\tisPlaying.set(playing)\n\t\t\t})\n\t\t}\n\n\t\tsyncState()\n\n\t\tconst appStateSub = AppState.addEventListener('change', (nextAppState) => {\n\t\t\tconst active = nextAppState === 'active'\n\t\t\tisAppActive.set(active)\n\t\t\tif (active) {\n\t\t\t\tsyncState()\n\t\t\t}\n\t\t})\n\n\t\tconst progressSub = playerProgressEmitter.subscribe('progress', (data) => {\n\t\t\tif (AppState.currentState !== 'active' && !background) return\n\t\t\tduration.set(data.duration)\n\t\t\tbuffered.set(data.buffered)\n\t\t\tconst diff = Math.abs(position.value - data.position)\n\t\t\tif (\n\t\t\t\tdiff > 0.05 ||\n\t\t\t\t!isPlaying.value ||\n\t\t\t\tAppState.currentState !== 'active'\n\t\t\t) {\n\t\t\t\tposition.set(data.position)\n\t\t\t}\n\t\t})\n\n\t\tconst stateSub = Orpheus.addListener('onPlaybackStateChanged', (_state) => {\n\t\t\tif (AppState.currentState !== 'active' && !background) return\n\t\t\tsyncState()\n\t\t})\n\n\t\tconst trackSub = Orpheus.addListener('onTrackStarted', syncState)\n\n\t\tconst playingSub = Orpheus.addListener(\n\t\t\t'onIsPlayingChanged',\n\t\t\t({ status }) => {\n\t\t\t\tisPlaying.set(status)\n\t\t\t\tif (AppState.currentState !== 'active' && !background) return\n\t\t\t\tsyncState()\n\t\t\t},\n\t\t)\n\n\t\treturn () => {\n\t\t\tprogressSub()\n\t\t\tstateSub.remove()\n\t\t\tappStateSub.remove()\n\t\t\ttrackSub.remove()\n\t\t\tplayingSub.remove()\n\t\t}\n\t}, [isPlaying, position, duration, buffered, isAppActive, background])\n\n\treturn { position, duration, buffered }\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/player/useTrackProgress.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useEffect, useRef, useState } from 'react'\nimport { AppState } from 'react-native'\n\nimport playerProgressEmitter from '@/lib/player/progressListener'\n\ninterface Progress {\n\tposition: number\n\tduration: number\n\tbuffered: number\n}\n\nconst INITIAL: Progress = { position: 0, duration: 0, buffered: 0 }\n\n/**\n * 基于事件的监听音频播放进度\n * @param background: 如果为 false，应用进入后台时会停止接收事件；为 true 则一直接收。\n */\nexport default function useTrackProgress(background = false) {\n\tconst [state, setState] = useState<Progress>(INITIAL)\n\tconst mountedRef = useRef(true)\n\tconst trackSubRef = useRef<(() => void) | null>(null)\n\tconst appSubRef = useRef<{ remove?: () => void } | null>(null)\n\n\tuseEffect(() => {\n\t\tmountedRef.current = true\n\t\treturn () => {\n\t\t\tmountedRef.current = false\n\t\t}\n\t}, [])\n\n\tconst addTrackListener = () => {\n\t\tif (trackSubRef.current) return\n\t\tconst handler = (e: Progress) => {\n\t\t\tif (!mountedRef.current) return\n\t\t\tsetState((prev) =>\n\t\t\t\tprev.position === e.position &&\n\t\t\t\tprev.duration === e.duration &&\n\t\t\t\tprev.buffered === e.buffered\n\t\t\t\t\t? prev\n\t\t\t\t\t: {\n\t\t\t\t\t\t\tposition: e.position,\n\t\t\t\t\t\t\tduration: e.duration,\n\t\t\t\t\t\t\tbuffered: e.buffered,\n\t\t\t\t\t\t},\n\t\t\t)\n\t\t}\n\t\ttrackSubRef.current = playerProgressEmitter.subscribe('progress', handler)\n\t}\n\n\tconst removeTrackListener = () => {\n\t\ttrackSubRef.current?.()\n\t\ttrackSubRef.current = null\n\t}\n\n\tuseEffect(() => {\n\t\tconst handleAppState = (next: string) => {\n\t\t\tif (next === 'active') {\n\t\t\t\taddTrackListener()\n\n\t\t\t\tvoid (async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst p = await Orpheus.getPosition()\n\t\t\t\t\t\tconst d = await Orpheus.getDuration()\n\t\t\t\t\t\tconst b = await Orpheus.getBuffered()\n\t\t\t\t\t\tif (!mountedRef.current) return\n\t\t\t\t\t\tsetState((prev) =>\n\t\t\t\t\t\t\tprev.position === p && prev.duration === d && prev.buffered === b\n\t\t\t\t\t\t\t\t? prev\n\t\t\t\t\t\t\t\t: { position: p, duration: d, buffered: prev.buffered },\n\t\t\t\t\t\t)\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// ignore\n\t\t\t\t\t}\n\t\t\t\t})()\n\t\t\t} else {\n\t\t\t\tif (!background) removeTrackListener()\n\t\t\t}\n\t\t}\n\n\t\tconst appSub = AppState.addEventListener('change', handleAppState)\n\t\tappSubRef.current = appSub\n\n\t\tif (background || AppState.currentState === 'active') {\n\t\t\taddTrackListener()\n\n\t\t\tvoid (async () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst p = await Orpheus.getPosition()\n\t\t\t\t\tconst d = await Orpheus.getDuration()\n\t\t\t\t\tconst b = await Orpheus.getBuffered()\n\t\t\t\t\tif (!mountedRef.current) return\n\t\t\t\t\tsetState((prev) =>\n\t\t\t\t\t\tprev.position === p && prev.duration === d && prev.buffered === b\n\t\t\t\t\t\t\t? prev\n\t\t\t\t\t\t\t: { position: p, duration: d, buffered: prev.buffered },\n\t\t\t\t\t)\n\t\t\t\t} catch {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t})()\n\t\t}\n\n\t\treturn () => {\n\t\t\tremoveTrackListener()\n\t\t\tappSubRef.current?.remove?.()\n\t\t}\n\t}, [background])\n\n\treturn state\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/bilibili/comments.ts",
    "content": "import { useInfiniteQuery } from '@tanstack/react-query'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const commentQueryKeys = {\n\tall: ['bilibili', 'comments'] as const,\n\tresults: (bvid: string, mode: number) =>\n\t\t[...commentQueryKeys.all, bvid, mode] as const,\n\treply: (bvid: string, rpid: number) =>\n\t\t[...commentQueryKeys.all, 'reply', bvid, rpid] as const,\n} as const\n\nexport function useComments(bvid: string, mode = 3) {\n\treturn useInfiniteQuery({\n\t\tqueryKey: commentQueryKeys.results(bvid, mode),\n\t\tqueryFn: async ({ pageParam }) => {\n\t\t\tconst res = await returnOrThrowAsync(\n\t\t\t\tbilibiliApi.getComments(bvid, pageParam, mode),\n\t\t\t)\n\t\t\treturn res\n\t\t},\n\t\tinitialPageParam: 0,\n\t\tgetNextPageParam: (lastPage) => {\n\t\t\tif (lastPage.cursor.is_end) return undefined\n\t\t\treturn lastPage.cursor.next\n\t\t},\n\t})\n}\n\nexport function useReplyComments(bvid: string, rpid: number) {\n\treturn useInfiniteQuery({\n\t\tqueryKey: commentQueryKeys.reply(bvid, rpid),\n\t\tqueryFn: async ({ pageParam }) => {\n\t\t\tconst res = await returnOrThrowAsync(\n\t\t\t\tbilibiliApi.getReplyComments(bvid, rpid, pageParam),\n\t\t\t)\n\t\t\treturn res\n\t\t},\n\t\tinitialPageParam: 1,\n\t\tgetNextPageParam: (lastPage) => {\n\t\t\tconst totalPages = Math.ceil(lastPage.page.count / lastPage.page.size)\n\t\t\tif (lastPage.page.num >= totalPages) return undefined\n\t\t\treturn lastPage.page.num + 1\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/bilibili/danmaku.ts",
    "content": "import { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { queryClient } from '@/lib/config/queryClient'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const danmakuQueryKeys = {\n\tall: ['bilibili', 'danmaku'] as const,\n\tsegment: (bvid: string, cid: number, segmentIndex: number) =>\n\t\t[...danmakuQueryKeys.all, 'segment', bvid, cid, segmentIndex] as const,\n}\n\nexport async function fetchDanmakuSegmentQuery(\n\tbvid: string,\n\tcid: number,\n\tsegmentIndex: number,\n) {\n\treturn queryClient.fetchQuery({\n\t\tqueryKey: danmakuQueryKeys.segment(bvid, cid, segmentIndex),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(bilibiliApi.getSegDanmaku(bvid, cid, segmentIndex)),\n\t\tstaleTime: 1000 * 60 * 5,\n\t\tgcTime: 1000 * 60 * 10,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/bilibili/favorite.ts",
    "content": "import { useInfiniteQuery, useQuery } from '@tanstack/react-query'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const favoriteListQueryKeys = {\n\tall: ['bilibili', 'favoriteList'] as const,\n\tinfiniteFavoriteList: (favoriteId?: number) =>\n\t\t[...favoriteListQueryKeys.all, 'infiniteFavoriteList', favoriteId] as const,\n\tallFavoriteList: (userMid?: number) =>\n\t\t[...favoriteListQueryKeys.all, 'allFavoriteList', userMid] as const,\n\tinfiniteCollectionList: (mid?: number) =>\n\t\t[...favoriteListQueryKeys.all, 'infiniteCollectionList', mid] as const,\n\tcollectionAllContents: (collectionId: number) =>\n\t\t[\n\t\t\t...favoriteListQueryKeys.all,\n\t\t\t'collectionAllContents',\n\t\t\tcollectionId,\n\t\t] as const,\n\tfavoriteForOneVideo: (bvid: string, userMid?: number) =>\n\t\t[\n\t\t\t...favoriteListQueryKeys.all,\n\t\t\t'favoriteForOneVideo',\n\t\t\tbvid,\n\t\t\tuserMid,\n\t\t] as const,\n\tinfiniteSearchFavoriteItems: (\n\t\tscope: 'all' | 'this',\n\t\tkeyword?: string,\n\t\tfavoriteId?: number,\n\t) => {\n\t\tswitch (scope) {\n\t\t\tcase 'all':\n\t\t\t\treturn [\n\t\t\t\t\t...favoriteListQueryKeys.all,\n\t\t\t\t\t'infiniteSearchFavoriteItems',\n\t\t\t\t\tkeyword,\n\t\t\t\t] as const\n\t\t\tcase 'this':\n\t\t\t\treturn [\n\t\t\t\t\t...favoriteListQueryKeys.all,\n\t\t\t\t\t'infiniteSearchFavoriteItems',\n\t\t\t\t\tkeyword,\n\t\t\t\t\tfavoriteId,\n\t\t\t\t] as const\n\t\t}\n\t},\n} as const\n\nconst useHasCookie = () => useAppStore((s) => s.hasBilibiliCookie())\n\n/**\n * 获取某个收藏夹的内容（无限滚动）\n * @param bilibiliApi\n * @param favoriteId\n */\nexport const useInfiniteFavoriteList = (favoriteId?: number) => {\n\tconst hasCookie = useHasCookie()\n\tconst enabled = hasCookie && !!favoriteId\n\treturn useInfiniteQuery({\n\t\tqueryKey: favoriteListQueryKeys.infiniteFavoriteList(favoriteId),\n\t\tqueryFn: ({ pageParam }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.getFavoriteListContents(favoriteId!, pageParam),\n\t\t\t),\n\t\tenabled,\n\t\tinitialPageParam: 1,\n\t\tgetNextPageParam: (lastPage, _allPages, lastPageParam) =>\n\t\t\tlastPage.has_more ? lastPageParam + 1 : undefined,\n\t\tstaleTime: 5 * 60 * 1000,\n\t})\n}\n\n/**\n * 获取收藏夹列表\n * @param bilibiliApi\n * @param userMid\n */\nexport const useGetFavoritePlaylists = (userMid?: number) => {\n\tconst hasCookie = useHasCookie()\n\tconst enabled = hasCookie && !!userMid\n\treturn useQuery({\n\t\tqueryKey: favoriteListQueryKeys.allFavoriteList(userMid),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(bilibiliApi.getFavoritePlaylists(userMid!)),\n\t\tenabled,\n\t\tstaleTime: 5 * 60 * 1000, // 5 minutes\n\t})\n}\n\n/**\n * 获取追更合集列表（分页）\n */\nexport const useInfiniteCollectionsList = (mid?: number) => {\n\tconst hasCookie = useHasCookie()\n\tconst enabled = hasCookie && !!mid\n\treturn useInfiniteQuery({\n\t\tqueryKey: favoriteListQueryKeys.infiniteCollectionList(mid),\n\t\tqueryFn: ({ pageParam }) =>\n\t\t\treturnOrThrowAsync(bilibiliApi.getCollectionsList(pageParam, mid!)),\n\t\tenabled,\n\t\tinitialPageParam: 1,\n\t\tgetNextPageParam: (lastPage, _allPages, lastPageParam) =>\n\t\t\tlastPage.hasMore ? lastPageParam + 1 : undefined,\n\t\tstaleTime: 1,\n\t})\n}\n\n/**\n * 获取合集详细信息和完整内容\n * (非登录可访问)\n */\nexport const useCollectionAllContents = (collectionId: number) => {\n\treturn useQuery({\n\t\tqueryKey: favoriteListQueryKeys.collectionAllContents(collectionId),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(bilibiliApi.getCollectionAllContents(collectionId)),\n\t\tstaleTime: 1,\n\t})\n}\n\n/**\n * 获取包含指定视频的收藏夹列表\n */\nexport const useGetFavoriteForOneVideo = (bvid: string, userMid?: number) => {\n\tconst hasCookie = useHasCookie()\n\tconst enabled = hasCookie && !!userMid && bvid.length > 0\n\treturn useQuery({\n\t\tqueryKey: favoriteListQueryKeys.favoriteForOneVideo(bvid, userMid),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.getTargetVideoFavoriteStatus(userMid!, bvid),\n\t\t\t),\n\t\tenabled,\n\t\tstaleTime: 0,\n\t\tgcTime: 0,\n\t})\n}\n\n/**\n * 在所有收藏夹中搜索关键字\n */\nexport const useInfiniteSearchFavoriteItems = (\n\tscope: 'all' | 'this',\n\tkeyword?: string,\n\tfavoriteId?: number,\n) => {\n\tconst hasCookie = useHasCookie()\n\tconst enabled =\n\t\t!!keyword && keyword.trim().length > 0 && hasCookie && !!favoriteId\n\treturn useInfiniteQuery({\n\t\tqueryKey: favoriteListQueryKeys.infiniteSearchFavoriteItems(\n\t\t\tscope,\n\t\t\tkeyword,\n\t\t\tfavoriteId,\n\t\t),\n\t\tqueryFn: ({ pageParam }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.searchFavoriteListContents(\n\t\t\t\t\tfavoriteId!,\n\t\t\t\t\tscope,\n\t\t\t\t\tpageParam,\n\t\t\t\t\tkeyword!,\n\t\t\t\t),\n\t\t\t),\n\t\tenabled,\n\t\tinitialPageParam: 1,\n\t\tgetNextPageParam: (lastPage, _allPages, lastPageParam) =>\n\t\t\tlastPage.has_more ? lastPageParam + 1 : undefined,\n\t\tstaleTime: 1,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/bilibili/search.ts",
    "content": "import { useInfiniteQuery, useQuery } from '@tanstack/react-query'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport log from '@/utils/log'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nconst logger = log.extend('Queries.SearchQueries')\n\nexport const searchQueryKeys = {\n\tall: ['bilibili', 'search'] as const,\n\tresults: (query: string) =>\n\t\t[...searchQueryKeys.all, 'results', query] as const,\n\thotSearches: () => [...searchQueryKeys.all, 'hotSearches'] as const,\n\tsuggestions: (query: string) =>\n\t\t[...searchQueryKeys.all, 'suggestions', query] as const,\n} as const\n\n// 搜索结果查询\nexport const useSearchResults = (query: string) => {\n\tconst enabled = query.trim().length > 0\n\treturn useInfiniteQuery({\n\t\tqueryKey: searchQueryKeys.results(query),\n\t\tqueryFn: ({ pageParam = 1 }) =>\n\t\t\treturnOrThrowAsync(bilibiliApi.searchVideos(query, pageParam)),\n\t\tenabled,\n\t\tstaleTime: 5 * 60 * 1000,\n\t\tinitialPageParam: 1,\n\t\tgetNextPageParam: (lastPage, allPages) => {\n\t\t\tif (lastPage.numPages === 0) {\n\t\t\t\treturn undefined\n\t\t\t}\n\t\t\tif (lastPage.numPages === allPages.length) {\n\t\t\t\treturn undefined\n\t\t\t}\n\t\t\treturn allPages.length + 1\n\t\t},\n\t})\n}\n\n// 热门搜索查询\nexport const useHotSearches = () => {\n\treturn useQuery({\n\t\tqueryKey: searchQueryKeys.hotSearches(),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.getHotSearches()),\n\t\tstaleTime: 15 * 60 * 1000,\n\t})\n}\n\n// 搜索建议查询\nexport const useSearchSuggestions = (query: string) => {\n\tconst enabled = query.trim().length > 0\n\treturn useQuery({\n\t\tqueryKey: searchQueryKeys.suggestions(query),\n\t\tqueryFn: async () => {\n\t\t\tconst result = await bilibiliApi.getSearchSuggestions(query)\n\t\t\tif (result.isErr()) {\n\t\t\t\tlogger.warning('搜索建议查询失败，但无关紧要', { query })\n\t\t\t\treturn []\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tenabled,\n\t\tstaleTime: 0,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/bilibili/user.ts",
    "content": "import { useInfiniteQuery, useQuery } from '@tanstack/react-query'\nimport { Image } from 'expo-image'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const userQueryKeys = {\n\tall: ['bilibili', 'user'] as const,\n\tpersonalInformation: () =>\n\t\t[...userQueryKeys.all, 'personalInformation'] as const,\n\trecentlyPlayed: () => [...userQueryKeys.all, 'recentlyPlayed'] as const,\n\tuploadedVideos: (mid: number, keyword?: string) =>\n\t\t[...userQueryKeys.all, 'uploadedVideos', mid, keyword ?? ''] as const,\n\totherUserInfo: (mid: number) =>\n\t\t[...userQueryKeys.all, 'otherUserInfo', mid] as const,\n}\n\nexport const usePersonalInformation = () => {\n\tconst hasCookie = useAppStore((s) => s.hasBilibiliCookie())\n\tconst enabled = hasCookie\n\n\treturn useQuery({\n\t\tqueryKey: userQueryKeys.personalInformation(),\n\t\tqueryFn: async () => {\n\t\t\tconst res = await returnOrThrowAsync(bilibiliApi.getUserInfo())\n\t\t\t// 缓存用户信息和头像供离线时显示\n\t\t\tif (res.name) {\n\t\t\t\tuseAppStore.getState().setBilibiliUserInfo({\n\t\t\t\t\tmid: res.mid,\n\t\t\t\t\tname: res.name,\n\t\t\t\t\tface: res.face,\n\t\t\t\t\tcachedAt: Date.now(),\n\t\t\t\t})\n\t\t\t\tif (res.face) {\n\t\t\t\t\tImage.prefetch(res.face, 'disk').catch(() => {\n\t\t\t\t\t\t// Ignore error\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn res\n\t\t},\n\t\tenabled,\n\t\tinitialData: () => {\n\t\t\tconst storeData = useAppStore.getState().bilibiliUserInfo\n\t\t\tif (storeData && storeData.name) {\n\t\t\t\treturn {\n\t\t\t\t\tmid: storeData.mid ?? 0,\n\t\t\t\t\tname: storeData.name,\n\t\t\t\t\tface: storeData.face,\n\t\t\t\t} as import('@/types/apis/bilibili').BilibiliUserInfo\n\t\t\t}\n\t\t\treturn undefined\n\t\t},\n\t\tinitialDataUpdatedAt: () => {\n\t\t\treturn useAppStore.getState().bilibiliUserInfo?.cachedAt ?? 0\n\t\t},\n\t\tstaleTime: 24 * 60 * 1000, // 不需要刷新太频繁\n\t})\n}\n\nexport const useRecentlyPlayed = () => {\n\tconst hasCookie = useAppStore((s) => s.hasBilibiliCookie())\n\tconst enabled = hasCookie\n\treturn useQuery({\n\t\tqueryKey: userQueryKeys.recentlyPlayed(),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.getHistory()),\n\t\tenabled,\n\t\tstaleTime: 1 * 60 * 1000,\n\t})\n}\n\nexport const useInfiniteGetUserUploadedVideos = (\n\tmid: number,\n\tkeyword?: string,\n) => {\n\t// 这个接口有风控校验\n\tconst hasCookie = useAppStore((s) => s.hasBilibiliCookie())\n\tconst enabled = !!mid && hasCookie\n\treturn useInfiniteQuery({\n\t\tqueryKey: userQueryKeys.uploadedVideos(mid, keyword),\n\t\tqueryFn: ({ pageParam }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tbilibiliApi.getUserUploadedVideos(mid, pageParam, keyword),\n\t\t\t),\n\t\tenabled,\n\t\tgetNextPageParam: (lastPage) => {\n\t\t\tconst nowLoaded = lastPage.page.pn * lastPage.page.ps\n\t\t\tif (nowLoaded >= lastPage.page.count) {\n\t\t\t\treturn undefined\n\t\t\t}\n\t\t\treturn lastPage.page.pn + 1\n\t\t},\n\t\tinitialPageParam: 1,\n\t\tstaleTime: 1,\n\t})\n}\n\nexport const useOtherUserInfo = (mid: number) => {\n\t// 这个接口有风控校验\n\tconst hasCookie = useAppStore((s) => s.hasBilibiliCookie())\n\tconst enabled = !!mid && hasCookie\n\treturn useQuery({\n\t\tqueryKey: userQueryKeys.otherUserInfo(mid),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.getOtherUserInfo(mid)),\n\t\tenabled,\n\t\tstaleTime: 24 * 60 * 1000, // 不需要刷新太频繁\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/bilibili/video.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const videoDataQueryKeys = {\n\tall: ['bilibili', 'videoData'] as const,\n\tgetMultiPageList: (bvid?: string) =>\n\t\t[...videoDataQueryKeys.all, 'getMultiPageList', bvid] as const,\n\tgetVideoDetails: (bvid?: string) =>\n\t\t[...videoDataQueryKeys.all, 'getVideoDetails', bvid] as const,\n\tgetVideoIsThumbUp: (bvid?: string) =>\n\t\t[...videoDataQueryKeys.all, 'getVideoIsThumbUp', bvid] as const,\n\tgetWebPlayerInfo: (bvid?: string, cid?: number) =>\n\t\t[...videoDataQueryKeys.all, 'getWebPlayerInfo', bvid, cid] as const,\n\tgetToViewVideoList: () =>\n\t\t[...videoDataQueryKeys.all, 'getToViewVideoList'] as const,\n} as const\n\n/**\n * 获取分P列表\n */\nexport const useGetMultiPageList = (bvid: string | undefined) => {\n\tconst enabled = !!bvid\n\treturn useQuery({\n\t\tqueryKey: videoDataQueryKeys.getMultiPageList(bvid),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.getPageList(bvid!)),\n\t\tenabled,\n\t\tstaleTime: 1,\n\t})\n}\n\n/**\n * 获取视频详细信息\n */\nexport const useGetVideoDetails = (bvid: string | undefined) => {\n\tconst enabled = !!bvid\n\treturn useQuery({\n\t\tqueryKey: videoDataQueryKeys.getVideoDetails(bvid),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.getVideoDetails(bvid!)),\n\t\tenabled,\n\t\tstaleTime: 60 * 60 * 1000, // 我们不需要获取实时的视频详细信息\n\t})\n}\n\n/**\n * 检查视频是否已经点赞\n */\nexport const useGetVideoIsThumbUp = (bvid: string | undefined) => {\n\tconst hasCookie = useAppStore((s) => s.hasBilibiliCookie())\n\tconst enabled = !!bvid && hasCookie\n\treturn useQuery({\n\t\tqueryKey: videoDataQueryKeys.getVideoIsThumbUp(bvid),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.checkVideoIsThumbUp(bvid!)),\n\t\tenabled,\n\t\tstaleTime: 0,\n\t})\n}\n\n/**\n * 获取 web 播放器信息\n */\nexport const useGetWebPlayerInfo = (\n\tbvid: string | undefined,\n\tcid: number | undefined,\n) => {\n\tconst enabled = !!bvid && !!cid\n\treturn useQuery({\n\t\tqueryKey: videoDataQueryKeys.getWebPlayerInfo(bvid, cid),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(bilibiliApi.getWebPlayerInfo(bvid!, cid!)),\n\t\tenabled,\n\t\tstaleTime: 5 * 60 * 1000,\n\t})\n}\n\n/**\n * 获取稍后再看视频列表\n */\nexport const useGetToViewVideoList = () => {\n\tconst hasCookie = useAppStore((s) => s.hasBilibiliCookie())\n\tconst enabled = hasCookie\n\treturn useQuery({\n\t\tqueryKey: videoDataQueryKeys.getToViewVideoList(),\n\t\tqueryFn: () => returnOrThrowAsync(bilibiliApi.getToViewVideoList()),\n\t\tenabled,\n\t\tstaleTime: 0,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/db/playlist.ts",
    "content": "import {\n\tkeepPreviousData,\n\tskipToken,\n\tuseInfiniteQuery,\n\tuseQuery,\n} from '@tanstack/react-query'\n\nimport { queryClient } from '@/lib/config/queryClient'\nimport { sharedPlaylistFacade } from '@/lib/facades/sharedPlaylist'\nimport { playlistService } from '@/lib/services/playlistService'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nqueryClient.setQueryDefaults(['db', 'playlists'], {\n\tretry: false,\n\tstaleTime: 0,\n\tnetworkMode: 'always',\n})\n\nexport const playlistKeys = {\n\tall: ['db', 'playlists'] as const,\n\tplaylistLists: () => [...playlistKeys.all, 'playlistLists'] as const,\n\tplaylistContents: (playlistId: number) =>\n\t\t[...playlistKeys.all, 'playlistContents', playlistId] as const,\n\tplaylistAllContents: (playlistId: number) =>\n\t\t[...playlistKeys.playlistContents(playlistId), 'all'] as const,\n\tplaylistMetadata: (playlistId: number) =>\n\t\t[...playlistKeys.all, 'playlistMetadata', playlistId] as const,\n\tplaylistsContainingTrack: (id: number | string | undefined) =>\n\t\t[...playlistKeys.all, 'playlistsContainingTrack', id] as const,\n\tsearchTracksInPlaylist: (playlistId: number, query: string) =>\n\t\t[...playlistKeys.all, 'searchTracksInPlaylist', playlistId, query] as const,\n\tsearchPlaylists: (query: string) =>\n\t\t[...playlistKeys.all, 'searchPlaylists', query] as const,\n\tplaylistContentsInfinite: (\n\t\tplaylistId: number,\n\t\tlimit: number,\n\t\tinitialLimit?: number,\n\t) =>\n\t\t[\n\t\t\t...playlistKeys.playlistContents(playlistId),\n\t\t\t'infinite',\n\t\t\tlimit,\n\t\t\tinitialLimit,\n\t\t] as const,\n\teditorInviteCode: (shareId: string) =>\n\t\t[...playlistKeys.all, 'editorInviteCode', shareId] as const,\n\tplaylistByShareId: (shareId: string) =>\n\t\t[...playlistKeys.all, 'byShareId', shareId] as const,\n}\n\nexport const usePlaylistLists = () => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.playlistLists(),\n\t\tqueryFn: () => returnOrThrowAsync(playlistService.getAllPlaylists()),\n\t})\n}\n\nexport const usePlaylistContents = (playlistId: number) => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.playlistAllContents(playlistId),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(playlistService.getPlaylistTracks(playlistId)),\n\t})\n}\n\nexport const usePlaylistMetadata = (playlistId: number) => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.playlistMetadata(playlistId),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(playlistService.getPlaylistMetadata(playlistId)),\n\t})\n}\n\nexport const usePlaylistsContainingTrack = (uniqueKey: string | undefined) => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.playlistsContainingTrack(uniqueKey),\n\t\tqueryFn:\n\t\t\tuniqueKey !== undefined\n\t\t\t\t? () =>\n\t\t\t\t\t\treturnOrThrowAsync(\n\t\t\t\t\t\t\tplaylistService.getLocalPlaylistsContainingTrackByUniqueKey(\n\t\t\t\t\t\t\t\tuniqueKey,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t)\n\t\t\t\t: skipToken,\n\t\tenabled: uniqueKey !== undefined,\n\t})\n}\n\nexport const useSearchTracksInPlaylist = (\n\tplaylistId: number,\n\tquery: string,\n\tstartSearch: boolean,\n) => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.searchTracksInPlaylist(playlistId, query),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tplaylistService.searchTrackInPlaylist(playlistId, query),\n\t\t\t),\n\t\tenabled: !!query.trim() && startSearch,\n\t\tplaceholderData: keepPreviousData,\n\t})\n}\n\nexport const useSearchPlaylists = (query: string, enabled: boolean) => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.searchPlaylists(query),\n\t\tqueryFn: () => returnOrThrowAsync(playlistService.searchPlaylists(query)),\n\t\tenabled: enabled && !!query.trim(),\n\t\tplaceholderData: keepPreviousData,\n\t})\n}\n\nexport const usePlaylistContentsInfinite = (\n\tplaylistId: number,\n\tlimit: number,\n\tinitialLimit?: number,\n) => {\n\treturn useInfiniteQuery({\n\t\tqueryKey: playlistKeys.playlistContentsInfinite(\n\t\t\tplaylistId,\n\t\t\tlimit,\n\t\t\tinitialLimit,\n\t\t),\n\t\tqueryFn: ({ pageParam }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\tplaylistService.getPlaylistTracksPaginated({\n\t\t\t\t\tplaylistId,\n\t\t\t\t\tlimit,\n\t\t\t\t\tinitialLimit,\n\t\t\t\t\tcursor: pageParam,\n\t\t\t\t}),\n\t\t\t),\n\t\tgetNextPageParam: (lastPage) => lastPage.nextCursor,\n\t\tinitialPageParam: undefined as\n\t\t\t| { lastSortKey: string; createdAt: number; lastId: number }\n\t\t\t| undefined,\n\t\tgcTime: 0,\n\t})\n}\n\nexport const usePlaylistByShareId = (shareId?: string) => {\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.playlistByShareId(shareId ?? ''),\n\t\tqueryFn: shareId\n\t\t\t? () => returnOrThrowAsync(playlistService.findPlaylistByShareId(shareId))\n\t\t\t: skipToken,\n\t\tenabled: !!shareId,\n\t})\n}\n\nexport const useEditorInviteCode = (shareId?: string | null) => {\n\tconst enabled = !!shareId\n\treturn useQuery({\n\t\tqueryKey: playlistKeys.editorInviteCode(shareId ?? ''),\n\t\tqueryFn: enabled\n\t\t\t? () =>\n\t\t\t\t\treturnOrThrowAsync(sharedPlaylistFacade.getEditorInviteCode(shareId))\n\t\t\t: skipToken,\n\t\tselect: (result) => result.editorInviteCode ?? null,\n\t\tenabled,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/db/track.ts",
    "content": "import { useInfiniteQuery, useQuery } from '@tanstack/react-query'\n\nimport { queryClient } from '@/lib/config/queryClient'\nimport { trackService } from '@/lib/services/trackService'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nqueryClient.setQueryDefaults(['db', 'tracks'], {\n\tretry: false,\n\tstaleTime: 0,\n\tnetworkMode: 'always',\n})\n\nexport const trackKeys = {\n\tall: ['db', 'tracks'] as const,\n\thistory: () => [...trackKeys.all, 'history'] as const,\n\thistoryContentPaginated: (\n\t\tlimit: number,\n\t\tonlyCompleted: boolean,\n\t\tinitialLimit?: number,\n\t) =>\n\t\t[\n\t\t\t...trackKeys.history(),\n\t\t\t'contentPaginated',\n\t\t\tlimit,\n\t\t\tonlyCompleted,\n\t\t\tinitialLimit,\n\t\t] as const,\n\ttotalPlaybackDuration: (onlyCompleted: boolean) =>\n\t\t[...trackKeys.history(), 'totalPlaybackDuration', onlyCompleted] as const,\n}\n\nexport function usePlayCountHistoryPaginated(\n\tlimit: number,\n\tonlyCompleted: boolean,\n\tinitialLimit?: number,\n) {\n\treturn useInfiniteQuery({\n\t\tqueryKey: trackKeys.historyContentPaginated(\n\t\t\tlimit,\n\t\t\tonlyCompleted,\n\t\t\tinitialLimit,\n\t\t),\n\n\t\tqueryFn: async ({ pageParam }) =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\ttrackService.getPlayCountHistoryPaginated({\n\t\t\t\t\tlimit,\n\t\t\t\t\tonlyCompleted,\n\t\t\t\t\tinitialLimit,\n\t\t\t\t\tcursor: pageParam,\n\t\t\t\t}),\n\t\t\t),\n\t\tinitialPageParam: undefined as\n\t\t\t| { lastPlayCount: number; lastUpdatedAt: number; lastId: number }\n\t\t\t| undefined,\n\t\tgetNextPageParam: (lastPage) => {\n\t\t\treturn lastPage.nextCursor\n\t\t},\n\t\t// 每次打开页面都重新获取数据，避免直接加载缓存中的大量数据导致卡顿\n\t\tgcTime: 0,\n\t})\n}\n\nexport function useTotalPlaybackDuration(onlyCompleted = true) {\n\treturn useQuery({\n\t\tqueryKey: trackKeys.totalPlaybackDuration(onlyCompleted),\n\t\tqueryFn: () =>\n\t\t\treturnOrThrowAsync(\n\t\t\t\ttrackService.getTotalPlaybackDuration({ onlyCompleted }),\n\t\t\t),\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/external-playlist/useExternalPlaylist.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\n\nimport { externalPlaylistService } from '@/lib/services/externalPlaylistService'\n\nexport const useExternalPlaylist = (\n\tplaylistId: string,\n\tsource: 'netease' | 'qq',\n) => {\n\treturn useQuery({\n\t\tqueryKey: ['external-playlist', source, playlistId],\n\t\tqueryFn: async () => {\n\t\t\tif (!playlistId) return null\n\t\t\tconst result = await externalPlaylistService.fetchExternalPlaylist(\n\t\t\t\tplaylistId,\n\t\t\t\tsource,\n\t\t\t)\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tenabled: !!playlistId,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/lyrics/index.ts",
    "content": "import { useQueries, useQuery } from '@tanstack/react-query'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { kugouApi } from '@/lib/api/kugou/api'\nimport { neteaseApi } from '@/lib/api/netease/api'\nimport { qqMusicApi } from '@/lib/api/qqmusic/api'\nimport lyricService from '@/lib/services/lyricService'\nimport type { Track } from '@/types/core/media'\nimport type { LyricFileData, LyricSearchResult } from '@/types/player/lyrics'\n\nexport const lyricsQueryKeys = {\n\tall: ['lyrics'] as const,\n\tsmartFetchLyrics: (uniqueKey?: string) =>\n\t\t[...lyricsQueryKeys.all, 'smartFetchLyrics', uniqueKey] as const,\n\tmanualSearch: (uniqueKey?: string, query?: string) =>\n\t\t[...lyricsQueryKeys.all, 'manualSearch', uniqueKey, query] as const,\n}\n\nexport const useSmartFetchLyrics = (enable: boolean, track?: Track) => {\n\tconst enabled = !!track && enable\n\treturn useQuery({\n\t\t// oxlint-disable-next-line @tanstack/query/exhaustive-deps\n\t\tqueryKey: lyricsQueryKeys.smartFetchLyrics(track?.uniqueKey),\n\t\tqueryFn: async () => {\n\t\t\tconst result = await lyricService.smartFetchLyrics(track!)\n\t\t\tif (result.isErr()) {\n\t\t\t\tif (result.error.type === 'LyricNotFound') {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: track!.uniqueKey,\n\t\t\t\t\t\tupdateTime: Date.now(),\n\t\t\t\t\t\tlrc: undefined,\n\t\t\t\t\t\ttlyric: undefined,\n\t\t\t\t\t\tromalrc: undefined,\n\t\t\t\t\t\terrorMessage: result.error.message,\n\t\t\t\t\t\tmisc: undefined,\n\t\t\t\t\t} satisfies LyricFileData\n\t\t\t\t}\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\t// manualSkip: 用户已手动跳过该曲目的歌词获取\n\t\t\tif (result.value.manualSkip) {\n\t\t\t\treturn {\n\t\t\t\t\tid: track!.uniqueKey,\n\t\t\t\t\tupdateTime: result.value.updateTime,\n\t\t\t\t\tlrc: undefined,\n\t\t\t\t\ttlyric: undefined,\n\t\t\t\t\tromalrc: undefined,\n\t\t\t\t\tmanualSkip: true,\n\t\t\t\t\terrorMessage: '已跳过歌词获取，但你可以重新搜索或编辑歌词',\n\t\t\t\t\tmisc: undefined,\n\t\t\t\t} satisfies LyricFileData\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tenabled,\n\t\tstaleTime: 0,\n\t\tnetworkMode: 'always',\n\t})\n}\n\nexport const useManualSearchLyrics = (uniqueKey?: string) => {\n\tconst [searchQuery, setSearchQuery] = useState<string | undefined>(undefined)\n\n\tconst [results, setResults] = useState<LyricSearchResult>([])\n\tconst processedProvidersRef = useRef<Set<string>>(new Set())\n\n\t// Effect to reset results when query changes - REMOVED\n\t// Moved to triggerSearch\n\n\tconst queries = useQueries({\n\t\tqueries: [\n\t\t\t{\n\t\t\t\tqueryKey: lyricsQueryKeys.manualSearch(\n\t\t\t\t\tuniqueKey,\n\t\t\t\t\t`netease-${searchQuery}`,\n\t\t\t\t),\n\t\t\t\tqueryFn: async ({ signal }) => {\n\t\t\t\t\tif (!searchQuery) return []\n\t\t\t\t\tconst res = await neteaseApi.search(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkeywords: searchQuery,\n\t\t\t\t\t\t\tlimit: 20,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t)\n\t\t\t\t\tif (res.isOk()) {\n\t\t\t\t\t\treturn res.value\n\t\t\t\t\t}\n\t\t\t\t\tthrow res.error\n\t\t\t\t},\n\t\t\t\tenabled: !!searchQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t},\n\t\t\t{\n\t\t\t\tqueryKey: lyricsQueryKeys.manualSearch(uniqueKey, `qq-${searchQuery}`),\n\t\t\t\tqueryFn: async ({ signal }) => {\n\t\t\t\t\tif (!searchQuery) return []\n\t\t\t\t\tconst res = await qqMusicApi.search(searchQuery, 20, signal)\n\t\t\t\t\tif (res.isOk()) {\n\t\t\t\t\t\treturn res.value\n\t\t\t\t\t}\n\t\t\t\t\tthrow res.error\n\t\t\t\t},\n\t\t\t\tenabled: !!searchQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t},\n\t\t\t{\n\t\t\t\tqueryKey: lyricsQueryKeys.manualSearch(\n\t\t\t\t\tuniqueKey,\n\t\t\t\t\t`kugou-${searchQuery}`,\n\t\t\t\t),\n\t\t\t\tqueryFn: async ({ signal }) => {\n\t\t\t\t\tif (!searchQuery) return []\n\t\t\t\t\tconst res = await kugouApi.search(searchQuery, 20, signal)\n\t\t\t\t\tif (res.isOk()) {\n\t\t\t\t\t\treturn res.value\n\t\t\t\t\t}\n\t\t\t\t\tthrow res.error\n\t\t\t\t},\n\t\t\t\tenabled: !!searchQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t},\n\t\t],\n\t})\n\n\tconst neteaseQuery = queries[0]\n\tconst qqQuery = queries[1]\n\tconst kugouQuery = queries[2]\n\n\tconst neteaseData = neteaseQuery.data\n\tconst qqData = qqQuery.data\n\tconst kugouData = kugouQuery.data\n\n\t// Effect to append results as they arrive\n\tuseEffect(() => {\n\t\tconst processResult = (\n\t\t\tproviderName: string,\n\t\t\tdata: LyricSearchResult | undefined,\n\t\t) => {\n\t\t\tif (data && !processedProvidersRef.current.has(providerName)) {\n\t\t\t\tsetResults((prev) => [...prev, ...data])\n\t\t\t\tprocessedProvidersRef.current.add(providerName)\n\t\t\t}\n\t\t}\n\n\t\tif (neteaseData) processResult('netease', neteaseData)\n\t\tif (qqData) processResult('qq', qqData)\n\t\tif (kugouData) processResult('kugou', kugouData)\n\t}, [neteaseData, qqData, kugouData])\n\n\tconst triggerSearch = useCallback((query: string) => {\n\t\tsetResults([])\n\t\tprocessedProvidersRef.current = new Set()\n\t\tsetSearchQuery(query)\n\t}, [])\n\n\tconst isLoading = queries.some((q) => q.isFetching)\n\n\treturn {\n\t\tsearch: triggerSearch,\n\t\tresults,\n\t\tisLoading,\n\t\terrors: {\n\t\t\tnetease: neteaseQuery.error,\n\t\t\tqq: qqQuery.error,\n\t\t\tkugou: kugouQuery.error,\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/orpheus/index.ts",
    "content": "import type { Track as OrpheusTrack } from '@bbplayer/orpheus'\nimport { Orpheus } from '@bbplayer/orpheus'\nimport { useQuery } from '@tanstack/react-query'\n\nimport { queryClient } from '@/lib/config/queryClient'\n\nexport const orpheusQueryKeys = {\n\tall: ['orpheus'] as const,\n\tbatchDownloadStatus: (ids: string[]) =>\n\t\t[...orpheusQueryKeys.all, 'batchDownloadStatus', ids] as const,\n\tshuffleMode: () => [...orpheusQueryKeys.all, 'shuffleMode'] as const,\n\tdownloadTasks: () => [...orpheusQueryKeys.all, 'downloadTasks'] as const,\n\tplayerQueue: () => [...orpheusQueryKeys.all, 'playerQueue'] as const,\n\tsleepTimer: () => [...orpheusQueryKeys.all, 'sleepTimerEndAt'] as const,\n\tallDownloads: () => [...orpheusQueryKeys.all, 'allDownloads'] as const,\n}\n\nqueryClient.setQueryDefaults(orpheusQueryKeys.all, {\n\tnetworkMode: 'always',\n\tgcTime: 0,\n\tstaleTime: 0,\n\tretry: false,\n})\n\nexport function useBatchDownloadStatus(ids: string[]) {\n\treturn useQuery({\n\t\tqueryKey: orpheusQueryKeys.batchDownloadStatus(ids),\n\t\tqueryFn: async () => {\n\t\t\treturn await Orpheus.getDownloadStatusByIds(ids)\n\t\t},\n\t\tstaleTime: 0,\n\t\tgcTime: 0,\n\t\tenabled: ids.length > 0,\n\t})\n}\n\nexport function useShuffleMode() {\n\treturn useQuery({\n\t\tqueryKey: orpheusQueryKeys.shuffleMode(),\n\t\tqueryFn: () => Orpheus.getShuffleMode(),\n\t\tgcTime: 0,\n\t\tstaleTime: 0,\n\t})\n}\n\nexport function useDownloadTasks() {\n\treturn useQuery({\n\t\tqueryKey: orpheusQueryKeys.downloadTasks(),\n\t\tqueryFn: async () => {\n\t\t\treturn await Orpheus.getUncompletedDownloadTasks()\n\t\t},\n\t\tstaleTime: 0,\n\t})\n}\n\nexport function useAllDownloads() {\n\treturn useQuery({\n\t\tqueryKey: orpheusQueryKeys.allDownloads(),\n\t\tqueryFn: async () => {\n\t\t\treturn await Orpheus.getDownloads()\n\t\t},\n\t\tstaleTime: 0,\n\t})\n}\n\nexport function usePlayerQueue(enabled: boolean = true) {\n\treturn useQuery<OrpheusTrack[]>({\n\t\tqueryKey: orpheusQueryKeys.playerQueue(),\n\t\tqueryFn: async () => {\n\t\t\tconst q = await Orpheus.getQueue()\n\t\t\treturn q\n\t\t},\n\t\tstaleTime: 0,\n\t\tenabled,\n\t\tgcTime: 0,\n\t})\n}\n\nexport function useSleepTimerEndTime() {\n\treturn useQuery({\n\t\tqueryFn: async () => {\n\t\t\treturn await Orpheus.getSleepTimerEndTime()\n\t\t},\n\t\tqueryKey: orpheusQueryKeys.sleepTimer(),\n\t\tgcTime: 0,\n\t\tstaleTime: 0,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/playHistory.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport dayjs from 'dayjs'\nimport { count, desc, sql } from 'drizzle-orm'\n\nimport drizzleDb from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { trackService } from '@/lib/services/trackService'\nimport type { Track } from '@/types/core/media'\n\nexport const playHistoryKeys = {\n\tall: ['playHistory'] as const,\n\theatmap: () => [...playHistoryKeys.all, 'heatmap'] as const,\n\tbyDate: (date: string) => [...playHistoryKeys.all, 'byDate', date] as const,\n\tbyDayOfMonth: (day: number) =>\n\t\t[...playHistoryKeys.all, 'byDayOfMonth', day] as const,\n\ttopPlayed: (days: number, limit: number) =>\n\t\t[...playHistoryKeys.all, 'topPlayed', days, limit] as const,\n}\n\nexport const usePlayHistoryHeatmap = () => {\n\treturn useQuery({\n\t\tqueryKey: playHistoryKeys.heatmap(),\n\t\tqueryFn: async () => {\n\t\t\tconst result = await drizzleDb\n\t\t\t\t.select({\n\t\t\t\t\tdate: sql<string>`date(\n                        CASE\n                            WHEN ${schema.playHistory.startTime} > 10000000000 THEN ${schema.playHistory.startTime} / 1000\n                            ELSE ${schema.playHistory.startTime}\n                        END,\n                        'unixepoch',\n                        'localtime'\n                    )`,\n\t\t\t\t\tcount: count(),\n\t\t\t\t})\n\t\t\t\t.from(schema.playHistory)\n\t\t\t\t.groupBy(\n\t\t\t\t\tsql`date(\n                        CASE\n                            WHEN ${schema.playHistory.startTime} > 10000000000 THEN ${schema.playHistory.startTime} / 1000\n                            ELSE ${schema.playHistory.startTime}\n                        END,\n                        'unixepoch',\n                        'localtime'\n                    )`,\n\t\t\t\t)\n\n\t\t\tconst data: Record<string, number> = {}\n\t\t\tresult.forEach((row) => {\n\t\t\t\tif (row.date) {\n\t\t\t\t\tdata[row.date] = row.count\n\t\t\t\t}\n\t\t\t})\n\t\t\treturn data\n\t\t},\n\t\tnetworkMode: 'always',\n\t\tstaleTime: 0,\n\t})\n}\n\nexport const usePlayHistoryByDate = (dateStr: string) => {\n\treturn useQuery({\n\t\tqueryKey: playHistoryKeys.byDate(dateStr),\n\t\tqueryFn: async () => {\n\t\t\tconst date = dayjs(dateStr)\n\t\t\tconst startTimeS = date.startOf('day').unix()\n\t\t\tconst endTimeS = date.endOf('day').unix()\n\n\t\t\tconst historyRows = await drizzleDb.query.playHistory.findMany({\n\t\t\t\twhere: (ph, { and, sql }) => {\n\t\t\t\t\treturn and(\n\t\t\t\t\t\tsql`${ph.startTime} >= ${startTimeS * 1000}`,\n\t\t\t\t\t\tsql`${ph.startTime} <= ${endTimeS * 1000}`,\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\twith: {\n\t\t\t\t\ttrack: {\n\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\torderBy: [desc(schema.playHistory.startTime)],\n\t\t\t})\n\n\t\t\t// 过滤掉没有 track 的异常数据，并转换类型\n\t\t\treturn historyRows\n\t\t\t\t.filter((row) => row.track !== null && row.track !== undefined)\n\t\t\t\t.map((row) => {\n\t\t\t\t\tconst track = row.track as unknown as Track\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...track,\n\t\t\t\t\t\thistoryId: row.id,\n\t\t\t\t\t\tplayedAt: row.startTime,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t},\n\t\tenabled: !!dateStr,\n\t\tnetworkMode: 'always',\n\t\tstaleTime: 0,\n\t})\n}\n\nexport const usePlayHistoryByDayOfMonth = (dayOfMonth: number) => {\n\treturn useQuery({\n\t\tqueryKey: playHistoryKeys.byDayOfMonth(dayOfMonth),\n\t\tqueryFn: async () => {\n\t\t\tconst historyRows = await drizzleDb.query.playHistory.findMany({\n\t\t\t\twhere: (ph, { sql }) => {\n\t\t\t\t\tconst dayOfMonthSql = sql`strftime('%d', ${ph.startTime} / 1000, 'unixepoch', 'localtime')`\n\t\t\t\t\treturn sql`${dayOfMonthSql} = ${String(dayOfMonth).padStart(2, '0')}`\n\t\t\t\t},\n\t\t\t\twith: {\n\t\t\t\t\ttrack: {\n\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\torderBy: [desc(schema.playHistory.startTime)],\n\t\t\t})\n\n\t\t\t// 过滤掉没有 track 的异常数据，并转换类型\n\t\t\treturn historyRows\n\t\t\t\t.filter((row) => row.track !== null && row.track !== undefined)\n\t\t\t\t.map((row) => {\n\t\t\t\t\tconst track = row.track as unknown as Track\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...track,\n\t\t\t\t\t\thistoryId: row.id,\n\t\t\t\t\t\tplayedAt: row.startTime,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t},\n\t\tenabled: !!dayOfMonth && dayOfMonth >= 1 && dayOfMonth <= 31,\n\t\tnetworkMode: 'always',\n\t\tstaleTime: 0,\n\t})\n}\n\nexport const useMostPlayedTracks = (days: number, limit: number) => {\n\treturn useQuery({\n\t\tqueryKey: playHistoryKeys.topPlayed(days, limit),\n\t\tqueryFn: async () => {\n\t\t\tconst result = await trackService.getMostPlayedTracksInLastDays({\n\t\t\t\tdays,\n\t\t\t\tlimit,\n\t\t\t})\n\t\t\tif (result.isErr()) {\n\t\t\t\tthrow result.error\n\t\t\t}\n\t\t\treturn result.value\n\t\t},\n\t\tenabled: true,\n\t\tnetworkMode: 'always',\n\t\tstaleTime: 60 * 1000,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/sharedPlaylistAllMembers.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\n\nimport { api } from '@/lib/api/bbplayer/client'\n\nexport type SharedPlaylistAllMember = {\n\tmid: number\n\tname: string\n\tavatarUrl?: string | null\n\trole: 'owner' | 'editor' | 'subscriber'\n\tjoinedAt: number\n}\n\nexport function useSharedPlaylistAllMembers(shareId?: string | null) {\n\treturn useQuery({\n\t\tqueryKey: ['sharedPlaylistAllMembers', shareId],\n\t\tqueryFn: async (): Promise<SharedPlaylistAllMember[]> => {\n\t\t\tif (!shareId) return []\n\t\t\tconst resp = await api.playlists[':id'].members.$get({\n\t\t\t\tparam: { id: shareId },\n\t\t\t})\n\t\t\tif (!resp.ok) {\n\t\t\t\tthrow new Error('Failed to fetch members')\n\t\t\t}\n\t\t\tconst data = await resp.json()\n\t\t\treturn data.members.map((m) => ({\n\t\t\t\tmid: m.mid,\n\t\t\t\tname: m.name,\n\t\t\t\tavatarUrl: m.avatar_url,\n\t\t\t\trole: m.role,\n\t\t\t\tjoinedAt: m.joined_at,\n\t\t\t}))\n\t\t},\n\t\tenabled: !!shareId,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/sharedPlaylistMembers.ts",
    "content": "import { useMemo } from 'react'\n\nimport {\n\tuseSharedPlaylistMembersStore,\n\ttype SharedPlaylistMember,\n} from '@/hooks/stores/useSharedPlaylistMembersStore'\n\nexport function useSharedPlaylistMembers(\n\tshareId?: string | null,\n): SharedPlaylistMember[] {\n\tconst members = useSharedPlaylistMembersStore((state) =>\n\t\tshareId ? state.membersByShareId[shareId] : undefined,\n\t)\n\treturn useMemo(() => members ?? [], [members])\n}\n\nexport type { SharedPlaylistMember }\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/sharedPlaylistPreview.ts",
    "content": "import { skipToken, useQuery } from '@tanstack/react-query'\n\nimport { sharedPlaylistFacade } from '@/lib/facades/sharedPlaylist'\nimport { returnOrThrowAsync } from '@/utils/neverthrow-utils'\n\nexport const sharedPlaylistPreviewKeys = {\n\tpreview: (shareId?: string) =>\n\t\t['bbplayer', 'sharedPlaylist', 'preview', shareId] as const,\n}\n\nexport const useSharedPlaylistPreview = (shareId?: string) => {\n\treturn useQuery({\n\t\tqueryKey: sharedPlaylistPreviewKeys.preview(shareId),\n\t\tqueryFn: shareId\n\t\t\t? () => returnOrThrowAsync(sharedPlaylistFacade.getPreview(shareId))\n\t\t\t: skipToken,\n\t\tenabled: !!shareId,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/queries/useRecentPlaylists.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { desc } from 'drizzle-orm'\n\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\n\nexport function useRecentPlaylists() {\n\treturn useQuery({\n\t\tqueryKey: ['recentPlaylists'],\n\t\tqueryFn: async () => {\n\t\t\treturn db\n\t\t\t\t.select({\n\t\t\t\t\tid: schema.playlists.id,\n\t\t\t\t\ttitle: schema.playlists.title,\n\t\t\t\t\tcoverUrl: schema.playlists.coverUrl,\n\t\t\t\t\ttype: schema.playlists.type,\n\t\t\t\t\titemCount: schema.playlists.itemCount,\n\t\t\t\t})\n\t\t\t\t.from(schema.playlists)\n\t\t\t\t.orderBy(desc(schema.playlists.updatedAt))\n\t\t\t\t.limit(6)\n\t\t},\n\t\tnetworkMode: 'always',\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/router/useBottomTabBarHeight.ts",
    "content": "import * as React from 'react'\nimport { BottomTabBarHeightContext } from 'react-native-bottom-tabs'\n\nexport function useBottomTabBarHeight() {\n\tconst height = React.useContext(BottomTabBarHeightContext)\n\n\tif (height === undefined) {\n\t\t// 说明这个页面并不是 tabs 页面，直接返回 0 就可以\n\t\treturn 0\n\t}\n\n\treturn height\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/router/usePreventRemove.ts",
    "content": "import type { EventArg, NavigationAction } from '@react-navigation/native'\nimport { useNavigation } from 'expo-router'\nimport { useEffect, useRef } from 'react'\n\nexport default function usePreventRemove(\n\tshouldPrevent: boolean,\n\tcallback: (\n\t\te: EventArg<\n\t\t\t'beforeRemove',\n\t\t\ttrue,\n\t\t\t{\n\t\t\t\taction: NavigationAction\n\t\t\t}\n\t\t>,\n\t) => void,\n) {\n\tconst navigation = useNavigation()\n\tconst callbackRef = useRef(callback)\n\tuseEffect(() => {\n\t\tcallbackRef.current = callback\n\t}, [callback])\n\n\tconst shouldPreventRef = useRef(shouldPrevent)\n\tuseEffect(() => {\n\t\tshouldPreventRef.current = shouldPrevent\n\t}, [shouldPrevent])\n\n\tuseEffect(() => {\n\t\tconst unsubscribe = navigation.addListener('beforeRemove', (e) => {\n\t\t\tif (shouldPreventRef.current) {\n\t\t\t\te.preventDefault()\n\t\t\t\tcallbackRef.current?.(e)\n\t\t\t}\n\t\t})\n\t\treturn unsubscribe\n\t}, [navigation])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/stores/useAppStore.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport * as parseCookie from 'cookie'\nimport * as Expo from 'expo'\nimport { err, ok, type Result } from 'neverthrow'\nimport { create } from 'zustand'\nimport { createJSONStorage, persist } from 'zustand/middleware'\nimport { immer } from 'zustand/middleware/immer'\n\nimport { alert } from '@/components/modals/AlertModal'\nimport { expoDb } from '@/lib/db/db'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport type { AppState, Settings } from '@/types/core/appStore'\nimport type { StorageKey } from '@/types/storage'\nimport log from '@/utils/log'\nimport { storage, zustandStorage } from '@/utils/mmkv'\n\nconst logger = log.extend('Store.App')\n\nimport toast from '@/utils/toast'\n\nexport const parseCookieToObject = (\n\tcookie?: string,\n): Result<Record<string, string>, Error> => {\n\tif (!cookie?.trim()) {\n\t\treturn ok({})\n\t}\n\ttry {\n\t\tconst cookieObj = parseCookie.parse(cookie)\n\t\tconst sanitizedObj: Record<string, string> = {}\n\t\tlet hasInvalidKeys = false\n\n\t\tfor (const [key, value] of Object.entries(cookieObj)) {\n\t\t\tif (value === undefined) {\n\t\t\t\treturn err(new Error(`无效的 cookie 字符串：值为 undefined：${value}`))\n\t\t\t}\n\t\t\tconst trimmedKey = key.trim()\n\t\t\tconst trimmedValue = value.trim()\n\n\t\t\tif (!trimmedKey) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif (trimmedKey !== key || trimmedValue !== value) {\n\t\t\t\thasInvalidKeys = true\n\t\t\t}\n\n\t\t\tsanitizedObj[trimmedKey] = trimmedValue\n\t\t}\n\n\t\tif (hasInvalidKeys) {\n\t\t\ttoast.error('检测到 Cookie 包含无效字符（如换行符），已自动修复')\n\t\t}\n\n\t\treturn ok(sanitizedObj)\n\t} catch (error) {\n\t\treturn err(\n\t\t\tnew Error(\n\t\t\t\t`无效的 cookie 字符串: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t),\n\t\t)\n\t}\n}\n\nexport const serializeCookieObject = (\n\tcookieObj: Record<string, string>,\n): string => {\n\treturn Object.entries(cookieObj)\n\t\t.map(([key, value]) => {\n\t\t\ttry {\n\t\t\t\treturn parseCookie.serialize(key, value)\n\t\t\t} catch {\n\t\t\t\ttry {\n\t\t\t\t\treturn parseCookie.serialize(key.trim(), value.trim())\n\t\t\t\t} catch {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\t.filter((item) => item !== null)\n\t\t.join('; ')\n}\n\nconst OLD_KEYS: Record<string, StorageKey> = {\n\tCOOKIE: 'bilibili_cookie',\n\tSEND_HISTORY: 'send_play_history',\n\tSENTRY: 'enable_sentry_report',\n\tDEBUG_LOG: 'enable_debug_log',\n\tOLD_LYRIC: 'enable_old_school_style_lyric',\n\tBG_STYLE: 'player_background_style',\n\tPERSIST_POSITION: 'enable_persist_current_position',\n}\n\nexport const useAppStore = create<AppState>()(\n\tpersist(\n\t\timmer((set, get) => {\n\t\t\treturn {\n\t\t\t\tbilibiliCookie: null,\n\t\t\t\tbbplayerToken: null,\n\t\t\t\tsettings: {\n\t\t\t\t\tsendPlayHistory: false,\n\t\t\t\t\tenableDebugLog: false,\n\t\t\t\t\tenableOldSchoolStyleLyric: false,\n\t\t\t\t\tenableSpectrumVisualizer: false,\n\t\t\t\t\tplayerBackgroundStyle: 'gradient',\n\t\t\t\t\tnowPlayingBarStyle: 'float',\n\t\t\t\t\tlyricSource: 'netease',\n\t\t\t\t\tenableVerbatimLyrics: true,\n\t\t\t\t\tenableDataCollection: true,\n\t\t\t\t\tenableDanmaku: false,\n\t\t\t\t\tdanmakuFilterLevel: 0,\n\t\t\t\t\tdownloadMaxParallelTasks: 1,\n\t\t\t\t},\n\t\t\t\tbilibiliUserInfo: null,\n\n\t\t\t\thasBilibiliCookie: () => {\n\t\t\t\t\tconst { bilibiliCookie } = get()\n\t\t\t\t\treturn !!bilibiliCookie && Object.keys(bilibiliCookie).length > 0\n\t\t\t\t},\n\n\t\t\t\tsetBilibiliCookie: (cookieString) => {\n\t\t\t\t\tconst result = parseCookieToObject(cookieString)\n\t\t\t\t\tif (result.isErr()) {\n\t\t\t\t\t\treturn err(result.error)\n\t\t\t\t\t}\n\n\t\t\t\t\tconst cookieObj = result.value\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.bilibiliCookie = cookieObj\n\t\t\t\t\t\tstate.bbplayerToken = null\n\t\t\t\t\t})\n\t\t\t\t\tOrpheus.setBilibiliCookie(serializeCookieObject(cookieObj))\n\t\t\t\t\tlogger.info('设置 cookie 到 orpheus')\n\n\t\t\t\t\treturn ok(undefined)\n\t\t\t\t},\n\n\t\t\t\tupdateBilibiliCookie: (updates) => {\n\t\t\t\t\tconst currentCookie = get().bilibiliCookie ?? {}\n\t\t\t\t\tconst newCookie = { ...currentCookie, ...updates }\n\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.bilibiliCookie = newCookie\n\t\t\t\t\t\tstate.bbplayerToken = null\n\t\t\t\t\t})\n\t\t\t\t\tOrpheus.setBilibiliCookie(serializeCookieObject(newCookie))\n\t\t\t\t\tlogger.info('更新 cookie 到 orpheus')\n\t\t\t\t\treturn ok(undefined)\n\t\t\t\t},\n\n\t\t\t\tclearBilibiliCookie: () => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.bilibiliCookie = null\n\t\t\t\t\t\tstate.bilibiliUserInfo = null\n\t\t\t\t\t\tstate.bbplayerToken = null\n\t\t\t\t\t})\n\t\t\t\t},\n\n\t\t\t\tsetBilibiliUserInfo: (info) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.bilibiliUserInfo = info\n\t\t\t\t\t})\n\t\t\t\t},\n\n\t\t\t\tsetBbplayerToken: (token) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.bbplayerToken = token\n\t\t\t\t\t})\n\t\t\t\t},\n\n\t\t\t\tclearBbplayerToken: () => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.bbplayerToken = null\n\t\t\t\t\t})\n\t\t\t\t},\n\n\t\t\t\tsetSettings: (updates) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tObject.assign(state.settings, updates)\n\t\t\t\t\t})\n\n\t\t\t\t\tif (updates.downloadMaxParallelTasks !== undefined) {\n\t\t\t\t\t\tvoid Orpheus.setDownloadMaxParallelTasks(\n\t\t\t\t\t\t\tupdates.downloadMaxParallelTasks,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t},\n\n\t\t\t\tsetEnableDataCollection: (value: boolean) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.settings.enableDataCollection = value\n\t\t\t\t\t})\n\t\t\t\t\tvoid analyticsService.setAnalyticsCollectionEnabled(value)\n\n\t\t\t\t\talert(\n\t\t\t\t\t\t'重启？',\n\t\t\t\t\t\t'切换隐私设置后，需要重启应用才能完全生效。',\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{ text: '取消' },\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: '确定',\n\t\t\t\t\t\t\t\tonPress: () => {\n\t\t\t\t\t\t\t\t\texpoDb.closeSync()\n\t\t\t\t\t\t\t\t\tvoid Expo.reloadAppAsync()\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\t{ cancelable: true },\n\t\t\t\t\t)\n\t\t\t\t},\n\n\t\t\t\tsetEnableDebugLog: (value) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.settings.enableDebugLog = value\n\t\t\t\t\t})\n\n\t\t\t\t\tlog.setSeverity(value ? 'debug' : 'info')\n\t\t\t\t},\n\t\t\t}\n\t\t}),\n\t\t{\n\t\t\tname: 'app-storage',\n\t\t\tstorage: createJSONStorage(() => zustandStorage),\n\t\t\tversion: 1,\n\n\t\t\tpartialize: (state) => ({\n\t\t\t\tbilibiliCookie: state.bilibiliCookie,\n\t\t\t\tbilibiliUserInfo: state.bilibiliUserInfo,\n\t\t\t\tbbplayerToken: state.bbplayerToken,\n\t\t\t\tsettings: state.settings,\n\t\t\t}),\n\n\t\t\tmerge: (persistedState, currentState) => {\n\t\t\t\tif (persistedState) {\n\t\t\t\t\tconst typedPersistedState = persistedState as AppState\n\n\t\t\t\t\t// @ts-expect-error -- handling migration of old keys\n\t\t\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-assignment\n\t\t\t\t\tconst oldSentry = typedPersistedState.settings.enableSentryReport\n\t\t\t\t\t// @ts-expect-error -- handling migration of old keys\n\t\t\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-assignment\n\t\t\t\t\tconst oldAnalytics = typedPersistedState.settings.enableAnalytics\n\n\t\t\t\t\tconst mergedState = {\n\t\t\t\t\t\t...currentState,\n\t\t\t\t\t\t...typedPersistedState,\n\t\t\t\t\t\tsettings: {\n\t\t\t\t\t\t\t...currentState.settings,\n\t\t\t\t\t\t\t...typedPersistedState.settings,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\n\t\t\t\t\tif (oldSentry === false || oldAnalytics === false) {\n\t\t\t\t\t\tmergedState.settings.enableDataCollection = false\n\t\t\t\t\t}\n\t\t\t\t\t// @ts-expect-error -- cleanup\n\t\t\t\t\tdelete mergedState.settings.enableSentryReport\n\t\t\t\t\t// @ts-expect-error -- cleanup\n\t\t\t\t\tdelete mergedState.settings.enableAnalytics\n\n\t\t\t\t\treturn mergedState\n\t\t\t\t}\n\n\t\t\t\t// Note: Migration logic is kept within merge to handle one-time transfer from old MMKV keys.\n\t\t\t\t// This runs only once when app-storage is missing.\n\t\t\t\tlogger.info('没找到 \"app-storage\" 存储项. 检查旧的 MMKV 键并尝试迁移')\n\t\t\t\tlet hasOldData = false\n\t\t\t\tconst migratedState = { ...currentState }\n\n\t\t\t\ttry {\n\t\t\t\t\tconst oldCookieStr = storage.getString('bilibili_cookie')\n\t\t\t\t\tif (oldCookieStr) {\n\t\t\t\t\t\tconst cookieResult = parseCookieToObject(oldCookieStr)\n\t\t\t\t\t\tif (cookieResult.isOk()) {\n\t\t\t\t\t\t\tmigratedState.bilibiliCookie = cookieResult.value\n\t\t\t\t\t\t\thasOldData = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tconst oldToken = storage.getString('bbplayer_jwt')\n\t\t\t\t\tif (oldToken) {\n\t\t\t\t\t\tmigratedState.bbplayerToken = oldToken\n\t\t\t\t\t\thasOldData = true\n\t\t\t\t\t\tstorage.remove('bbplayer_jwt')\n\t\t\t\t\t}\n\t\t\t\t} catch (e) {\n\t\t\t\t\tlogger.error('解析并迁移旧的 bilibili 数据失败', e)\n\t\t\t\t}\n\n\t\t\t\tconst migratedSettings = { ...currentState.settings }\n\t\t\t\tlet hasOldSettings = false\n\n\t\t\t\ttry {\n\t\t\t\t\tconst checkAndSet = (\n\t\t\t\t\t\tkey: StorageKey,\n\t\t\t\t\t\tsettingName: keyof Settings,\n\t\t\t\t\t\ttype: 'boolean' | 'string' | 'number',\n\t\t\t\t\t) => {\n\t\t\t\t\t\tlet value\n\t\t\t\t\t\tswitch (type) {\n\t\t\t\t\t\t\tcase 'boolean':\n\t\t\t\t\t\t\t\t// @ts-expect-error -- ts 无法理解这里\n\t\t\t\t\t\t\t\tvalue = storage.getBoolean(key)\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\tcase 'string':\n\t\t\t\t\t\t\t\t// @ts-expect-error -- ts 无法理解这里\n\t\t\t\t\t\t\t\tvalue = storage.getString(key)\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\tcase 'number':\n\t\t\t\t\t\t\t\t// @ts-expect-error -- ts 无法理解这里\n\t\t\t\t\t\t\t\tvalue = storage.getNumber(key)\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (value !== undefined && value !== null) {\n\t\t\t\t\t\t\t// @ts-expect-error -- ts 无法理解这里\n\t\t\t\t\t\t\tmigratedSettings[settingName] = value\n\t\t\t\t\t\t\thasOldSettings = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tcheckAndSet(OLD_KEYS.SEND_HISTORY, 'sendPlayHistory', 'boolean')\n\t\t\t\t\tcheckAndSet(OLD_KEYS.DEBUG_LOG, 'enableDebugLog', 'boolean')\n\t\t\t\t\tcheckAndSet(\n\t\t\t\t\t\tOLD_KEYS.OLD_LYRIC,\n\t\t\t\t\t\t'enableOldSchoolStyleLyric',\n\t\t\t\t\t\t'boolean',\n\t\t\t\t\t)\n\t\t\t\t\tcheckAndSet(OLD_KEYS.BG_STYLE, 'playerBackgroundStyle', 'string')\n\t\t\t\t} catch (e) {\n\t\t\t\t\tlogger.error('迁移设置项失败', e)\n\t\t\t\t}\n\n\t\t\t\tif (hasOldSettings) {\n\t\t\t\t\tmigratedState.settings = migratedSettings\n\t\t\t\t\thasOldData = true\n\t\t\t\t}\n\n\t\t\t\tif (!hasOldData) {\n\t\t\t\t\tlogger.info('没有旧数据，使用默认值')\n\t\t\t\t\treturn currentState\n\t\t\t\t}\n\n\t\t\t\tlogger.info('迁移旧数据成功！')\n\t\t\t\treturn migratedState\n\t\t\t},\n\t\t},\n\t),\n)\n\nexport default useAppStore\n"
  },
  {
    "path": "apps/mobile/src/hooks/stores/useDownloadManagerStore.ts",
    "content": "import type { DownloadState } from '@bbplayer/orpheus'\nimport { Orpheus } from '@bbplayer/orpheus'\n\nimport createStickyEmitter from '@/utils/sticky-mitt'\n\nexport type ProgressEvent = Record<\n\t`progress:${string}`,\n\t{\n\t\tcurrent: number\n\t\ttotal: number\n\t\tpercent: number\n\t\tstate: DownloadState\n\t}\n>\nexport const eventListner = createStickyEmitter<ProgressEvent>()\n\n// Dispatch event to each task\nOrpheus.addListener('onDownloadUpdated', (event) => {\n\tconst eventKey = `progress:${event.id}` as const\n\teventListner.emit(eventKey, {\n\t\tcurrent: event.bytesDownloaded,\n\t\ttotal: event.contentLength,\n\t\tpercent: event.percentDownloaded,\n\t\tstate: event.state,\n\t})\n})\n"
  },
  {
    "path": "apps/mobile/src/hooks/stores/useExternalPlaylistSyncStore.tsx",
    "content": "import { createContext, use, useRef } from 'react'\nimport { createStore, useStore } from 'zustand'\n\nimport type { MatchResult } from '@/lib/services/externalPlaylistService'\n\ninterface SyncState {\n\tresults: Record<number, MatchResult>\n\tprogress: number\n\tsyncing: boolean\n\tsetSyncing: (syncing: boolean) => void\n\tsetResult: (index: number, result: MatchResult) => void\n\tsetProgress: (current: number, total: number) => void\n\treset: () => void\n}\n\ntype SyncStore = ReturnType<typeof createExternalPlaylistSyncStore>\n\nconst createExternalPlaylistSyncStore = () => {\n\treturn createStore<SyncState>((set) => ({\n\t\tresults: {},\n\t\tprogress: 0,\n\t\tsyncing: false,\n\t\tsetSyncing: (syncing) => set({ syncing }),\n\t\tsetResult: (index, result) =>\n\t\t\tset((state) => ({ results: { ...state.results, [index]: result } })),\n\t\tsetProgress: (current, total) => set({ progress: current / total }),\n\t\treset: () => set({ results: {}, progress: 0, syncing: false }),\n\t}))\n}\n\nconst ExternalPlaylistSyncStoreContext = createContext<SyncStore | null>(null)\n\nexport const ExternalPlaylistSyncStoreProvider = ({\n\tchildren,\n}: {\n\tchildren: React.ReactNode\n}) => {\n\tconst storeRef = useRef<SyncStore | null>(null)\n\tif (!storeRef.current) {\n\t\tstoreRef.current = createExternalPlaylistSyncStore()\n\t}\n\treturn (\n\t\t<ExternalPlaylistSyncStoreContext.Provider value={storeRef.current}>\n\t\t\t{children}\n\t\t</ExternalPlaylistSyncStoreContext.Provider>\n\t)\n}\n\nexport type { SyncStore }\n\nexport function useExternalPlaylistSyncStoreApi() {\n\tconst store = use(ExternalPlaylistSyncStoreContext)\n\tif (!store) {\n\t\tthrow new Error(\n\t\t\t'useExternalPlaylistSyncStoreApi must be used within ExternalPlaylistSyncStoreProvider',\n\t\t)\n\t}\n\treturn store\n}\n\nexport function useExternalPlaylistSyncStore<T>(\n\tselector: (state: SyncState) => T,\n): T {\n\tconst store = useExternalPlaylistSyncStoreApi()\n\treturn useStore(store, selector)\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/stores/useModalStore.ts",
    "content": "import { router } from 'expo-router'\nimport type { Emitter } from 'mitt'\nimport mitt from 'mitt'\nimport { create } from 'zustand'\nimport { immer } from 'zustand/middleware/immer'\n\nimport type { ModalInstance, ModalKey, ModalPropsMap } from '@/types/navigation'\nimport toast from '@/utils/toast'\n\ninterface ModalState {\n\tmodals: ModalInstance[]\n\teventEmitter: Emitter<{ modalHostDidClose: undefined }>\n\n\topen: <K extends ModalKey>(\n\t\tkey: K,\n\t\tprops: ModalPropsMap[K],\n\t\toptions?: ModalInstance['options'],\n\t) => void\n\t/**\n\t * 如果需要在 close 时进行跳转到其他页面的操作，**必须**将 navigation.navigate 调用放在 doAfterModalHostClosed 回调中执行\n\t * @param key modal 的 key\n\t * @returns\n\t */\n\tclose: (key: ModalKey) => void\n\tcloseAll: () => void\n\tcloseTop: () => void\n\tdoAfterModalHostClosed: (callback: () => void) => void\n}\n\nexport const useModalStore = create<ModalState>()(\n\timmer((set, get) => ({\n\t\tmodals: [],\n\t\teventEmitter: mitt<{ modalHostDidClose: undefined }>(),\n\n\t\topen: (key, props, options) => {\n\t\t\tconst exists = get().modals.some((m) => m.key === key)\n\n\t\t\tif (exists) {\n\t\t\t\ttoast.error(`已经打开 ${key} 了`)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tset((state) => ({\n\t\t\t\tmodals: [...state.modals, { key, props, options }],\n\t\t\t}))\n\n\t\t\trouter.navigate('/modal')\n\t\t},\n\n\t\tclose: (key) => {\n\t\t\tset((state) => ({ modals: state.modals.filter((m) => m.key !== key) }))\n\t\t},\n\n\t\tcloseAll: () => {\n\t\t\tset({ modals: [] })\n\t\t},\n\n\t\tcloseTop: () => {\n\t\t\tconst topOne = get().modals[get().modals.length - 1]\n\t\t\tif (topOne) {\n\t\t\t\tget().close(topOne.key)\n\t\t\t}\n\t\t},\n\n\t\tdoAfterModalHostClosed: (callback) => {\n\t\t\tconst wrapper = () => {\n\t\t\t\tget().eventEmitter.off('modalHostDidClose', wrapper)\n\t\t\t\tcallback()\n\t\t\t}\n\t\t\tget().eventEmitter.on('modalHostDidClose', wrapper)\n\t\t},\n\t})),\n)\n\nexport const openModal = useModalStore.getState().open\n"
  },
  {
    "path": "apps/mobile/src/hooks/stores/usePlayerStore.ts",
    "content": "import { Orpheus, type Track as OrpheusTrack } from '@bbplayer/orpheus'\nimport { create } from 'zustand'\n\nimport { trackService } from '@/lib/services/trackService'\nimport type { Track } from '@/types/core/media'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport log from '@/utils/log'\n\nconst logger = log.extend('Store.Player')\n\ninterface PlayerState {\n\torpheusTrack: OrpheusTrack | null\n\tinternalTrack: Track | null\n\tcurrentIndex: number\n\n\tinitialize: () => void\n\tsync: () => Promise<void>\n}\n\nexport const usePlayerStore = create<PlayerState>((set, get) => ({\n\torpheusTrack: null,\n\tinternalTrack: null,\n\tcurrentIndex: -1,\n\n\tinitialize: () => {\n\t\tvoid get().sync()\n\n\t\tOrpheus.addListener('onTrackStarted', async () => {\n\t\t\tawait get().sync()\n\t\t})\n\t},\n\n\tsync: async () => {\n\t\ttry {\n\t\t\tconst [currentTrack, currentIndex] = await Promise.all([\n\t\t\t\tOrpheus.getCurrentTrack(),\n\t\t\t\tOrpheus.getCurrentIndex(),\n\t\t\t])\n\n\t\t\tconst currentInternalTrackId = get().internalTrack?.uniqueKey\n\t\t\tconst newTrackId = currentTrack?.id\n\n\t\t\tset({ orpheusTrack: currentTrack, currentIndex })\n\n\t\t\tif (!currentTrack) {\n\t\t\t\tset({ internalTrack: null })\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (newTrackId !== currentInternalTrackId) {\n\t\t\t\tconst result = await trackService.getTrackByUniqueKey(currentTrack.id)\n\n\t\t\t\tif (get().orpheusTrack?.id !== newTrackId) return\n\n\t\t\t\tif (result.isErr()) {\n\t\t\t\t\tset({ internalTrack: null })\n\t\t\t\t\ttoastAndLogError('读取当前曲目信息失败', result.error, 'Store.Player')\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tset({ internalTrack: result.value })\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tlogger.warning('Failed to sync player state', { error: e })\n\t\t}\n\t},\n}))\n\nexport default usePlayerStore\n"
  },
  {
    "path": "apps/mobile/src/hooks/stores/useSharedPlaylistMembersStore.ts",
    "content": "import { create } from 'zustand'\nimport { createJSONStorage, persist } from 'zustand/middleware'\nimport { immer } from 'zustand/middleware/immer'\n\nimport { zustandStorage } from '@/utils/mmkv'\n\nexport type SharedPlaylistMember = {\n\tmid: number\n\tname: string\n\tavatarUrl?: string | null\n\trole: 'owner' | 'editor'\n}\n\nconst EMPTY: SharedPlaylistMember[] = []\n\ninterface SharedPlaylistMembersState {\n\tmembersByShareId: Record<string, SharedPlaylistMember[]>\n\tsetMembers: (shareId: string, members: SharedPlaylistMember[]) => void\n\tclearMembers: (shareId: string) => void\n}\n\nexport const useSharedPlaylistMembersStore =\n\tcreate<SharedPlaylistMembersState>()(\n\t\tpersist(\n\t\t\timmer((set) => ({\n\t\t\t\tmembersByShareId: {},\n\t\t\t\tsetMembers: (shareId, members) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tstate.membersByShareId[shareId] = members\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t\tclearMembers: (shareId) => {\n\t\t\t\t\tset((state) => {\n\t\t\t\t\t\tdelete state.membersByShareId[shareId]\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t})),\n\t\t\t{\n\t\t\t\tname: 'shared-playlist-members',\n\t\t\t\tstorage: createJSONStorage(() => zustandStorage),\n\t\t\t},\n\t\t),\n\t)\n\nexport const getSharedPlaylistMembers = (\n\tshareId: string | null | undefined,\n): SharedPlaylistMember[] => {\n\tif (!shareId) return EMPTY\n\treturn (\n\t\tuseSharedPlaylistMembersStore.getState().membersByShareId[shareId] ?? EMPTY\n\t)\n}\n\nexport const setSharedPlaylistMembers = (\n\tshareId: string,\n\tmembers: SharedPlaylistMember[],\n): void => {\n\tuseSharedPlaylistMembersStore.getState().setMembers(shareId, members)\n}\n\nexport const clearSharedPlaylistMembers = (\n\tshareId: string | null | undefined,\n): void => {\n\tif (!shareId) return\n\tuseSharedPlaylistMembersStore.getState().clearMembers(shareId)\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/ui/useDoubleTapScrollToTop.ts",
    "content": "import type { FlashListRef } from '@shopify/flash-list'\nimport type { RefObject } from 'react'\nimport { useCallback, useRef } from 'react'\nimport type { GestureResponderEvent } from 'react-native'\n\nexport function useDoubleTapScrollToTop<T>(\n\tpassedRef?: RefObject<FlashListRef<T> | null>,\n) {\n\tconst localRef = useRef<FlashListRef<T>>(null)\n\tconst listRef = passedRef ?? localRef\n\n\tconst lastTapRef = useRef<number>(0)\n\n\tconst handleDoubleTap = useCallback(\n\t\t(_e: GestureResponderEvent) => {\n\t\t\tconst now = Date.now()\n\t\t\tconst DOUBLE_TAP_DELAY = 300\n\t\t\tif (now - lastTapRef.current < DOUBLE_TAP_DELAY) {\n\t\t\t\tlistRef.current?.scrollToOffset({ offset: 0, animated: true })\n\t\t\t\tlastTapRef.current = 0\n\t\t\t} else {\n\t\t\t\tlastTapRef.current = now\n\t\t\t}\n\t\t},\n\t\t[listRef],\n\t)\n\n\treturn {\n\t\tlistRef,\n\t\thandleDoubleTap,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/ui/usePlaylistBackgroundColor.ts",
    "content": "import type { ExtractedPalette } from '@bbplayer/image-theme-colors'\nimport ImageThemeColors from '@bbplayer/image-theme-colors'\nimport type { ImageRef } from 'expo-image'\nimport { useEffect, useMemo, useState } from 'react'\nimport { AppState } from 'react-native'\n\nimport { hexToHsl, hslToString } from '@/utils/color'\nimport { reportErrorToSentry } from '@/utils/log'\n\nfunction getDominantColor(\n\tpalette: ExtractedPalette | undefined,\n\tisDarkMode: boolean,\n): string | undefined {\n\tif (!palette) return undefined\n\tif (isDarkMode) {\n\t\treturn palette.darkMuted?.hex ?? palette.muted?.hex\n\t} else {\n\t\treturn palette.lightMuted?.hex ?? palette.muted?.hex\n\t}\n}\n\nfunction computeLightenedColor(\n\thexColor: string | undefined,\n\tlightenAmount = 10,\n): string | undefined {\n\tif (!hexColor) return undefined\n\n\tconst hsl = hexToHsl(hexColor)\n\tconst newLightness = Math.min(hsl.l + lightenAmount, 100)\n\treturn hslToString(hsl.h, hsl.s, newLightness)\n}\n\nexport interface PlaylistBackgroundColorResult {\n\tbackgroundColor: string\n\tnowPlayingBarColor: string | undefined\n}\n\n/**\n * 供播放列表使用，根据封面提取主题色和对应的 NowPlayingBar 颜色\n */\nexport function usePlaylistBackgroundColor(\n\timageRef: ImageRef | null | undefined,\n\tisDarkMode: boolean,\n\tfallbackColor: string,\n): PlaylistBackgroundColorResult {\n\tconst [palette, setPalette] = useState<ExtractedPalette | undefined>(\n\t\tundefined,\n\t)\n\tconst [appState, setAppState] = useState(AppState.currentState)\n\n\tuseEffect(() => {\n\t\tconst subscription = AppState.addEventListener('change', (nextAppState) => {\n\t\t\tsetAppState(nextAppState)\n\t\t})\n\t\treturn () => {\n\t\t\tsubscription.remove()\n\t\t}\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (!imageRef) {\n\t\t\tsetPalette(undefined)\n\t\t\treturn\n\t\t}\n\n\t\tif (appState !== 'active') {\n\t\t\treturn\n\t\t}\n\n\t\tlet isCancelled = false\n\n\t\tconst extract = async () => {\n\t\t\ttry {\n\t\t\t\tconst result = await ImageThemeColors.extractThemeColorAsync(imageRef)\n\t\t\t\tif (!isCancelled) {\n\t\t\t\t\tsetPalette(result ?? undefined)\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tif (!isCancelled) {\n\t\t\t\t\treportErrorToSentry(e, '提取图片主题色失败', 'Hooks.useImageColor')\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tvoid extract()\n\n\t\treturn () => {\n\t\t\tisCancelled = true\n\t\t}\n\t}, [imageRef, appState])\n\n\tconst result = useMemo<PlaylistBackgroundColorResult>(() => {\n\t\tconst dominantColor = getDominantColor(palette, isDarkMode)\n\t\tconst backgroundColor = dominantColor ?? fallbackColor\n\n\t\tconst nowPlayingBarColor = isDarkMode\n\t\t\t? computeLightenedColor(dominantColor)\n\t\t\t: computeLightenedColor(dominantColor, -10)\n\n\t\treturn {\n\t\t\tbackgroundColor,\n\t\t\tnowPlayingBarColor,\n\t\t}\n\t}, [palette, isDarkMode, fallbackColor])\n\n\treturn result\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/ui/useScreenDimensions.ts",
    "content": "import { useEffect, useState } from 'react'\nimport { Dimensions, type ScaledSize } from 'react-native'\n\nexport function useScreenDimensions() {\n\tconst [dimensions, setDimensions] = useState(() => Dimensions.get('screen'))\n\n\tuseEffect(() => {\n\t\tconst subscription = Dimensions.addEventListener(\n\t\t\t'change',\n\t\t\t({ screen }: { screen: ScaledSize }) => {\n\t\t\t\tsetDimensions(screen)\n\t\t\t},\n\t\t)\n\n\t\treturn () => subscription.remove()\n\t}, [])\n\n\treturn dimensions\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/utils/useDebouncedValue.ts",
    "content": "import { useEffect, useRef, useState } from 'react'\n\nexport function useDebouncedValue<T>(value: T, delay = 300) {\n\tconst [debounced, setDebounced] = useState(value)\n\tconst timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n\tuseEffect(() => {\n\t\tif (timerRef.current) clearTimeout(timerRef.current)\n\t\ttimerRef.current = setTimeout(() => {\n\t\t\tsetDebounced(value)\n\t\t}, delay)\n\t\treturn () => {\n\t\t\tif (timerRef.current) clearTimeout(timerRef.current)\n\t\t}\n\t}, [value, delay])\n\n\treturn debounced\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/utils/useIsActuallyOffline.ts",
    "content": "import { useNetInfo } from '@react-native-community/netinfo'\nimport { useMemo } from 'react'\n\nimport { isActuallyOffline } from '@/utils/network'\n\n/**\n * 一个增强版的网络离线状态 Hook。\n * 解决了 NetInfo 在 VPN 连接下 isConnected 判定不准确的问题。\n */\nexport const useIsActuallyOffline = () => {\n\tconst state = useNetInfo()\n\n\treturn useMemo(() => isActuallyOffline(state), [state])\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/utils/usePreviousState.ts",
    "content": "import { useEffect, useRef } from 'react'\n\nexport default function usePreviousState<T>(value: T) {\n\tconst ref = useRef<T>(value)\n\n\tuseEffect(() => {\n\t\tref.current = value\n\t}, [value])\n\n\treturn ref.current\n}\n"
  },
  {
    "path": "apps/mobile/src/hooks/utils/useRefreshOnFocus.ts",
    "content": "import { useFocusEffect } from 'expo-router'\nimport { useCallback, useRef } from 'react'\n\nexport function useRefreshOnFocus<T>(refetch: () => Promise<T>) {\n\tconst firstTimeRef = useRef(true)\n\n\tuseFocusEffect(\n\t\tuseCallback(() => {\n\t\t\tif (firstTimeRef.current) {\n\t\t\t\tfirstTimeRef.current = false\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvoid refetch()\n\t\t}, [refetch]),\n\t)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bbplayer/client.ts",
    "content": "import type { AppType } from '@bbplayer/backend'\nimport { hc } from 'hono/client'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\n\nconst BASE_URL =\n\tprocess.env.EXPO_PUBLIC_BBPLAYER_API_URL ?? 'https://be.bbplayer.roitium.com'\n\nexport const api = hc<AppType>(BASE_URL, {\n\theaders: () => {\n\t\tconst token = useAppStore.getState().bbplayerToken\n\t\tconst headers: Record<string, string> = {}\n\t\tif (token) headers['Authorization'] = `Bearer ${token}`\n\t\treturn headers\n\t},\n})\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/api.ts",
    "content": "import { errAsync, okAsync, ResultAsync } from 'neverthrow'\n\nimport { useAppStore } from '@/hooks/stores/useAppStore'\nimport { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\nimport type {\n\tBilibiliCaptchaTokenData,\n\tBilibiliCommentsResponse,\n\tBilibiliDanmakuItem,\n\tBilibiliReplyCommentsResponse,\n\tBilibiliSearchSuggestionItem,\n\tBilibiliSmsLoginData,\n\tBilibiliSmsSendData,\n\tBilibiliToViewVideoList,\n\tBilibiliWebPlayerInfo,\n} from '@/types/apis/bilibili'\nimport {\n\ttype BilibiliAudioStreamParams,\n\ttype BilibiliAudioStreamResponse,\n\ttype BilibiliCollection,\n\ttype BilibiliCollectionAllContents,\n\ttype BilibiliDealFavoriteForOneVideoResponse,\n\ttype BilibiliFavoriteListAllContents,\n\ttype BilibiliFavoriteListContents,\n\ttype BilibiliHistoryVideo,\n\ttype BilibiliHotSearch,\n\ttype BilibiliMultipageVideo,\n\ttype BilibiliPlaylist,\n\tBilibiliQrCodeLoginStatus,\n\ttype BilibiliSearchVideo,\n\ttype BilibiliUserInfo,\n\ttype BilibiliUserUploadedVideosResponse,\n\ttype BilibiliVideoDetails,\n} from '@/types/apis/bilibili'\nimport type { BilibiliTrack } from '@/types/core/media'\nimport log from '@/utils/log'\n\nimport { bilibiliApiClient } from './client'\nimport { bilibili } from './proto/dm'\nimport { bv2av } from './utils'\nimport getWbiEncodedParams from './wbi'\n\nconst logger = log.extend('3Party.Bilibili.Api')\n\n/**\n * Bilibili passport API 请求所使用的 User-Agent\n */\nconst PASSPORT_UA =\n\t'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 BiliApp/6.66.0'\n\n/**\n * B站 API 客户端类\n */\nexport class BilibiliApi {\n\t/**\n\t * 获取用户观看历史记录\n\t */\n\tgetHistory(): ResultAsync<BilibiliHistoryVideo[], BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliHistoryVideo[]>(\n\t\t\t'/x/v2/history',\n\t\t\tundefined,\n\t\t)\n\t}\n\n\t/**\n\t * 获取分区热门视频\n\t */\n\tgetPopularVideos(\n\t\tpartition: string,\n\t): ResultAsync<BilibiliVideoDetails[], BilibiliApiError> {\n\t\treturn bilibiliApiClient\n\t\t\t.get<{\n\t\t\t\tlist: BilibiliVideoDetails[]\n\t\t\t} | null>(`/x/web-interface/ranking/v2?rid=${partition}`, undefined)\n\t\t\t.map((response) => response?.list ?? [])\n\t}\n\n\t/**\n\t * 获取用户收藏夹列表\n\t */\n\tgetFavoritePlaylists(\n\t\tuserMid: number,\n\t): ResultAsync<BilibiliPlaylist[], BilibiliApiError> {\n\t\treturn bilibiliApiClient\n\t\t\t.get<{\n\t\t\t\tlist: BilibiliPlaylist[] | null\n\t\t\t} | null>(\n\t\t\t\t`/x/v3/fav/folder/created/list-all?up_mid=${userMid}`,\n\t\t\t\tundefined,\n\t\t\t)\n\t\t\t.map((response) => response?.list ?? [])\n\t}\n\n\t/**\n\t * 创建收藏夹\n\t */\n\tcreateFavoriteFolder(\n\t\ttitle: string,\n\t\tintro?: string,\n\t\tcover?: string,\n\t\tprivacy: 0 | 1 = 0, // 0: public, 1: private\n\t): ResultAsync<\n\t\t{ id: number; title: string; mid: number; fid: number },\n\t\tBilibiliApiError\n\t> {\n\t\treturn bilibiliApiClient.postWithCsrf<{\n\t\t\tid: number\n\t\t\tfid: number\n\t\t\tmid: number\n\t\t\ttitle: string\n\t\t}>('/x/v3/fav/folder/add', {\n\t\t\ttitle,\n\t\t\tintro: intro ?? '',\n\t\t\tprivacy: String(privacy),\n\t\t\tcover: cover ?? '',\n\t\t})\n\t}\n\n\t/**\n\t * 获取分段弹幕\n\t * @param bvid 视频 BV 号\n\t * @param cid 视频 CID\n\t * @param segment_index 分段索引（6min 一段，从 1 开始）\n\t */\n\tgetSegDanmaku(\n\t\tbvid: string,\n\t\tcid: number,\n\t\tsegment_index: number,\n\t): ResultAsync<BilibiliDanmakuItem[], BilibiliApiError> {\n\t\tconst params = getWbiEncodedParams({\n\t\t\ttype: 1,\n\t\t\toid: cid,\n\t\t\tsegment_index: segment_index,\n\t\t\tpid: bv2av(bvid),\n\t\t})\n\n\t\treturn params\n\t\t\t.andThen((params) => {\n\t\t\t\treturn bilibiliApiClient.getBuffer('/x/v2/dm/wbi/web/seg.so', params)\n\t\t\t})\n\t\t\t.andThen((buffer) => {\n\t\t\t\ttry {\n\t\t\t\t\tconst data = new Uint8Array(buffer)\n\t\t\t\t\tconst decoded =\n\t\t\t\t\t\tbilibili.community.service.dm.v1.DmSegMobileReply.decode(data)\n\t\t\t\t\tconst filtered = decoded.elems.filter((elem) => {\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\telem.progress !== undefined &&\n\t\t\t\t\t\t\telem.progress !== null &&\n\t\t\t\t\t\t\telem.id !== undefined &&\n\t\t\t\t\t\t\telem.id !== null &&\n\t\t\t\t\t\t\telem.content !== undefined &&\n\t\t\t\t\t\t\telem.content !== null &&\n\t\t\t\t\t\t\telem.mode !== undefined &&\n\t\t\t\t\t\t\telem.mode !== null\n\t\t\t\t\t\t)\n\t\t\t\t\t}) as (Omit<BilibiliDanmakuItem, 'progress'> & {\n\t\t\t\t\t\tprogress: number | Long\n\t\t\t\t\t})[]\n\t\t\t\t\t// oxlint-disable-next-line oxc/no-map-spread -- 如果修改为 Object.assign 会导致 worklets 报错？\n\t\t\t\t\tconst mapped = filtered.map((elem) => ({\n\t\t\t\t\t\t...elem,\n\t\t\t\t\t\tprogress:\n\t\t\t\t\t\t\ttypeof elem.progress === 'number'\n\t\t\t\t\t\t\t\t? elem.progress\n\t\t\t\t\t\t\t\t: elem.progress.toNumber(),\n\t\t\t\t\t}))\n\t\t\t\t\treturn okAsync(mapped)\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// TODO: 有可能返回的是 json，需要解析并且给出详细的错误信息\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: `弹幕解包失败: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t\t\tcause: error,\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t})\n\t}\n\n\t/**\n\t * 搜索视频\n\t * keyword: string,\n\t * page: number,\n\t * options?: { skipCookie?: boolean },\n\t */\n\tsearchVideos(\n\t\tkeyword: string,\n\t\tpage: number,\n\t\toptions?: { skipCookie?: boolean },\n\t): ResultAsync<\n\t\t{ result: BilibiliSearchVideo[]; numPages: number },\n\t\tBilibiliApiError\n\t> {\n\t\tconst params = getWbiEncodedParams({\n\t\t\tkeyword,\n\t\t\tsearch_type: 'video',\n\t\t\tpage: page.toString(),\n\t\t})\n\n\t\treturn params\n\t\t\t.andThen((params) => {\n\t\t\t\treturn bilibiliApiClient.get<{\n\t\t\t\t\tresult: BilibiliSearchVideo[]\n\t\t\t\t\tnumPages: number\n\t\t\t\t}>(\n\t\t\t\t\t'/x/web-interface/wbi/search/type',\n\t\t\t\t\tparams,\n\t\t\t\t\tundefined,\n\t\t\t\t\toptions?.skipCookie,\n\t\t\t\t)\n\t\t\t})\n\t\t\t.andThen((res) => {\n\t\t\t\tif (!res.result) {\n\t\t\t\t\tres.result = []\n\t\t\t\t}\n\t\t\t\treturn okAsync(res)\n\t\t\t})\n\t}\n\n\t/**\n\t * 获取热门搜索关键词\n\t */\n\tgetHotSearches(): ResultAsync<BilibiliHotSearch[], BilibiliApiError> {\n\t\treturn bilibiliApiClient\n\t\t\t.get<{\n\t\t\t\ttrending: { list: BilibiliHotSearch[] }\n\t\t\t} | null>('/x/web-interface/search/square', {\n\t\t\t\tlimit: '10',\n\t\t\t})\n\t\t\t.map((response) => response?.trending.list ?? [])\n\t}\n\n\t/**\n\t * 获取搜索建议\n\t */\n\tgetSearchSuggestions(\n\t\tterm: string,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<BilibiliSearchSuggestionItem[], BilibiliApiError> {\n\t\tconst params = new URLSearchParams()\n\t\tparams.append('main_ver', 'v1')\n\t\tparams.append('term', term)\n\t\tconst bilibiliCookie = useAppStore.getState().bilibiliCookie\n\t\tif (bilibiliCookie?.mid) {\n\t\t\tparams.append('userid', bilibiliCookie.mid)\n\t\t}\n\t\tconst url = `https://s.search.bilibili.com/main/suggest?${params.toString()}`\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(url, {\n\t\t\t\tmethod: 'GET',\n\t\t\t\tsignal: signal,\n\t\t\t}),\n\t\t\t(e) => {\n\t\t\t\tif (e instanceof Error && e.name === 'AbortError') {\n\t\t\t\t\treturn new BilibiliApiError({\n\t\t\t\t\t\tmessage: '请求被取消',\n\t\t\t\t\t\ttype: 'RequestAborted',\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn new BilibiliApiError({\n\t\t\t\t\tmessage: e instanceof Error ? e.message : String(e),\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t})\n\t\t\t},\n\t\t)\n\t\t\t.andThen((response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: `请求 bilibili API 失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\t\tresponse.json() as Promise<{\n\t\t\t\t\t\tcode: number\n\t\t\t\t\t\tresult: { tag: BilibiliSearchSuggestionItem[] }\n\t\t\t\t\t}>,\n\t\t\t\t\t(error) =>\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t})\n\t\t\t.andThen((data) => {\n\t\t\t\tif (data.code !== 0) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: `获取搜索建议失败: ${data.code}`,\n\t\t\t\t\t\t\tmsgCode: data.code,\n\t\t\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn okAsync(data.result.tag)\n\t\t\t})\n\t}\n\n\t/**\n\t * 获取视频音频流信息\n\t * 优先级（在 dolby 和 hi-res 都开启的情况下）：dolby > hi-res > normal\n\t */\n\tgetAudioStream(\n\t\tparams: BilibiliAudioStreamParams,\n\t): ResultAsync<\n\t\tExclude<BilibiliTrack['bilibiliMetadata']['bilibiliStreamUrl'], undefined>,\n\t\tBilibiliApiError\n\t> {\n\t\tconst { bvid, cid, audioQuality, enableDolby, enableHiRes } = params\n\t\tconst wbiParams = getWbiEncodedParams({\n\t\t\tbvid,\n\t\t\tcid: String(cid),\n\t\t\tfnval: '4048',\n\t\t\tfnver: '0',\n\t\t\tfourk: '1',\n\t\t\tqlt: String(audioQuality),\n\t\t\tvoice_balance: '1',\n\t\t})\n\n\t\treturn wbiParams\n\t\t\t.andThen((params) => {\n\t\t\t\treturn bilibiliApiClient.get<BilibiliAudioStreamResponse>(\n\t\t\t\t\t'/x/player/wbi/playurl',\n\t\t\t\t\tparams,\n\t\t\t\t)\n\t\t\t})\n\t\t\t.andThen((response) => {\n\t\t\t\tconst { dash, durl, volume } = response\n\n\t\t\t\tif (!dash) {\n\t\t\t\t\tif (!durl?.[0]) {\n\t\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\t\tmessage: '请求到的流数据不包含 dash 或 durl 任一字段',\n\t\t\t\t\t\t\t\ttype: 'AudioStreamError',\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug('老视频不存在 dash，回退到使用 durl 音频流')\n\t\t\t\t\treturn okAsync({\n\t\t\t\t\t\turl: durl[0].url,\n\t\t\t\t\t\tquality: 114514,\n\t\t\t\t\t\tgetTime: Date.now() + 60 * 1000, // Add 60s buffer\n\t\t\t\t\t\ttype: 'mp4' as const,\n\t\t\t\t\t\tvolume,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tif (enableDolby && dash?.dolby?.audio && dash.dolby.audio.length > 0) {\n\t\t\t\t\tlogger.debug('优先使用 Dolby 音频流')\n\t\t\t\t\treturn okAsync({\n\t\t\t\t\t\turl: dash.dolby.audio[0].baseUrl,\n\t\t\t\t\t\tquality: dash.dolby.audio[0].id,\n\t\t\t\t\t\tgetTime: Date.now() + 60 * 1000, // Add 60s buffer\n\t\t\t\t\t\ttype: 'dash' as const,\n\t\t\t\t\t\tvolume,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tif (enableHiRes && dash?.flac?.audio) {\n\t\t\t\t\tlogger.debug('次级使用 Hi-Res 音频流')\n\t\t\t\t\treturn okAsync({\n\t\t\t\t\t\turl: dash.flac.audio.baseUrl,\n\t\t\t\t\t\tquality: dash.flac.audio.id,\n\t\t\t\t\t\tgetTime: Date.now() + 60 * 1000, // Add 60s buffer\n\t\t\t\t\t\ttype: 'dash' as const,\n\t\t\t\t\t\tvolume,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tif (!dash?.audio || dash.audio.length === 0) {\n\t\t\t\t\tlogger.error('未找到有效的音频流数据', { response })\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: '未找到有效的音频流数据',\n\t\t\t\t\t\t\ttype: 'AudioStreamError',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tlet stream:\n\t\t\t\t\t| BilibiliTrack['bilibiliMetadata']['bilibiliStreamUrl']\n\t\t\t\t\t| null = null\n\t\t\t\tconst getTime = Date.now() + 60 * 1000 // 加 60s 提前量\n\n\t\t\t\t// 尝试找到指定质量的音频流\n\t\t\t\tconst targetAudio = dash.audio.find(\n\t\t\t\t\t(audio) => audio.id === audioQuality,\n\t\t\t\t)\n\n\t\t\t\tif (targetAudio) {\n\t\t\t\t\tstream = {\n\t\t\t\t\t\turl: targetAudio.baseUrl,\n\t\t\t\t\t\tquality: targetAudio.id,\n\t\t\t\t\t\tgetTime,\n\t\t\t\t\t\ttype: 'dash',\n\t\t\t\t\t\tvolume,\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug('找到指定质量音频流', { quality: audioQuality })\n\t\t\t\t} else {\n\t\t\t\t\t// Fallback: 使用最高质量如果未找到指定质量\n\t\t\t\t\tlogger.warning('未找到指定质量音频流，使用最高质量', {\n\t\t\t\t\t\trequestedQuality: audioQuality,\n\t\t\t\t\t\tavailableQualities: dash.audio.map((a) => a.id),\n\t\t\t\t\t})\n\t\t\t\t\tconst highestQualityAudio = dash.audio[0]\n\t\t\t\t\tif (highestQualityAudio) {\n\t\t\t\t\t\tstream = {\n\t\t\t\t\t\t\turl: highestQualityAudio.baseUrl,\n\t\t\t\t\t\t\tquality: highestQualityAudio.id,\n\t\t\t\t\t\t\tgetTime,\n\t\t\t\t\t\t\ttype: 'dash',\n\t\t\t\t\t\t\tvolume,\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (!stream) {\n\t\t\t\t\tlogger.error('未能确定任何可用的音频流', { response })\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: '未能确定任何可用的音频流',\n\t\t\t\t\t\t\ttype: 'AudioStreamError',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\treturn okAsync(stream)\n\t\t\t})\n\t}\n\n\t/**\n\t * 获取视频分P列表\n\t */\n\tgetPageList(\n\t\tbvid: string,\n\t): ResultAsync<BilibiliMultipageVideo[], BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliMultipageVideo[]>(\n\t\t\t'/x/player/pagelist',\n\t\t\t{\n\t\t\t\tbvid,\n\t\t\t},\n\t\t)\n\t}\n\n\t/**\n\t * 获取登录本人信息\n\t */\n\tgetUserInfo(): ResultAsync<BilibiliUserInfo, BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliUserInfo>('/x/space/myinfo', undefined)\n\t}\n\n\t/**\n\t * 获取别人用户信息\n\t */\n\tgetOtherUserInfo(mid: number) {\n\t\tconst params = getWbiEncodedParams({\n\t\t\tmid: mid.toString(),\n\t\t})\n\t\treturn params.andThen((params) => {\n\t\t\treturn bilibiliApiClient.get<BilibiliUserInfo>(\n\t\t\t\t'/x/space/wbi/acc/info',\n\t\t\t\tparams,\n\t\t\t\tundefined,\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * 获取收藏夹内容(分页)\n\t */\n\tgetFavoriteListContents(\n\t\tfavoriteId: number,\n\t\tpn: number,\n\t): ResultAsync<BilibiliFavoriteListContents, BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliFavoriteListContents>(\n\t\t\t'/x/v3/fav/resource/list',\n\t\t\t{\n\t\t\t\tmedia_id: favoriteId.toString(),\n\t\t\t\tpn: pn.toString(),\n\t\t\t\tps: '40',\n\t\t\t},\n\t\t)\n\t}\n\n\t/**\n\t * 搜索收藏夹内容\n\t * @param favoriteId 如果是全局搜索，随意提供一个**有效**的收藏夹 ID 即可\n\t */\n\tsearchFavoriteListContents(\n\t\tfavoriteId: number,\n\t\tscope: 'all' | 'this',\n\t\tpn: number,\n\t\tkeyword: string,\n\t): ResultAsync<BilibiliFavoriteListContents, BilibiliApiError> {\n\t\treturn bilibiliApiClient\n\t\t\t.get<BilibiliFavoriteListContents>('/x/v3/fav/resource/list', {\n\t\t\t\tmedia_id: favoriteId.toString(),\n\t\t\t\tpn: pn.toString(),\n\t\t\t\tps: '40',\n\t\t\t\tkeyword,\n\t\t\t\ttype: scope === 'this' ? '0' : '1',\n\t\t\t})\n\t\t\t.andThen((res) => {\n\t\t\t\tres.medias ??= []\n\t\t\t\treturn okAsync(res)\n\t\t\t})\n\t}\n\n\t/**\n\t * 获取收藏夹所有视频内容（仅bvid和类型）\n\t * 此接口用于获取收藏夹内所有视频的bvid，常用于批量操作前获取所有目标ID\n\t */\n\tgetFavoriteListAllContents(\n\t\tfavoriteId: number,\n\t): ResultAsync<BilibiliFavoriteListAllContents, BilibiliApiError> {\n\t\treturn bilibiliApiClient\n\t\t\t.get<BilibiliFavoriteListAllContents>('/x/v3/fav/resource/ids', {\n\t\t\t\tmedia_id: favoriteId.toString(),\n\t\t\t})\n\t\t\t.map((response) => response.filter((item) => item.type === 2)) // 过滤非视频稿件 (type 2 is video)\n\t}\n\n\t/**\n\t * 获取视频详细信息\n\t */\n\tgetVideoDetails(\n\t\tbvid: string,\n\t): ResultAsync<BilibiliVideoDetails, BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliVideoDetails>(\n\t\t\t'/x/web-interface/view',\n\t\t\t{\n\t\t\t\tbvid,\n\t\t\t},\n\t\t)\n\t}\n\n\t/**\n\t * 批量删除收藏夹内容\n\t */\n\tbatchDeleteFavoriteListContents(\n\t\tfavoriteId: number,\n\t\tbvids: string[],\n\t): ResultAsync<0, BilibiliApiError> {\n\t\tconst resourcesIds = bvids.map((bvid) => `${bv2av(bvid)}:2`)\n\t\treturn bilibiliApiClient.postWithCsrf<0>('/x/v3/fav/resource/batch-del', {\n\t\t\tresources: resourcesIds.join(','),\n\t\t\tmedia_id: String(favoriteId),\n\t\t\tplatform: 'web',\n\t\t})\n\t}\n\n\t/**\n\t * 获取用户追更的视频合集/收藏夹（非用户自己创建的）列表\n\t */\n\tgetCollectionsList(\n\t\tpageNumber: number,\n\t\tmid: number,\n\t): ResultAsync<\n\t\t{ list: BilibiliCollection[]; count: number; hasMore: boolean },\n\t\tBilibiliApiError\n\t> {\n\t\treturn bilibiliApiClient\n\t\t\t.get<{\n\t\t\t\tlist: BilibiliCollection[]\n\t\t\t\tcount: number\n\t\t\t\thas_more: boolean\n\t\t\t}>('/x/v3/fav/folder/collected/list', {\n\t\t\t\tpn: pageNumber.toString(),\n\t\t\t\tps: '20', // Page size\n\t\t\t\tup_mid: mid.toString(),\n\t\t\t\tplatform: 'web',\n\t\t\t})\n\t\t\t.map((response) => ({\n\t\t\t\tlist: response.list ?? [],\n\t\t\t\tcount: response.count,\n\t\t\t\thasMore: response.has_more,\n\t\t\t}))\n\t}\n\n\t/**\n\t * 获取合集详细信息和完整内容\n\t */\n\tgetCollectionAllContents(\n\t\tcollectionId: number,\n\t): ResultAsync<BilibiliCollectionAllContents, BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliCollectionAllContents>(\n\t\t\t'/x/space/fav/season/list',\n\t\t\t{\n\t\t\t\tseason_id: collectionId.toString(),\n\t\t\t\tps: '20', // Page size, adjust if needed\n\t\t\t\tpn: '1', // Start from page 1\n\t\t\t},\n\t\t)\n\t}\n\n\t/**\n\t * 单个视频添加/删除到多个收藏夹\n\t */\n\tdealFavoriteForOneVideo(\n\t\tbvid: string,\n\t\taddToFavoriteIds: string[],\n\t\tdelInFavoriteIds: string[],\n\t): ResultAsync<BilibiliDealFavoriteForOneVideoResponse, BilibiliApiError> {\n\t\tconst avid = bv2av(bvid)\n\t\tconst addToFavoriteIdsCombined = addToFavoriteIds.join(',')\n\t\tconst delInFavoriteIdsCombined = delInFavoriteIds.join(',')\n\n\t\tconst data = {\n\t\t\trid: String(avid),\n\t\t\tadd_media_ids: addToFavoriteIdsCombined,\n\t\t\tdel_media_ids: delInFavoriteIdsCombined,\n\t\t\ttype: '2',\n\t\t}\n\t\treturn bilibiliApiClient.postWithCsrf<BilibiliDealFavoriteForOneVideoResponse>(\n\t\t\t'/x/v3/fav/resource/deal',\n\t\t\tdata,\n\t\t)\n\t}\n\n\t/**\n\t * 获取目标视频的收藏情况\n\t */\n\tgetTargetVideoFavoriteStatus(\n\t\tuserMid: number,\n\t\tbvid: string,\n\t): ResultAsync<BilibiliPlaylist[], BilibiliApiError> {\n\t\tconst avid = bv2av(bvid)\n\t\treturn bilibiliApiClient\n\t\t\t.get<{ list: BilibiliPlaylist[] | null }>(\n\t\t\t\t'/x/v3/fav/folder/created/list-all',\n\t\t\t\t{\n\t\t\t\t\tup_mid: userMid.toString(),\n\t\t\t\t\trid: String(avid),\n\t\t\t\t\ttype: '2',\n\t\t\t\t},\n\t\t\t)\n\t\t\t.map((response) => {\n\t\t\t\tif (!response.list) {\n\t\t\t\t\treturn []\n\t\t\t\t}\n\t\t\t\treturn response.list\n\t\t\t})\n\t}\n\n\t/**\n\t * 上报观看记录\n\t */\n\treportPlaybackHistory(\n\t\tbvid: string,\n\t\tcid: number,\n\t\tprogress: number,\n\t): ResultAsync<0, BilibiliApiError> {\n\t\tconst avid = bv2av(bvid)\n\n\t\tconst data = {\n\t\t\taid: String(avid),\n\t\t\tcid: String(cid),\n\t\t\tprogress: Math.floor(progress).toString(),\n\t\t}\n\t\treturn bilibiliApiClient.postWithCsrf<0>('/x/v2/history/report', data)\n\t}\n\n\t/**\n\t * 查询用户投稿视频明细\n\t * 可通过 keyword 搜索用户发布的视频\n\t */\n\tgetUserUploadedVideos(\n\t\tmid: number,\n\t\tpn: number,\n\t\tkeyword?: string,\n\t): ResultAsync<BilibiliUserUploadedVideosResponse, BilibiliApiError> {\n\t\tconst params = getWbiEncodedParams({\n\t\t\tmid: mid.toString(),\n\t\t\tpn: pn.toString(),\n\t\t\tkeyword: keyword ?? '',\n\t\t\tps: '30',\n\t\t})\n\t\treturn params.andThen((params) => {\n\t\t\treturn bilibiliApiClient.get<BilibiliUserUploadedVideosResponse>(\n\t\t\t\t'/x/space/wbi/arc/search',\n\t\t\t\tparams,\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * 获取评论区列表\n\t * @param bvid 视频 BV 号\n\t * @param next 加载游标，第一页为 0\n\t * @param mode 排序方式 3: 热度, 2: 时间\n\t */\n\tgetComments(\n\t\tbvid: string,\n\t\tnext: number,\n\t\tmode = 3,\n\t): ResultAsync<BilibiliCommentsResponse, BilibiliApiError> {\n\t\tconst avid = bv2av(bvid)\n\t\treturn bilibiliApiClient.get<BilibiliCommentsResponse>('/x/v2/reply/main', {\n\t\t\toid: String(avid),\n\t\t\ttype: '1', // 1 for video\n\t\t\tmode: String(mode),\n\t\t\tnext: String(next),\n\t\t\tplat: '1',\n\t\t})\n\t}\n\n\t/**\n\t * 获取楼中楼（子评论）列表\n\t * @param bvid 视频 BV 号\n\t * @param rpid 根评论 ID\n\t * @param pn 页码，从 1 开始\n\t */\n\tgetReplyComments(\n\t\tbvid: string,\n\t\trpid: number,\n\t\tpn: number,\n\t): ResultAsync<BilibiliReplyCommentsResponse, BilibiliApiError> {\n\t\tconst avid = bv2av(bvid)\n\t\treturn bilibiliApiClient.get<BilibiliReplyCommentsResponse>(\n\t\t\t'/x/v2/reply/reply',\n\t\t\t{\n\t\t\t\toid: String(avid),\n\t\t\t\ttype: '1',\n\t\t\t\troot: String(rpid),\n\t\t\t\tpn: String(pn),\n\t\t\t\tps: '20',\n\t\t\t},\n\t\t)\n\t}\n\n\t/**\n\t * 点赞/取消点赞评论\n\t * @param bvid 视频 BV 号\n\t * @param rpid 评论 ID\n\t * @param action 1: 点赞, 0: 取消点赞\n\t */\n\tlikeComment(\n\t\tbvid: string,\n\t\trpid: number,\n\t\taction: 0 | 1,\n\t): ResultAsync<0, BilibiliApiError> {\n\t\tconst avid = bv2av(bvid)\n\t\treturn bilibiliApiClient.postWithCsrf<0>('/x/v2/reply/action', {\n\t\t\toid: String(avid),\n\t\t\ttype: '1',\n\t\t\trpid: String(rpid),\n\t\t\taction: String(action),\n\t\t})\n\t}\n\n\t/**\n\t * 申请登录二维码\n\t */\n\tgetLoginQrCode(): ResultAsync<\n\t\t{ url: string; qrcode_key: string },\n\t\tBilibiliApiError\n\t> {\n\t\treturn bilibiliApiClient.get<{ url: string; qrcode_key: string }>(\n\t\t\t'',\n\t\t\tundefined,\n\t\t\t'https://passport.bilibili.com/x/passport-login/web/qrcode/generate',\n\t\t)\n\t}\n\n\t/**\n\t * 轮询二维码登录状态接口\n\t */\n\tpollQrCodeLoginStatus(\n\t\tqrcode_key: string,\n\t): ResultAsync<\n\t\t{ status: BilibiliQrCodeLoginStatus; cookies: string },\n\t\tBilibiliApiError\n\t> {\n\t\tconst reqFunction = async () => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${qrcode_key}`,\n\t\t\t\t{\n\t\t\t\t\tmethod: 'GET',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'User-Agent':\n\t\t\t\t\t\t\t'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 BiliApp/6.66.0',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `请求 bilibili API 失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst data = (await response.json()) as {\n\t\t\t\tdata: { code: number }\n\t\t\t\tcode: number\n\t\t\t}\n\t\t\tif (data.code !== 0) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `获取二维码登录状态失败: ${data.code}`,\n\t\t\t\t\tmsgCode: data.code,\n\t\t\t\t\trawData: data,\n\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst code = data.data.code as BilibiliQrCodeLoginStatus\n\t\t\tif (code !== BilibiliQrCodeLoginStatus.QRCODE_LOGIN_STATUS_SUCCESS) {\n\t\t\t\treturn {\n\t\t\t\t\tstatus: code,\n\t\t\t\t\tcookies: '',\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst combinedCookieHeader = response.headers.get('Set-Cookie')\n\t\t\tif (!combinedCookieHeader) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: '未获取到 Set-Cookie 头信息',\n\t\t\t\t\tmsgCode: 0,\n\t\t\t\t\trawData: null,\n\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tstatus: BilibiliQrCodeLoginStatus.QRCODE_LOGIN_STATUS_SUCCESS,\n\t\t\t\tcookies: combinedCookieHeader,\n\t\t\t}\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(reqFunction(), (error) => {\n\t\t\tif (error instanceof BilibiliApiError) {\n\t\t\t\treturn error\n\t\t\t}\n\t\t\treturn new BilibiliApiError({\n\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\tmsgCode: 0,\n\t\t\t\trawData: null,\n\t\t\t\ttype: 'ResponseFailed',\n\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 获取 b23.tv 短链接的解析后结果\n\t */\n\tgetB23ResolvedUrl(b23Url: string): ResultAsync<string, BilibiliApiError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(b23Url, {\n\t\t\t\theaders: {\n\t\t\t\t\t'User-Agent':\n\t\t\t\t\t\t'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 BiliApp/6.66.0',\n\t\t\t\t},\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\tmessage: (e as Error).message,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t}),\n\t\t).andThen((response) => {\n\t\t\tif (!response.ok) {\n\t\t\t\treturn errAsync(\n\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\tmessage: `请求 b23.tv 短链接失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t}\n\t\t\tconst responseUrl = response.url // react native 不支持 redirect: 'manual'，所以在这里直接获取最终跳转到的 URL\n\n\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\tresponse.text(),\n\t\t\t\t() =>\n\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\tmessage: '解析响应体失败',\n\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t}),\n\t\t\t).andThen((html) => {\n\t\t\t\t// 提取 canonical URL，目前的 b23.tv 可能不重定向直接返回 HTML\n\t\t\t\tconst match = html.match(\n\t\t\t\t\t/<link[^>]*rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)[\"']/i,\n\t\t\t\t)\n\t\t\t\tif (match && match[1]) {\n\t\t\t\t\treturn okAsync(match[1])\n\t\t\t\t}\n\n\t\t\t\t// 兜底：如果 HTML 里面没找到 canonical link，可以 fallback 到 response.url\n\t\t\t\t// response.url 在以前的行为（302 重定向）中会变成最终 URL\n\t\t\t\tif (!responseUrl || responseUrl.includes('b23.tv')) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: '未获取到 b23.tv 短链接的解析结果',\n\t\t\t\t\t\t\tmsgCode: 0,\n\t\t\t\t\t\t\trawData: null,\n\t\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn okAsync(responseUrl)\n\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 检查视频是否已经点赞\n\t * （文档中说该接口实际查询的是 **近期** 是否被点赞）\n\t */\n\tcheckVideoIsThumbUp(bvid: string) {\n\t\treturn bilibiliApiClient.get<0 | 1>('/x/web-interface/archive/has/like', {\n\t\t\tbvid,\n\t\t})\n\t}\n\n\t/**\n\t * 给视频点赞或取消点赞\n\t * @param bvid\n\t * @param like true 表示点赞，false 表示取消点赞\n\t * @returns 对于重复点赞的错误一律当作成功返回。\n\t */\n\tthumbUpVideo(bvid: string, like: boolean): ResultAsync<0, BilibiliApiError> {\n\t\tconst data = {\n\t\t\tbvid,\n\t\t\tlike: like ? '1' : '2',\n\t\t}\n\n\t\treturn bilibiliApiClient\n\t\t\t.postWithCsrf<undefined>('/x/web-interface/archive/like', data)\n\t\t\t.andThen(() => {\n\t\t\t\treturn okAsync(0 as const)\n\t\t\t})\n\t\t\t.orElse((err) => {\n\t\t\t\tswitch (err.data.msgCode) {\n\t\t\t\t\tcase 65006:\n\t\t\t\t\t\t// 重复点赞\n\t\t\t\t\t\treturn okAsync(0 as const)\n\t\t\t\t\tdefault:\n\t\t\t\t\t\treturn errAsync(err)\n\t\t\t\t}\n\t\t\t})\n\t}\n\n\t/**\n\t * web 播放器信息\n\t */\n\tgetWebPlayerInfo(\n\t\tbvid: string,\n\t\tcid: number,\n\t): ResultAsync<BilibiliWebPlayerInfo, BilibiliApiError> {\n\t\tconst params = getWbiEncodedParams({\n\t\t\tbvid,\n\t\t\tcid: String(cid),\n\t\t})\n\t\treturn params.andThen((params) => {\n\t\t\treturn bilibiliApiClient.get<BilibiliWebPlayerInfo>(\n\t\t\t\t'/x/player/wbi/v2',\n\t\t\t\tparams,\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * 获取稍后再看视频列表\n\t */\n\tgetToViewVideoList(): ResultAsync<BilibiliToViewVideoList, BilibiliApiError> {\n\t\treturn bilibiliApiClient.get<BilibiliToViewVideoList>(\n\t\t\t'/x/v2/history/toview',\n\t\t\tundefined,\n\t\t)\n\t}\n\n\t/**\n\t * 删除稍后再看列表中的视频\n\t * @param deleteAllViewed 如果为 true，则删除所有已播放的视频\n\t * @param avid 要删除的视频 avid\n\t * @returns 如果删除成功，返回 0，否则返回 1\n\t */\n\tdeleteToViewVideo(\n\t\tdeleteAllViewed?: boolean,\n\t\tavid?: number,\n\t): ResultAsync<undefined, BilibiliApiError> {\n\t\tif (deleteAllViewed && avid) {\n\t\t\treturn errAsync(\n\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\tmessage: '只能指定一个值',\n\t\t\t\t\ttype: 'InvalidArgument',\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\t\tif (!deleteAllViewed && !avid) {\n\t\t\treturn errAsync(\n\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\tmessage: '你没提供任何参数',\n\t\t\t\t\ttype: 'InvalidArgument',\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\t\tconst data: Record<string, string> = {}\n\t\tif (deleteAllViewed) {\n\t\t\tdata.viewed = 'true'\n\t\t} else if (avid) {\n\t\t\tdata.aid = avid.toString()\n\t\t}\n\t\treturn bilibiliApiClient.postWithCsrf<undefined>(\n\t\t\t'/x/v2/history/toview/del',\n\t\t\tdata,\n\t\t)\n\t}\n\n\t/**\n\t * 清除稍后再看列表中的所有视频\n\t */\n\tclearToViewVideoList(): ResultAsync<undefined, BilibiliApiError> {\n\t\treturn bilibiliApiClient.postWithCsrf<undefined>(\n\t\t\t'/x/v2/history/toview/clear',\n\t\t)\n\t}\n\n\t/**\n\t * 获取手机号登录所需的图形验证 token\n\t */\n\tgetPhoneLoginCaptchaToken(): ResultAsync<\n\t\tBilibiliCaptchaTokenData,\n\t\tBilibiliApiError\n\t> {\n\t\tconst reqFunction = async () => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://passport.bilibili.com/x/passport-login/captcha?source=main_web&t=${Date.now()}`,\n\t\t\t\t{\n\t\t\t\t\tmethod: 'GET',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'User-Agent': PASSPORT_UA,\n\t\t\t\t\t\tReferer: 'https://www.bilibili.com/',\n\t\t\t\t\t},\n\t\t\t\t\t// 手动管理 cookie，避免原生 cookie jar 干扰 passport 接口\n\t\t\t\t\tcredentials: 'omit',\n\t\t\t\t},\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `获取验证码 token 失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst data = (await response.json()) as {\n\t\t\t\tcode: number\n\t\t\t\tmessage?: string\n\t\t\t\tdata: BilibiliCaptchaTokenData\n\t\t\t}\n\t\t\tif (data.code !== 0) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `获取验证码 token 失败: ${data.message ?? data.code}`,\n\t\t\t\t\tmsgCode: data.code,\n\t\t\t\t\trawData: data,\n\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn data.data\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(reqFunction(), (error) => {\n\t\t\tif (error instanceof BilibiliApiError) return error\n\t\t\treturn new BilibiliApiError({\n\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\tmsgCode: 0,\n\t\t\t\trawData: null,\n\t\t\t\ttype: 'ResponseFailed',\n\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 发送手机短信验证码\n\t * @param tel 手机号\n\t * @param cid 国家代码（中国大陆为 86）\n\t * @param token 图形验证 token\n\t * @param challenge geetest challenge\n\t * @param validate geetest validate\n\t * @param seccode geetest seccode\n\t */\n\tsendPhoneLoginSms(\n\t\ttel: string,\n\t\tcid: string,\n\t\ttoken: string,\n\t\tchallenge: string,\n\t\tvalidate: string,\n\t\tseccode: string,\n\t): ResultAsync<BilibiliSmsSendData, BilibiliApiError> {\n\t\tconst reqFunction = async () => {\n\t\t\tconst body = new URLSearchParams({\n\t\t\t\tcid,\n\t\t\t\ttel,\n\t\t\t\tsource: 'main_mini_login',\n\t\t\t\ttoken,\n\t\t\t\tchallenge,\n\t\t\t\tvalidate,\n\t\t\t\tseccode,\n\t\t\t}).toString()\n\n\t\t\tconst response = await fetch(\n\t\t\t\t'https://passport.bilibili.com/x/passport-login/web/sms/send',\n\t\t\t\t{\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded',\n\t\t\t\t\t\t'User-Agent': PASSPORT_UA,\n\t\t\t\t\t\tReferer: 'https://www.bilibili.com/',\n\t\t\t\t\t\tOrigin: 'https://www.bilibili.com',\n\t\t\t\t\t},\n\t\t\t\t\tbody,\n\t\t\t\t\t// 手动管理 cookie，避免原生 cookie jar 干扰 passport 接口\n\t\t\t\t\tcredentials: 'omit',\n\t\t\t\t},\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `发送短信验证码失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst data = (await response.json()) as {\n\t\t\t\tcode: number\n\t\t\t\tmessage?: string\n\t\t\t\tdata: BilibiliSmsSendData\n\t\t\t}\n\t\t\tif (data.code !== 0) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `发送短信验证码失败: ${data.message ?? data.code}`,\n\t\t\t\t\tmsgCode: data.code,\n\t\t\t\t\trawData: data,\n\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn data.data\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(reqFunction(), (error) => {\n\t\t\tif (error instanceof BilibiliApiError) return error\n\t\t\treturn new BilibiliApiError({\n\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\tmsgCode: 0,\n\t\t\t\trawData: null,\n\t\t\t\ttype: 'ResponseFailed',\n\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 使用短信验证码登录\n\t * @param tel 手机号\n\t * @param cid 国家代码（中国大陆为 86）\n\t * @param code 短信验证码\n\t * @param captchaKey 发送短信验证码时返回的 captcha_key\n\t * @returns 返回 Set-Cookie 字符串\n\t */\n\tloginWithPhoneSmsCode(\n\t\ttel: string,\n\t\tcid: string,\n\t\tcode: string,\n\t\tcaptchaKey: string,\n\t): ResultAsync<string, BilibiliApiError> {\n\t\tconst reqFunction = async () => {\n\t\t\tconst body = new URLSearchParams({\n\t\t\t\tcid,\n\t\t\t\ttel,\n\t\t\t\tcode,\n\t\t\t\tsource: 'main_mini_login',\n\t\t\t\tcaptcha_key: captchaKey,\n\t\t\t\tkeep: '1',\n\t\t\t}).toString()\n\n\t\t\tconst response = await fetch(\n\t\t\t\t'https://passport.bilibili.com/x/passport-login/web/login/sms',\n\t\t\t\t{\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded',\n\t\t\t\t\t\t'User-Agent': PASSPORT_UA,\n\t\t\t\t\t\tReferer: 'https://www.bilibili.com/',\n\t\t\t\t\t\tOrigin: 'https://www.bilibili.com',\n\t\t\t\t\t},\n\t\t\t\t\tbody,\n\t\t\t\t\t// 手动管理 cookie，避免原生 cookie jar 干扰 passport 接口\n\t\t\t\t\tcredentials: 'omit',\n\t\t\t\t},\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `短信验证码登录失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst data = (await response.json()) as {\n\t\t\t\tcode: number\n\t\t\t\tmessage?: string\n\t\t\t\tdata: BilibiliSmsLoginData\n\t\t\t}\n\t\t\tif (data.code !== 0) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: `短信验证码登录失败: ${data.message ?? data.code}`,\n\t\t\t\t\tmsgCode: data.code,\n\t\t\t\t\trawData: data,\n\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\tconst combinedCookieHeader = response.headers.get('Set-Cookie')\n\t\t\tif (!combinedCookieHeader) {\n\t\t\t\tthrow new BilibiliApiError({\n\t\t\t\t\tmessage: '登录成功但未获取到 Set-Cookie 头信息',\n\t\t\t\t\tmsgCode: 0,\n\t\t\t\t\trawData: null,\n\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn combinedCookieHeader\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(reqFunction(), (error) => {\n\t\t\tif (error instanceof BilibiliApiError) return error\n\t\t\treturn new BilibiliApiError({\n\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\tmsgCode: 0,\n\t\t\t\trawData: null,\n\t\t\t\ttype: 'ResponseFailed',\n\t\t\t})\n\t\t})\n\t}\n}\n\nexport const bilibiliApi = new BilibiliApi()\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/client.ts",
    "content": "import { errAsync, okAsync, ResultAsync } from 'neverthrow'\n\nimport useAppStore, { serializeCookieObject } from '@/hooks/stores/useAppStore'\nimport { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\n\nimport { getCsrfToken } from './utils'\n\nexport interface ReqResponse<T> {\n\tcode: number\n\tmessage: string\n\tdata: T\n}\n\nclass ApiClient {\n\tprivate baseUrl = 'https://api.bilibili.com'\n\n\t/**\n\t * 核心请求方法，使用 neverthrow 进行封装\n\t * @param endpoint API 端点\n\t * @param options Fetch 请求选项\n\t * @returns ResultAsync 包含成功数据或错误\n\t */\n\tprivate request = <T>(\n\t\tendpoint: string,\n\t\toptions: RequestInit = {},\n\t\tfullUrl?: string,\n\t\tskipCookie?: boolean,\n\t): ResultAsync<T, BilibiliApiError> => {\n\t\tconst url = fullUrl ?? `${this.baseUrl}${endpoint}`\n\t\tconst cookieList = useAppStore.getState().bilibiliCookie\n\t\tconst cookie =\n\t\t\tcookieList && !skipCookie ? serializeCookieObject(cookieList) : ''\n\n\t\tconst defaultHeaders = {\n\t\t\tCookie: cookie,\n\t\t\t'User-Agent':\n\t\t\t\t'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 BiliApp/6.66.0',\n\t\t\tReferer: 'https://www.bilibili.com/',\n\t\t\tOrigin: 'https://www.bilibili.com',\n\t\t}\n\n\t\tconst headers = new Headers(defaultHeaders)\n\n\t\tif (options.headers) {\n\t\t\tnew Headers(options.headers).forEach((value, key) => {\n\t\t\t\theaders.set(key, value)\n\t\t\t})\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(url, {\n\t\t\t\t...options,\n\t\t\t\theaders,\n\t\t\t\t// react native 实现了 cookie 的自动注入，但我们正在自己管理 cookie，所以忽略\n\t\t\t\t// TODO: 应该采用 react-native-cookie 库实现与原生请求库 cookie jar 的更紧密集成。但现阶段我们直接忽略原生注入的 cookie。\n\t\t\t\tcredentials: 'omit',\n\t\t\t}),\n\t\t\t(error) =>\n\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\tmessage: `请求失败: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\tcause: error,\n\t\t\t\t}),\n\t\t)\n\t\t\t.andThen((response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: `请求 bilibili API 失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\t\tresponse.json() as Promise<ReqResponse<T>>,\n\t\t\t\t\t(error) =>\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t})\n\t\t\t.andThen((data) => {\n\t\t\t\t// 对于 wbi 接口，直接返回 data，因为未登录状态下 code 为 -101\n\t\t\t\tif (endpoint === '/x/web-interface/nav') {\n\t\t\t\t\treturn okAsync(data.data)\n\t\t\t\t}\n\t\t\t\tif (data.code !== 0) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\t\tmessage: data.message,\n\t\t\t\t\t\t\tmsgCode: data.code,\n\t\t\t\t\t\t\trawData: data.data,\n\t\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn okAsync(data.data)\n\t\t\t})\n\t}\n\n\t/**\n\t * 发送 GET 请求\n\t * @param endpoint API 端点\n\t * @param params URL 查询参数\n\t * @param fullUrl 完整的 URL，如果提供则忽略 baseUrl\n\t * @param skipCookie 是否跳过 cookie 注入\n\t * @returns ResultAsync 包含成功数据或错误\n\t */\n\tget<T>(\n\t\tendpoint: string,\n\t\tparams?: Record<string, string | undefined> | string,\n\t\tfullUrl?: string,\n\t\tskipCookie?: boolean,\n\t): ResultAsync<T, BilibiliApiError> {\n\t\tlet url = endpoint\n\t\tif (typeof params === 'string') {\n\t\t\turl = `${endpoint}?${params}`\n\t\t} else if (params) {\n\t\t\tconst searchParams = new URLSearchParams()\n\t\t\tfor (const [key, value] of Object.entries(params)) {\n\t\t\t\tif (value !== undefined) {\n\t\t\t\t\tsearchParams.append(key, value)\n\t\t\t\t}\n\t\t\t}\n\t\t\turl = `${endpoint}?${searchParams.toString()}`\n\t\t}\n\t\treturn this.request<T>(url, { method: 'GET' }, fullUrl, skipCookie)\n\t}\n\n\t/**\n\t * 发送 GET 请求并返回 ArrayBuffer\n\t * @param endpoint API 端点\n\t * @param params URL 查询参数\n\t * @param fullUrl 完整的 URL，如果提供则忽略 baseUrl\n\t * @param skipCookie 是否跳过 cookie 注入\n\t * @returns ResultAsync 包含 ArrayBuffer 或错误\n\t */\n\tgetBuffer(\n\t\tendpoint: string,\n\t\tparams?: Record<string, string | undefined> | string,\n\t\theaders?: Record<string, string>,\n\t\tfullUrl?: string,\n\t\tskipCookie?: boolean,\n\t): ResultAsync<ArrayBuffer, BilibiliApiError> {\n\t\tlet url = endpoint\n\t\tif (typeof params === 'string') {\n\t\t\turl = `${endpoint}?${params}`\n\t\t} else if (params) {\n\t\t\tconst searchParams = new URLSearchParams()\n\t\t\tfor (const [key, value] of Object.entries(params)) {\n\t\t\t\tif (value !== undefined) {\n\t\t\t\t\tsearchParams.append(key, value)\n\t\t\t\t}\n\t\t\t}\n\t\t\turl = `${endpoint}?${searchParams.toString()}`\n\t\t}\n\t\tconst requestUrl = fullUrl ?? `${this.baseUrl}${url}`\n\t\tconst cookieList = useAppStore.getState().bilibiliCookie\n\t\tconst cookie =\n\t\t\tcookieList && !skipCookie ? serializeCookieObject(cookieList) : ''\n\n\t\tconst requestHeaders = {\n\t\t\tCookie: cookie,\n\t\t\t'User-Agent':\n\t\t\t\t'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 BiliApp/6.66.0',\n\t\t\tReferer: 'https://www.bilibili.com/',\n\t\t\tOrigin: 'https://www.bilibili.com',\n\t\t\t...headers,\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(requestUrl, {\n\t\t\t\tmethod: 'GET',\n\t\t\t\theaders: requestHeaders,\n\t\t\t\tcredentials: 'omit',\n\t\t\t}),\n\t\t\t(error) =>\n\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\tmessage: `请求失败: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\tcause: error,\n\t\t\t\t}),\n\t\t).andThen((response) => {\n\t\t\tif (!response.ok) {\n\t\t\t\treturn errAsync(\n\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\tmessage: `请求 bilibili API 失败: ${response.status} ${response.statusText}`,\n\t\t\t\t\t\tmsgCode: response.status,\n\t\t\t\t\t\ttype: 'RequestFailed',\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t}\n\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\tresponse.arrayBuffer(),\n\t\t\t\t(error) =>\n\t\t\t\t\tnew BilibiliApiError({\n\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t}),\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * 发送 POST 请求\n\t * @param endpoint API 端点\n\t * @param data 请求体数据\n\t * @param headers 请求头（默认请求类型为 application/x-www-form-urlencoded）\n\t * @param fullUrl 完整的 URL，如果提供则忽略 baseUrl\n\t * @returns ResultAsync 包含成功数据或错误\n\t */\n\tpost<T>(\n\t\tendpoint: string,\n\t\tdata?: BodyInit,\n\t\theaders?: Record<string, string>,\n\t\tfullUrl?: string,\n\t\tskipCookie?: boolean,\n\t): ResultAsync<T, BilibiliApiError> {\n\t\treturn this.request<T>(\n\t\t\tendpoint,\n\t\t\t{\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded',\n\t\t\t\t\t...headers,\n\t\t\t\t},\n\t\t\t\tbody: data,\n\t\t\t},\n\t\t\tfullUrl,\n\t\t\tskipCookie,\n\t\t)\n\t}\n\n\t/**\n\t * 自动处理 CSRF token 并发送 POST 请求 (x-www-form-urlencoded)\n\t * @param url 请求的 URL\n\t * @param payload 请求体数据\n\t * @returns\n\t */\n\tpublic postWithCsrf<T>(\n\t\turl: string,\n\t\tpayload: Record<string, string> = {},\n\t): ResultAsync<T, BilibiliApiError> {\n\t\treturn getCsrfToken().asyncAndThen((csrfToken) => {\n\t\t\tconst dataWithCsrf = {\n\t\t\t\t...payload,\n\t\t\t\tcsrf: csrfToken,\n\t\t\t}\n\n\t\t\tconst body = new URLSearchParams(dataWithCsrf).toString()\n\n\t\t\treturn this.post<T>(url, body)\n\t\t})\n\t}\n}\nexport const bilibiliApiClient = new ApiClient()\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/proto/dm.d.ts",
    "content": "import * as $protobuf from \"protobufjs\";\nimport Long = require(\"long\");\n/** Namespace bilibili. */\nexport namespace bilibili {\n\n    /** Namespace community. */\n    namespace community {\n\n        /** Namespace service. */\n        namespace service {\n\n            /** Namespace dm. */\n            namespace dm {\n\n                /** Namespace v1. */\n                namespace v1 {\n\n                    /** Represents a DM */\n                    class DM extends $protobuf.rpc.Service {\n\n                        /**\n                         * Constructs a new DM service.\n                         * @param rpcImpl RPC implementation\n                         * @param [requestDelimited=false] Whether requests are length-delimited\n                         * @param [responseDelimited=false] Whether responses are length-delimited\n                         */\n                        constructor(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean);\n\n                        /**\n                         * Creates new DM service using the specified rpc implementation.\n                         * @param rpcImpl RPC implementation\n                         * @param [requestDelimited=false] Whether requests are length-delimited\n                         * @param [responseDelimited=false] Whether responses are length-delimited\n                         * @returns RPC service. Useful where requests and/or responses are streamed.\n                         */\n                        public static create(rpcImpl: $protobuf.RPCImpl, requestDelimited?: boolean, responseDelimited?: boolean): DM;\n\n                        /**\n                         * Calls DmSegMobile.\n                         * @param request DmSegMobileReq message or plain object\n                         * @param callback Node-style callback called with the error, if any, and DmSegMobileReply\n                         */\n                        public dmSegMobile(request: bilibili.community.service.dm.v1.IDmSegMobileReq, callback: bilibili.community.service.dm.v1.DM.DmSegMobileCallback): void;\n\n                        /**\n                         * Calls DmSegMobile.\n                         * @param request DmSegMobileReq message or plain object\n                         * @returns Promise\n                         */\n                        public dmSegMobile(request: bilibili.community.service.dm.v1.IDmSegMobileReq): Promise<bilibili.community.service.dm.v1.DmSegMobileReply>;\n\n                        /**\n                         * Calls DmView.\n                         * @param request DmViewReq message or plain object\n                         * @param callback Node-style callback called with the error, if any, and DmViewReply\n                         */\n                        public dmView(request: bilibili.community.service.dm.v1.IDmViewReq, callback: bilibili.community.service.dm.v1.DM.DmViewCallback): void;\n\n                        /**\n                         * Calls DmView.\n                         * @param request DmViewReq message or plain object\n                         * @returns Promise\n                         */\n                        public dmView(request: bilibili.community.service.dm.v1.IDmViewReq): Promise<bilibili.community.service.dm.v1.DmViewReply>;\n\n                        /**\n                         * Calls DmPlayerConfig.\n                         * @param request DmPlayerConfigReq message or plain object\n                         * @param callback Node-style callback called with the error, if any, and Response\n                         */\n                        public dmPlayerConfig(request: bilibili.community.service.dm.v1.IDmPlayerConfigReq, callback: bilibili.community.service.dm.v1.DM.DmPlayerConfigCallback): void;\n\n                        /**\n                         * Calls DmPlayerConfig.\n                         * @param request DmPlayerConfigReq message or plain object\n                         * @returns Promise\n                         */\n                        public dmPlayerConfig(request: bilibili.community.service.dm.v1.IDmPlayerConfigReq): Promise<bilibili.community.service.dm.v1.Response>;\n\n                        /**\n                         * Calls DmSegOtt.\n                         * @param request DmSegOttReq message or plain object\n                         * @param callback Node-style callback called with the error, if any, and DmSegOttReply\n                         */\n                        public dmSegOtt(request: bilibili.community.service.dm.v1.IDmSegOttReq, callback: bilibili.community.service.dm.v1.DM.DmSegOttCallback): void;\n\n                        /**\n                         * Calls DmSegOtt.\n                         * @param request DmSegOttReq message or plain object\n                         * @returns Promise\n                         */\n                        public dmSegOtt(request: bilibili.community.service.dm.v1.IDmSegOttReq): Promise<bilibili.community.service.dm.v1.DmSegOttReply>;\n\n                        /**\n                         * Calls DmSegSDK.\n                         * @param request DmSegSDKReq message or plain object\n                         * @param callback Node-style callback called with the error, if any, and DmSegSDKReply\n                         */\n                        public dmSegSDK(request: bilibili.community.service.dm.v1.IDmSegSDKReq, callback: bilibili.community.service.dm.v1.DM.DmSegSDKCallback): void;\n\n                        /**\n                         * Calls DmSegSDK.\n                         * @param request DmSegSDKReq message or plain object\n                         * @returns Promise\n                         */\n                        public dmSegSDK(request: bilibili.community.service.dm.v1.IDmSegSDKReq): Promise<bilibili.community.service.dm.v1.DmSegSDKReply>;\n\n                        /**\n                         * Calls DmExpoReport.\n                         * @param request DmExpoReportReq message or plain object\n                         * @param callback Node-style callback called with the error, if any, and DmExpoReportRes\n                         */\n                        public dmExpoReport(request: bilibili.community.service.dm.v1.IDmExpoReportReq, callback: bilibili.community.service.dm.v1.DM.DmExpoReportCallback): void;\n\n                        /**\n                         * Calls DmExpoReport.\n                         * @param request DmExpoReportReq message or plain object\n                         * @returns Promise\n                         */\n                        public dmExpoReport(request: bilibili.community.service.dm.v1.IDmExpoReportReq): Promise<bilibili.community.service.dm.v1.DmExpoReportRes>;\n                    }\n\n                    namespace DM {\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmSegMobile}.\n                         * @param error Error, if any\n                         * @param [response] DmSegMobileReply\n                         */\n                        type DmSegMobileCallback = (error: (Error|null), response?: bilibili.community.service.dm.v1.DmSegMobileReply) => void;\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmView}.\n                         * @param error Error, if any\n                         * @param [response] DmViewReply\n                         */\n                        type DmViewCallback = (error: (Error|null), response?: bilibili.community.service.dm.v1.DmViewReply) => void;\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmPlayerConfig}.\n                         * @param error Error, if any\n                         * @param [response] Response\n                         */\n                        type DmPlayerConfigCallback = (error: (Error|null), response?: bilibili.community.service.dm.v1.Response) => void;\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmSegOtt}.\n                         * @param error Error, if any\n                         * @param [response] DmSegOttReply\n                         */\n                        type DmSegOttCallback = (error: (Error|null), response?: bilibili.community.service.dm.v1.DmSegOttReply) => void;\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmSegSDK}.\n                         * @param error Error, if any\n                         * @param [response] DmSegSDKReply\n                         */\n                        type DmSegSDKCallback = (error: (Error|null), response?: bilibili.community.service.dm.v1.DmSegSDKReply) => void;\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmExpoReport}.\n                         * @param error Error, if any\n                         * @param [response] DmExpoReportRes\n                         */\n                        type DmExpoReportCallback = (error: (Error|null), response?: bilibili.community.service.dm.v1.DmExpoReportRes) => void;\n                    }\n\n                    /** Properties of an Avatar. */\n                    interface IAvatar {\n\n                        /** Avatar id */\n                        id?: (string|null);\n\n                        /** Avatar url */\n                        url?: (string|null);\n\n                        /** Avatar avatarType */\n                        avatarType?: (bilibili.community.service.dm.v1.AvatarType|null);\n                    }\n\n                    /** Represents an Avatar. */\n                    class Avatar implements IAvatar {\n\n                        /**\n                         * Constructs a new Avatar.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IAvatar);\n\n                        /** Avatar id. */\n                        public id: string;\n\n                        /** Avatar url. */\n                        public url: string;\n\n                        /** Avatar avatarType. */\n                        public avatarType: bilibili.community.service.dm.v1.AvatarType;\n\n                        /**\n                         * Creates a new Avatar instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Avatar instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IAvatar): bilibili.community.service.dm.v1.Avatar;\n\n                        /**\n                         * Encodes the specified Avatar message. Does not implicitly {@link bilibili.community.service.dm.v1.Avatar.verify|verify} messages.\n                         * @param message Avatar message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IAvatar, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Avatar message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Avatar.verify|verify} messages.\n                         * @param message Avatar message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IAvatar, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes an Avatar message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Avatar\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Avatar;\n\n                        /**\n                         * Decodes an Avatar message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Avatar\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Avatar;\n\n                        /**\n                         * Verifies an Avatar message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates an Avatar message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Avatar\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Avatar;\n\n                        /**\n                         * Creates a plain object from an Avatar message. Also converts values to other types if specified.\n                         * @param message Avatar\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Avatar, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Avatar to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Avatar\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** AvatarType enum. */\n                    enum AvatarType {\n                        AvatarTypeNone = 0,\n                        AvatarTypeNFT = 1\n                    }\n\n                    /** Properties of a Bubble. */\n                    interface IBubble {\n\n                        /** Bubble text */\n                        text?: (string|null);\n\n                        /** Bubble url */\n                        url?: (string|null);\n                    }\n\n                    /** Represents a Bubble. */\n                    class Bubble implements IBubble {\n\n                        /**\n                         * Constructs a new Bubble.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IBubble);\n\n                        /** Bubble text. */\n                        public text: string;\n\n                        /** Bubble url. */\n                        public url: string;\n\n                        /**\n                         * Creates a new Bubble instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Bubble instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IBubble): bilibili.community.service.dm.v1.Bubble;\n\n                        /**\n                         * Encodes the specified Bubble message. Does not implicitly {@link bilibili.community.service.dm.v1.Bubble.verify|verify} messages.\n                         * @param message Bubble message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IBubble, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Bubble message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Bubble.verify|verify} messages.\n                         * @param message Bubble message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IBubble, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a Bubble message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Bubble\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Bubble;\n\n                        /**\n                         * Decodes a Bubble message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Bubble\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Bubble;\n\n                        /**\n                         * Verifies a Bubble message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a Bubble message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Bubble\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Bubble;\n\n                        /**\n                         * Creates a plain object from a Bubble message. Also converts values to other types if specified.\n                         * @param message Bubble\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Bubble, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Bubble to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Bubble\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** BubbleType enum. */\n                    enum BubbleType {\n                        BubbleTypeNone = 0,\n                        BubbleTypeClickButton = 1,\n                        BubbleTypeDmSettingPanel = 2\n                    }\n\n                    /** Properties of a BubbleV2. */\n                    interface IBubbleV2 {\n\n                        /** BubbleV2 text */\n                        text?: (string|null);\n\n                        /** BubbleV2 url */\n                        url?: (string|null);\n\n                        /** BubbleV2 bubbleType */\n                        bubbleType?: (bilibili.community.service.dm.v1.BubbleType|null);\n\n                        /** BubbleV2 exposureOnce */\n                        exposureOnce?: (boolean|null);\n\n                        /** BubbleV2 exposureType */\n                        exposureType?: (bilibili.community.service.dm.v1.ExposureType|null);\n                    }\n\n                    /** Represents a BubbleV2. */\n                    class BubbleV2 implements IBubbleV2 {\n\n                        /**\n                         * Constructs a new BubbleV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IBubbleV2);\n\n                        /** BubbleV2 text. */\n                        public text: string;\n\n                        /** BubbleV2 url. */\n                        public url: string;\n\n                        /** BubbleV2 bubbleType. */\n                        public bubbleType: bilibili.community.service.dm.v1.BubbleType;\n\n                        /** BubbleV2 exposureOnce. */\n                        public exposureOnce: boolean;\n\n                        /** BubbleV2 exposureType. */\n                        public exposureType: bilibili.community.service.dm.v1.ExposureType;\n\n                        /**\n                         * Creates a new BubbleV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns BubbleV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IBubbleV2): bilibili.community.service.dm.v1.BubbleV2;\n\n                        /**\n                         * Encodes the specified BubbleV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.BubbleV2.verify|verify} messages.\n                         * @param message BubbleV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IBubbleV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified BubbleV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.BubbleV2.verify|verify} messages.\n                         * @param message BubbleV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IBubbleV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a BubbleV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns BubbleV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.BubbleV2;\n\n                        /**\n                         * Decodes a BubbleV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns BubbleV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.BubbleV2;\n\n                        /**\n                         * Verifies a BubbleV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a BubbleV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns BubbleV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.BubbleV2;\n\n                        /**\n                         * Creates a plain object from a BubbleV2 message. Also converts values to other types if specified.\n                         * @param message BubbleV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.BubbleV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this BubbleV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for BubbleV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a Button. */\n                    interface IButton {\n\n                        /** Button text */\n                        text?: (string|null);\n\n                        /** Button action */\n                        action?: (number|null);\n                    }\n\n                    /** Represents a Button. */\n                    class Button implements IButton {\n\n                        /**\n                         * Constructs a new Button.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IButton);\n\n                        /** Button text. */\n                        public text: string;\n\n                        /** Button action. */\n                        public action: number;\n\n                        /**\n                         * Creates a new Button instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Button instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IButton): bilibili.community.service.dm.v1.Button;\n\n                        /**\n                         * Encodes the specified Button message. Does not implicitly {@link bilibili.community.service.dm.v1.Button.verify|verify} messages.\n                         * @param message Button message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IButton, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Button message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Button.verify|verify} messages.\n                         * @param message Button message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IButton, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a Button message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Button\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Button;\n\n                        /**\n                         * Decodes a Button message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Button\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Button;\n\n                        /**\n                         * Verifies a Button message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a Button message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Button\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Button;\n\n                        /**\n                         * Creates a plain object from a Button message. Also converts values to other types if specified.\n                         * @param message Button\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Button, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Button to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Button\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a BuzzwordConfig. */\n                    interface IBuzzwordConfig {\n\n                        /** BuzzwordConfig keywords */\n                        keywords?: (bilibili.community.service.dm.v1.IBuzzwordShowConfig[]|null);\n                    }\n\n                    /** Represents a BuzzwordConfig. */\n                    class BuzzwordConfig implements IBuzzwordConfig {\n\n                        /**\n                         * Constructs a new BuzzwordConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IBuzzwordConfig);\n\n                        /** BuzzwordConfig keywords. */\n                        public keywords: bilibili.community.service.dm.v1.IBuzzwordShowConfig[];\n\n                        /**\n                         * Creates a new BuzzwordConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns BuzzwordConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IBuzzwordConfig): bilibili.community.service.dm.v1.BuzzwordConfig;\n\n                        /**\n                         * Encodes the specified BuzzwordConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordConfig.verify|verify} messages.\n                         * @param message BuzzwordConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IBuzzwordConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified BuzzwordConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordConfig.verify|verify} messages.\n                         * @param message BuzzwordConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IBuzzwordConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a BuzzwordConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns BuzzwordConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.BuzzwordConfig;\n\n                        /**\n                         * Decodes a BuzzwordConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns BuzzwordConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.BuzzwordConfig;\n\n                        /**\n                         * Verifies a BuzzwordConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a BuzzwordConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns BuzzwordConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.BuzzwordConfig;\n\n                        /**\n                         * Creates a plain object from a BuzzwordConfig message. Also converts values to other types if specified.\n                         * @param message BuzzwordConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.BuzzwordConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this BuzzwordConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for BuzzwordConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a BuzzwordShowConfig. */\n                    interface IBuzzwordShowConfig {\n\n                        /** BuzzwordShowConfig name */\n                        name?: (string|null);\n\n                        /** BuzzwordShowConfig schema */\n                        schema?: (string|null);\n\n                        /** BuzzwordShowConfig source */\n                        source?: (number|null);\n\n                        /** BuzzwordShowConfig id */\n                        id?: (number|Long|null);\n\n                        /** BuzzwordShowConfig buzzwordId */\n                        buzzwordId?: (number|Long|null);\n\n                        /** BuzzwordShowConfig schemaType */\n                        schemaType?: (number|null);\n                    }\n\n                    /** Represents a BuzzwordShowConfig. */\n                    class BuzzwordShowConfig implements IBuzzwordShowConfig {\n\n                        /**\n                         * Constructs a new BuzzwordShowConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IBuzzwordShowConfig);\n\n                        /** BuzzwordShowConfig name. */\n                        public name: string;\n\n                        /** BuzzwordShowConfig schema. */\n                        public schema: string;\n\n                        /** BuzzwordShowConfig source. */\n                        public source: number;\n\n                        /** BuzzwordShowConfig id. */\n                        public id: (number|Long);\n\n                        /** BuzzwordShowConfig buzzwordId. */\n                        public buzzwordId: (number|Long);\n\n                        /** BuzzwordShowConfig schemaType. */\n                        public schemaType: number;\n\n                        /**\n                         * Creates a new BuzzwordShowConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns BuzzwordShowConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IBuzzwordShowConfig): bilibili.community.service.dm.v1.BuzzwordShowConfig;\n\n                        /**\n                         * Encodes the specified BuzzwordShowConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordShowConfig.verify|verify} messages.\n                         * @param message BuzzwordShowConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IBuzzwordShowConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified BuzzwordShowConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordShowConfig.verify|verify} messages.\n                         * @param message BuzzwordShowConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IBuzzwordShowConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a BuzzwordShowConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns BuzzwordShowConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.BuzzwordShowConfig;\n\n                        /**\n                         * Decodes a BuzzwordShowConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns BuzzwordShowConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.BuzzwordShowConfig;\n\n                        /**\n                         * Verifies a BuzzwordShowConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a BuzzwordShowConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns BuzzwordShowConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.BuzzwordShowConfig;\n\n                        /**\n                         * Creates a plain object from a BuzzwordShowConfig message. Also converts values to other types if specified.\n                         * @param message BuzzwordShowConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.BuzzwordShowConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this BuzzwordShowConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for BuzzwordShowConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a CheckBox. */\n                    interface ICheckBox {\n\n                        /** CheckBox text */\n                        text?: (string|null);\n\n                        /** CheckBox type */\n                        type?: (bilibili.community.service.dm.v1.CheckboxType|null);\n\n                        /** CheckBox defaultValue */\n                        defaultValue?: (boolean|null);\n\n                        /** CheckBox show */\n                        show?: (boolean|null);\n                    }\n\n                    /** Represents a CheckBox. */\n                    class CheckBox implements ICheckBox {\n\n                        /**\n                         * Constructs a new CheckBox.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ICheckBox);\n\n                        /** CheckBox text. */\n                        public text: string;\n\n                        /** CheckBox type. */\n                        public type: bilibili.community.service.dm.v1.CheckboxType;\n\n                        /** CheckBox defaultValue. */\n                        public defaultValue: boolean;\n\n                        /** CheckBox show. */\n                        public show: boolean;\n\n                        /**\n                         * Creates a new CheckBox instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns CheckBox instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ICheckBox): bilibili.community.service.dm.v1.CheckBox;\n\n                        /**\n                         * Encodes the specified CheckBox message. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBox.verify|verify} messages.\n                         * @param message CheckBox message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ICheckBox, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified CheckBox message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBox.verify|verify} messages.\n                         * @param message CheckBox message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ICheckBox, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a CheckBox message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns CheckBox\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.CheckBox;\n\n                        /**\n                         * Decodes a CheckBox message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns CheckBox\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.CheckBox;\n\n                        /**\n                         * Verifies a CheckBox message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a CheckBox message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns CheckBox\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.CheckBox;\n\n                        /**\n                         * Creates a plain object from a CheckBox message. Also converts values to other types if specified.\n                         * @param message CheckBox\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.CheckBox, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this CheckBox to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for CheckBox\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** CheckboxType enum. */\n                    enum CheckboxType {\n                        CheckboxTypeNone = 0,\n                        CheckboxTypeEncourage = 1,\n                        CheckboxTypeColorDM = 2\n                    }\n\n                    /** Properties of a CheckBoxV2. */\n                    interface ICheckBoxV2 {\n\n                        /** CheckBoxV2 text */\n                        text?: (string|null);\n\n                        /** CheckBoxV2 type */\n                        type?: (number|null);\n\n                        /** CheckBoxV2 defaultValue */\n                        defaultValue?: (boolean|null);\n                    }\n\n                    /** Represents a CheckBoxV2. */\n                    class CheckBoxV2 implements ICheckBoxV2 {\n\n                        /**\n                         * Constructs a new CheckBoxV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ICheckBoxV2);\n\n                        /** CheckBoxV2 text. */\n                        public text: string;\n\n                        /** CheckBoxV2 type. */\n                        public type: number;\n\n                        /** CheckBoxV2 defaultValue. */\n                        public defaultValue: boolean;\n\n                        /**\n                         * Creates a new CheckBoxV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns CheckBoxV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ICheckBoxV2): bilibili.community.service.dm.v1.CheckBoxV2;\n\n                        /**\n                         * Encodes the specified CheckBoxV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBoxV2.verify|verify} messages.\n                         * @param message CheckBoxV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ICheckBoxV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified CheckBoxV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBoxV2.verify|verify} messages.\n                         * @param message CheckBoxV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ICheckBoxV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a CheckBoxV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns CheckBoxV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.CheckBoxV2;\n\n                        /**\n                         * Decodes a CheckBoxV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns CheckBoxV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.CheckBoxV2;\n\n                        /**\n                         * Verifies a CheckBoxV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a CheckBoxV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns CheckBoxV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.CheckBoxV2;\n\n                        /**\n                         * Creates a plain object from a CheckBoxV2 message. Also converts values to other types if specified.\n                         * @param message CheckBoxV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.CheckBoxV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this CheckBoxV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for CheckBoxV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a ClickButton. */\n                    interface IClickButton {\n\n                        /** ClickButton portraitText */\n                        portraitText?: (string[]|null);\n\n                        /** ClickButton landscapeText */\n                        landscapeText?: (string[]|null);\n\n                        /** ClickButton portraitTextFocus */\n                        portraitTextFocus?: (string[]|null);\n\n                        /** ClickButton landscapeTextFocus */\n                        landscapeTextFocus?: (string[]|null);\n\n                        /** ClickButton renderType */\n                        renderType?: (bilibili.community.service.dm.v1.RenderType|null);\n\n                        /** ClickButton show */\n                        show?: (boolean|null);\n\n                        /** ClickButton bubble */\n                        bubble?: (bilibili.community.service.dm.v1.IBubble|null);\n                    }\n\n                    /** Represents a ClickButton. */\n                    class ClickButton implements IClickButton {\n\n                        /**\n                         * Constructs a new ClickButton.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IClickButton);\n\n                        /** ClickButton portraitText. */\n                        public portraitText: string[];\n\n                        /** ClickButton landscapeText. */\n                        public landscapeText: string[];\n\n                        /** ClickButton portraitTextFocus. */\n                        public portraitTextFocus: string[];\n\n                        /** ClickButton landscapeTextFocus. */\n                        public landscapeTextFocus: string[];\n\n                        /** ClickButton renderType. */\n                        public renderType: bilibili.community.service.dm.v1.RenderType;\n\n                        /** ClickButton show. */\n                        public show: boolean;\n\n                        /** ClickButton bubble. */\n                        public bubble?: (bilibili.community.service.dm.v1.IBubble|null);\n\n                        /**\n                         * Creates a new ClickButton instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns ClickButton instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IClickButton): bilibili.community.service.dm.v1.ClickButton;\n\n                        /**\n                         * Encodes the specified ClickButton message. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButton.verify|verify} messages.\n                         * @param message ClickButton message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IClickButton, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified ClickButton message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButton.verify|verify} messages.\n                         * @param message ClickButton message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IClickButton, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a ClickButton message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns ClickButton\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.ClickButton;\n\n                        /**\n                         * Decodes a ClickButton message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns ClickButton\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.ClickButton;\n\n                        /**\n                         * Verifies a ClickButton message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a ClickButton message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns ClickButton\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.ClickButton;\n\n                        /**\n                         * Creates a plain object from a ClickButton message. Also converts values to other types if specified.\n                         * @param message ClickButton\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.ClickButton, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this ClickButton to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for ClickButton\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a ClickButtonV2. */\n                    interface IClickButtonV2 {\n\n                        /** ClickButtonV2 portraitText */\n                        portraitText?: (string[]|null);\n\n                        /** ClickButtonV2 landscapeText */\n                        landscapeText?: (string[]|null);\n\n                        /** ClickButtonV2 portraitTextFocus */\n                        portraitTextFocus?: (string[]|null);\n\n                        /** ClickButtonV2 landscapeTextFocus */\n                        landscapeTextFocus?: (string[]|null);\n\n                        /** ClickButtonV2 renderType */\n                        renderType?: (number|null);\n\n                        /** ClickButtonV2 textInputPost */\n                        textInputPost?: (boolean|null);\n\n                        /** ClickButtonV2 exposureOnce */\n                        exposureOnce?: (boolean|null);\n\n                        /** ClickButtonV2 exposureType */\n                        exposureType?: (number|null);\n                    }\n\n                    /** Represents a ClickButtonV2. */\n                    class ClickButtonV2 implements IClickButtonV2 {\n\n                        /**\n                         * Constructs a new ClickButtonV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IClickButtonV2);\n\n                        /** ClickButtonV2 portraitText. */\n                        public portraitText: string[];\n\n                        /** ClickButtonV2 landscapeText. */\n                        public landscapeText: string[];\n\n                        /** ClickButtonV2 portraitTextFocus. */\n                        public portraitTextFocus: string[];\n\n                        /** ClickButtonV2 landscapeTextFocus. */\n                        public landscapeTextFocus: string[];\n\n                        /** ClickButtonV2 renderType. */\n                        public renderType: number;\n\n                        /** ClickButtonV2 textInputPost. */\n                        public textInputPost: boolean;\n\n                        /** ClickButtonV2 exposureOnce. */\n                        public exposureOnce: boolean;\n\n                        /** ClickButtonV2 exposureType. */\n                        public exposureType: number;\n\n                        /**\n                         * Creates a new ClickButtonV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns ClickButtonV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IClickButtonV2): bilibili.community.service.dm.v1.ClickButtonV2;\n\n                        /**\n                         * Encodes the specified ClickButtonV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButtonV2.verify|verify} messages.\n                         * @param message ClickButtonV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IClickButtonV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified ClickButtonV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButtonV2.verify|verify} messages.\n                         * @param message ClickButtonV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IClickButtonV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a ClickButtonV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns ClickButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.ClickButtonV2;\n\n                        /**\n                         * Decodes a ClickButtonV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns ClickButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.ClickButtonV2;\n\n                        /**\n                         * Verifies a ClickButtonV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a ClickButtonV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns ClickButtonV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.ClickButtonV2;\n\n                        /**\n                         * Creates a plain object from a ClickButtonV2 message. Also converts values to other types if specified.\n                         * @param message ClickButtonV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.ClickButtonV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this ClickButtonV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for ClickButtonV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a CommandDm. */\n                    interface ICommandDm {\n\n                        /** CommandDm id */\n                        id?: (number|Long|null);\n\n                        /** CommandDm oid */\n                        oid?: (number|Long|null);\n\n                        /** CommandDm mid */\n                        mid?: (string|null);\n\n                        /** CommandDm command */\n                        command?: (string|null);\n\n                        /** CommandDm content */\n                        content?: (string|null);\n\n                        /** CommandDm progress */\n                        progress?: (number|null);\n\n                        /** CommandDm ctime */\n                        ctime?: (string|null);\n\n                        /** CommandDm mtime */\n                        mtime?: (string|null);\n\n                        /** CommandDm extra */\n                        extra?: (string|null);\n\n                        /** CommandDm idStr */\n                        idStr?: (string|null);\n                    }\n\n                    /** Represents a CommandDm. */\n                    class CommandDm implements ICommandDm {\n\n                        /**\n                         * Constructs a new CommandDm.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ICommandDm);\n\n                        /** CommandDm id. */\n                        public id: (number|Long);\n\n                        /** CommandDm oid. */\n                        public oid: (number|Long);\n\n                        /** CommandDm mid. */\n                        public mid: string;\n\n                        /** CommandDm command. */\n                        public command: string;\n\n                        /** CommandDm content. */\n                        public content: string;\n\n                        /** CommandDm progress. */\n                        public progress: number;\n\n                        /** CommandDm ctime. */\n                        public ctime: string;\n\n                        /** CommandDm mtime. */\n                        public mtime: string;\n\n                        /** CommandDm extra. */\n                        public extra: string;\n\n                        /** CommandDm idStr. */\n                        public idStr: string;\n\n                        /**\n                         * Creates a new CommandDm instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns CommandDm instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ICommandDm): bilibili.community.service.dm.v1.CommandDm;\n\n                        /**\n                         * Encodes the specified CommandDm message. Does not implicitly {@link bilibili.community.service.dm.v1.CommandDm.verify|verify} messages.\n                         * @param message CommandDm message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ICommandDm, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified CommandDm message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.CommandDm.verify|verify} messages.\n                         * @param message CommandDm message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ICommandDm, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a CommandDm message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns CommandDm\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.CommandDm;\n\n                        /**\n                         * Decodes a CommandDm message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns CommandDm\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.CommandDm;\n\n                        /**\n                         * Verifies a CommandDm message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a CommandDm message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns CommandDm\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.CommandDm;\n\n                        /**\n                         * Creates a plain object from a CommandDm message. Also converts values to other types if specified.\n                         * @param message CommandDm\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.CommandDm, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this CommandDm to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for CommandDm\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmakuAIFlag. */\n                    interface IDanmakuAIFlag {\n\n                        /** DanmakuAIFlag dmFlags */\n                        dmFlags?: (bilibili.community.service.dm.v1.IDanmakuFlag[]|null);\n                    }\n\n                    /** Represents a DanmakuAIFlag. */\n                    class DanmakuAIFlag implements IDanmakuAIFlag {\n\n                        /**\n                         * Constructs a new DanmakuAIFlag.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmakuAIFlag);\n\n                        /** DanmakuAIFlag dmFlags. */\n                        public dmFlags: bilibili.community.service.dm.v1.IDanmakuFlag[];\n\n                        /**\n                         * Creates a new DanmakuAIFlag instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmakuAIFlag instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmakuAIFlag): bilibili.community.service.dm.v1.DanmakuAIFlag;\n\n                        /**\n                         * Encodes the specified DanmakuAIFlag message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuAIFlag.verify|verify} messages.\n                         * @param message DanmakuAIFlag message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmakuAIFlag, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmakuAIFlag message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuAIFlag.verify|verify} messages.\n                         * @param message DanmakuAIFlag message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmakuAIFlag, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmakuAIFlag message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmakuAIFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmakuAIFlag;\n\n                        /**\n                         * Decodes a DanmakuAIFlag message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmakuAIFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmakuAIFlag;\n\n                        /**\n                         * Verifies a DanmakuAIFlag message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmakuAIFlag message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmakuAIFlag\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmakuAIFlag;\n\n                        /**\n                         * Creates a plain object from a DanmakuAIFlag message. Also converts values to other types if specified.\n                         * @param message DanmakuAIFlag\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmakuAIFlag, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmakuAIFlag to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmakuAIFlag\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmakuElem. */\n                    interface IDanmakuElem {\n\n                        /** DanmakuElem id */\n                        id?: (number|Long|null);\n\n                        /** DanmakuElem progress */\n                        progress?: (number|null);\n\n                        /** DanmakuElem mode */\n                        mode?: (number|null);\n\n                        /** DanmakuElem fontsize */\n                        fontsize?: (number|null);\n\n                        /** DanmakuElem color */\n                        color?: (number|null);\n\n                        /** DanmakuElem midHash */\n                        midHash?: (string|null);\n\n                        /** DanmakuElem content */\n                        content?: (string|null);\n\n                        /** DanmakuElem ctime */\n                        ctime?: (number|Long|null);\n\n                        /** DanmakuElem weight */\n                        weight?: (number|null);\n\n                        /** DanmakuElem action */\n                        action?: (string|null);\n\n                        /** DanmakuElem pool */\n                        pool?: (number|null);\n\n                        /** DanmakuElem idStr */\n                        idStr?: (string|null);\n\n                        /** DanmakuElem attr */\n                        attr?: (number|null);\n\n                        /** DanmakuElem animation */\n                        animation?: (string|null);\n\n                        /** DanmakuElem colorful */\n                        colorful?: (bilibili.community.service.dm.v1.DmColorfulType|null);\n                    }\n\n                    /** Represents a DanmakuElem. */\n                    class DanmakuElem implements IDanmakuElem {\n\n                        /**\n                         * Constructs a new DanmakuElem.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmakuElem);\n\n                        /** DanmakuElem id. */\n                        public id: (number|Long);\n\n                        /** DanmakuElem progress. */\n                        public progress: number;\n\n                        /** DanmakuElem mode. */\n                        public mode: number;\n\n                        /** DanmakuElem fontsize. */\n                        public fontsize: number;\n\n                        /** DanmakuElem color. */\n                        public color: number;\n\n                        /** DanmakuElem midHash. */\n                        public midHash: string;\n\n                        /** DanmakuElem content. */\n                        public content: string;\n\n                        /** DanmakuElem ctime. */\n                        public ctime: (number|Long);\n\n                        /** DanmakuElem weight. */\n                        public weight: number;\n\n                        /** DanmakuElem action. */\n                        public action: string;\n\n                        /** DanmakuElem pool. */\n                        public pool: number;\n\n                        /** DanmakuElem idStr. */\n                        public idStr: string;\n\n                        /** DanmakuElem attr. */\n                        public attr: number;\n\n                        /** DanmakuElem animation. */\n                        public animation: string;\n\n                        /** DanmakuElem colorful. */\n                        public colorful: bilibili.community.service.dm.v1.DmColorfulType;\n\n                        /**\n                         * Creates a new DanmakuElem instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmakuElem instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmakuElem): bilibili.community.service.dm.v1.DanmakuElem;\n\n                        /**\n                         * Encodes the specified DanmakuElem message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuElem.verify|verify} messages.\n                         * @param message DanmakuElem message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmakuElem, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmakuElem message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuElem.verify|verify} messages.\n                         * @param message DanmakuElem message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmakuElem, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmakuElem message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmakuElem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmakuElem;\n\n                        /**\n                         * Decodes a DanmakuElem message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmakuElem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmakuElem;\n\n                        /**\n                         * Verifies a DanmakuElem message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmakuElem message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmakuElem\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmakuElem;\n\n                        /**\n                         * Creates a plain object from a DanmakuElem message. Also converts values to other types if specified.\n                         * @param message DanmakuElem\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmakuElem, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmakuElem to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmakuElem\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmakuFlag. */\n                    interface IDanmakuFlag {\n\n                        /** DanmakuFlag dmid */\n                        dmid?: (number|Long|null);\n\n                        /** DanmakuFlag flag */\n                        flag?: (number|null);\n                    }\n\n                    /** Represents a DanmakuFlag. */\n                    class DanmakuFlag implements IDanmakuFlag {\n\n                        /**\n                         * Constructs a new DanmakuFlag.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmakuFlag);\n\n                        /** DanmakuFlag dmid. */\n                        public dmid: (number|Long);\n\n                        /** DanmakuFlag flag. */\n                        public flag: number;\n\n                        /**\n                         * Creates a new DanmakuFlag instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmakuFlag instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmakuFlag): bilibili.community.service.dm.v1.DanmakuFlag;\n\n                        /**\n                         * Encodes the specified DanmakuFlag message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlag.verify|verify} messages.\n                         * @param message DanmakuFlag message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmakuFlag, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmakuFlag message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlag.verify|verify} messages.\n                         * @param message DanmakuFlag message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmakuFlag, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmakuFlag message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmakuFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmakuFlag;\n\n                        /**\n                         * Decodes a DanmakuFlag message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmakuFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmakuFlag;\n\n                        /**\n                         * Verifies a DanmakuFlag message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmakuFlag message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmakuFlag\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmakuFlag;\n\n                        /**\n                         * Creates a plain object from a DanmakuFlag message. Also converts values to other types if specified.\n                         * @param message DanmakuFlag\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmakuFlag, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmakuFlag to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmakuFlag\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmakuFlagConfig. */\n                    interface IDanmakuFlagConfig {\n\n                        /** DanmakuFlagConfig recFlag */\n                        recFlag?: (number|null);\n\n                        /** DanmakuFlagConfig recText */\n                        recText?: (string|null);\n\n                        /** DanmakuFlagConfig recSwitch */\n                        recSwitch?: (number|null);\n                    }\n\n                    /** Represents a DanmakuFlagConfig. */\n                    class DanmakuFlagConfig implements IDanmakuFlagConfig {\n\n                        /**\n                         * Constructs a new DanmakuFlagConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmakuFlagConfig);\n\n                        /** DanmakuFlagConfig recFlag. */\n                        public recFlag: number;\n\n                        /** DanmakuFlagConfig recText. */\n                        public recText: string;\n\n                        /** DanmakuFlagConfig recSwitch. */\n                        public recSwitch: number;\n\n                        /**\n                         * Creates a new DanmakuFlagConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmakuFlagConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmakuFlagConfig): bilibili.community.service.dm.v1.DanmakuFlagConfig;\n\n                        /**\n                         * Encodes the specified DanmakuFlagConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlagConfig.verify|verify} messages.\n                         * @param message DanmakuFlagConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmakuFlagConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmakuFlagConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlagConfig.verify|verify} messages.\n                         * @param message DanmakuFlagConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmakuFlagConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmakuFlagConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmakuFlagConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmakuFlagConfig;\n\n                        /**\n                         * Decodes a DanmakuFlagConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmakuFlagConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmakuFlagConfig;\n\n                        /**\n                         * Verifies a DanmakuFlagConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmakuFlagConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmakuFlagConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmakuFlagConfig;\n\n                        /**\n                         * Creates a plain object from a DanmakuFlagConfig message. Also converts values to other types if specified.\n                         * @param message DanmakuFlagConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmakuFlagConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmakuFlagConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmakuFlagConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmuDefaultPlayerConfig. */\n                    interface IDanmuDefaultPlayerConfig {\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuUseDefaultConfig */\n                        playerDanmakuUseDefaultConfig?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedSwitch */\n                        playerDanmakuAiRecommendedSwitch?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevel */\n                        playerDanmakuAiRecommendedLevel?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlocktop */\n                        playerDanmakuBlocktop?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockscroll */\n                        playerDanmakuBlockscroll?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockbottom */\n                        playerDanmakuBlockbottom?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockcolorful */\n                        playerDanmakuBlockcolorful?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockrepeat */\n                        playerDanmakuBlockrepeat?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockspecial */\n                        playerDanmakuBlockspecial?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuOpacity */\n                        playerDanmakuOpacity?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuScalingfactor */\n                        playerDanmakuScalingfactor?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuDomain */\n                        playerDanmakuDomain?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuSpeed */\n                        playerDanmakuSpeed?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig inlinePlayerDanmakuSwitch */\n                        inlinePlayerDanmakuSwitch?: (boolean|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuSeniorModeSwitch */\n                        playerDanmakuSeniorModeSwitch?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2 */\n                        playerDanmakuAiRecommendedLevelV2?: (number|null);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2Map */\n                        playerDanmakuAiRecommendedLevelV2Map?: ({ [k: string]: number }|null);\n                    }\n\n                    /** Represents a DanmuDefaultPlayerConfig. */\n                    class DanmuDefaultPlayerConfig implements IDanmuDefaultPlayerConfig {\n\n                        /**\n                         * Constructs a new DanmuDefaultPlayerConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig);\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuUseDefaultConfig. */\n                        public playerDanmakuUseDefaultConfig: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedSwitch. */\n                        public playerDanmakuAiRecommendedSwitch: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevel. */\n                        public playerDanmakuAiRecommendedLevel: number;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlocktop. */\n                        public playerDanmakuBlocktop: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockscroll. */\n                        public playerDanmakuBlockscroll: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockbottom. */\n                        public playerDanmakuBlockbottom: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockcolorful. */\n                        public playerDanmakuBlockcolorful: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockrepeat. */\n                        public playerDanmakuBlockrepeat: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuBlockspecial. */\n                        public playerDanmakuBlockspecial: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuOpacity. */\n                        public playerDanmakuOpacity: number;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuScalingfactor. */\n                        public playerDanmakuScalingfactor: number;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuDomain. */\n                        public playerDanmakuDomain: number;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuSpeed. */\n                        public playerDanmakuSpeed: number;\n\n                        /** DanmuDefaultPlayerConfig inlinePlayerDanmakuSwitch. */\n                        public inlinePlayerDanmakuSwitch: boolean;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuSeniorModeSwitch. */\n                        public playerDanmakuSeniorModeSwitch: number;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2. */\n                        public playerDanmakuAiRecommendedLevelV2: number;\n\n                        /** DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2Map. */\n                        public playerDanmakuAiRecommendedLevelV2Map: { [k: string]: number };\n\n                        /**\n                         * Creates a new DanmuDefaultPlayerConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmuDefaultPlayerConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig): bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig;\n\n                        /**\n                         * Encodes the specified DanmuDefaultPlayerConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.verify|verify} messages.\n                         * @param message DanmuDefaultPlayerConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmuDefaultPlayerConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.verify|verify} messages.\n                         * @param message DanmuDefaultPlayerConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmuDefaultPlayerConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmuDefaultPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig;\n\n                        /**\n                         * Decodes a DanmuDefaultPlayerConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmuDefaultPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig;\n\n                        /**\n                         * Verifies a DanmuDefaultPlayerConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmuDefaultPlayerConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmuDefaultPlayerConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig;\n\n                        /**\n                         * Creates a plain object from a DanmuDefaultPlayerConfig message. Also converts values to other types if specified.\n                         * @param message DanmuDefaultPlayerConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmuDefaultPlayerConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmuDefaultPlayerConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmuPlayerConfig. */\n                    interface IDanmuPlayerConfig {\n\n                        /** DanmuPlayerConfig playerDanmakuSwitch */\n                        playerDanmakuSwitch?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuSwitchSave */\n                        playerDanmakuSwitchSave?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuUseDefaultConfig */\n                        playerDanmakuUseDefaultConfig?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedSwitch */\n                        playerDanmakuAiRecommendedSwitch?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedLevel */\n                        playerDanmakuAiRecommendedLevel?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuBlocktop */\n                        playerDanmakuBlocktop?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuBlockscroll */\n                        playerDanmakuBlockscroll?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuBlockbottom */\n                        playerDanmakuBlockbottom?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuBlockcolorful */\n                        playerDanmakuBlockcolorful?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuBlockrepeat */\n                        playerDanmakuBlockrepeat?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuBlockspecial */\n                        playerDanmakuBlockspecial?: (boolean|null);\n\n                        /** DanmuPlayerConfig playerDanmakuOpacity */\n                        playerDanmakuOpacity?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuScalingfactor */\n                        playerDanmakuScalingfactor?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuDomain */\n                        playerDanmakuDomain?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuSpeed */\n                        playerDanmakuSpeed?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuEnableblocklist */\n                        playerDanmakuEnableblocklist?: (boolean|null);\n\n                        /** DanmuPlayerConfig inlinePlayerDanmakuSwitch */\n                        inlinePlayerDanmakuSwitch?: (boolean|null);\n\n                        /** DanmuPlayerConfig inlinePlayerDanmakuConfig */\n                        inlinePlayerDanmakuConfig?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuIosSwitchSave */\n                        playerDanmakuIosSwitchSave?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuSeniorModeSwitch */\n                        playerDanmakuSeniorModeSwitch?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2 */\n                        playerDanmakuAiRecommendedLevelV2?: (number|null);\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2Map */\n                        playerDanmakuAiRecommendedLevelV2Map?: ({ [k: string]: number }|null);\n                    }\n\n                    /** Represents a DanmuPlayerConfig. */\n                    class DanmuPlayerConfig implements IDanmuPlayerConfig {\n\n                        /**\n                         * Constructs a new DanmuPlayerConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmuPlayerConfig);\n\n                        /** DanmuPlayerConfig playerDanmakuSwitch. */\n                        public playerDanmakuSwitch: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuSwitchSave. */\n                        public playerDanmakuSwitchSave: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuUseDefaultConfig. */\n                        public playerDanmakuUseDefaultConfig: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedSwitch. */\n                        public playerDanmakuAiRecommendedSwitch: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedLevel. */\n                        public playerDanmakuAiRecommendedLevel: number;\n\n                        /** DanmuPlayerConfig playerDanmakuBlocktop. */\n                        public playerDanmakuBlocktop: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuBlockscroll. */\n                        public playerDanmakuBlockscroll: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuBlockbottom. */\n                        public playerDanmakuBlockbottom: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuBlockcolorful. */\n                        public playerDanmakuBlockcolorful: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuBlockrepeat. */\n                        public playerDanmakuBlockrepeat: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuBlockspecial. */\n                        public playerDanmakuBlockspecial: boolean;\n\n                        /** DanmuPlayerConfig playerDanmakuOpacity. */\n                        public playerDanmakuOpacity: number;\n\n                        /** DanmuPlayerConfig playerDanmakuScalingfactor. */\n                        public playerDanmakuScalingfactor: number;\n\n                        /** DanmuPlayerConfig playerDanmakuDomain. */\n                        public playerDanmakuDomain: number;\n\n                        /** DanmuPlayerConfig playerDanmakuSpeed. */\n                        public playerDanmakuSpeed: number;\n\n                        /** DanmuPlayerConfig playerDanmakuEnableblocklist. */\n                        public playerDanmakuEnableblocklist: boolean;\n\n                        /** DanmuPlayerConfig inlinePlayerDanmakuSwitch. */\n                        public inlinePlayerDanmakuSwitch: boolean;\n\n                        /** DanmuPlayerConfig inlinePlayerDanmakuConfig. */\n                        public inlinePlayerDanmakuConfig: number;\n\n                        /** DanmuPlayerConfig playerDanmakuIosSwitchSave. */\n                        public playerDanmakuIosSwitchSave: number;\n\n                        /** DanmuPlayerConfig playerDanmakuSeniorModeSwitch. */\n                        public playerDanmakuSeniorModeSwitch: number;\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2. */\n                        public playerDanmakuAiRecommendedLevelV2: number;\n\n                        /** DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2Map. */\n                        public playerDanmakuAiRecommendedLevelV2Map: { [k: string]: number };\n\n                        /**\n                         * Creates a new DanmuPlayerConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmuPlayerConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmuPlayerConfig): bilibili.community.service.dm.v1.DanmuPlayerConfig;\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfig.verify|verify} messages.\n                         * @param message DanmuPlayerConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmuPlayerConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfig.verify|verify} messages.\n                         * @param message DanmuPlayerConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmuPlayerConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmuPlayerConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmuPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmuPlayerConfig;\n\n                        /**\n                         * Decodes a DanmuPlayerConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmuPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmuPlayerConfig;\n\n                        /**\n                         * Verifies a DanmuPlayerConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmuPlayerConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmuPlayerConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmuPlayerConfig;\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerConfig message. Also converts values to other types if specified.\n                         * @param message DanmuPlayerConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmuPlayerConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmuPlayerConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmuPlayerConfigPanel. */\n                    interface IDanmuPlayerConfigPanel {\n\n                        /** DanmuPlayerConfigPanel selectionText */\n                        selectionText?: (string|null);\n                    }\n\n                    /** Represents a DanmuPlayerConfigPanel. */\n                    class DanmuPlayerConfigPanel implements IDanmuPlayerConfigPanel {\n\n                        /**\n                         * Constructs a new DanmuPlayerConfigPanel.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel);\n\n                        /** DanmuPlayerConfigPanel selectionText. */\n                        public selectionText: string;\n\n                        /**\n                         * Creates a new DanmuPlayerConfigPanel instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmuPlayerConfigPanel instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel): bilibili.community.service.dm.v1.DanmuPlayerConfigPanel;\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfigPanel message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.verify|verify} messages.\n                         * @param message DanmuPlayerConfigPanel message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfigPanel message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.verify|verify} messages.\n                         * @param message DanmuPlayerConfigPanel message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmuPlayerConfigPanel message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmuPlayerConfigPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmuPlayerConfigPanel;\n\n                        /**\n                         * Decodes a DanmuPlayerConfigPanel message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmuPlayerConfigPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmuPlayerConfigPanel;\n\n                        /**\n                         * Verifies a DanmuPlayerConfigPanel message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmuPlayerConfigPanel message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmuPlayerConfigPanel\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmuPlayerConfigPanel;\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerConfigPanel message. Also converts values to other types if specified.\n                         * @param message DanmuPlayerConfigPanel\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmuPlayerConfigPanel, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmuPlayerConfigPanel to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerConfigPanel\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmuPlayerDynamicConfig. */\n                    interface IDanmuPlayerDynamicConfig {\n\n                        /** DanmuPlayerDynamicConfig progress */\n                        progress?: (number|null);\n\n                        /** DanmuPlayerDynamicConfig playerDanmakuDomain */\n                        playerDanmakuDomain?: (number|null);\n                    }\n\n                    /** Represents a DanmuPlayerDynamicConfig. */\n                    class DanmuPlayerDynamicConfig implements IDanmuPlayerDynamicConfig {\n\n                        /**\n                         * Constructs a new DanmuPlayerDynamicConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig);\n\n                        /** DanmuPlayerDynamicConfig progress. */\n                        public progress: number;\n\n                        /** DanmuPlayerDynamicConfig playerDanmakuDomain. */\n                        public playerDanmakuDomain: number;\n\n                        /**\n                         * Creates a new DanmuPlayerDynamicConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmuPlayerDynamicConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig): bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig;\n\n                        /**\n                         * Encodes the specified DanmuPlayerDynamicConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.verify|verify} messages.\n                         * @param message DanmuPlayerDynamicConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmuPlayerDynamicConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.verify|verify} messages.\n                         * @param message DanmuPlayerDynamicConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmuPlayerDynamicConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmuPlayerDynamicConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig;\n\n                        /**\n                         * Decodes a DanmuPlayerDynamicConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmuPlayerDynamicConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig;\n\n                        /**\n                         * Verifies a DanmuPlayerDynamicConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmuPlayerDynamicConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmuPlayerDynamicConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig;\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerDynamicConfig message. Also converts values to other types if specified.\n                         * @param message DanmuPlayerDynamicConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmuPlayerDynamicConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerDynamicConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmuPlayerViewConfig. */\n                    interface IDanmuPlayerViewConfig {\n\n                        /** DanmuPlayerViewConfig danmukuDefaultPlayerConfig */\n                        danmukuDefaultPlayerConfig?: (bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig|null);\n\n                        /** DanmuPlayerViewConfig danmukuPlayerConfig */\n                        danmukuPlayerConfig?: (bilibili.community.service.dm.v1.IDanmuPlayerConfig|null);\n\n                        /** DanmuPlayerViewConfig danmukuPlayerDynamicConfig */\n                        danmukuPlayerDynamicConfig?: (bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig[]|null);\n\n                        /** DanmuPlayerViewConfig danmukuPlayerConfigPanel */\n                        danmukuPlayerConfigPanel?: (bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel|null);\n                    }\n\n                    /** Represents a DanmuPlayerViewConfig. */\n                    class DanmuPlayerViewConfig implements IDanmuPlayerViewConfig {\n\n                        /**\n                         * Constructs a new DanmuPlayerViewConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmuPlayerViewConfig);\n\n                        /** DanmuPlayerViewConfig danmukuDefaultPlayerConfig. */\n                        public danmukuDefaultPlayerConfig?: (bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig|null);\n\n                        /** DanmuPlayerViewConfig danmukuPlayerConfig. */\n                        public danmukuPlayerConfig?: (bilibili.community.service.dm.v1.IDanmuPlayerConfig|null);\n\n                        /** DanmuPlayerViewConfig danmukuPlayerDynamicConfig. */\n                        public danmukuPlayerDynamicConfig: bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig[];\n\n                        /** DanmuPlayerViewConfig danmukuPlayerConfigPanel. */\n                        public danmukuPlayerConfigPanel?: (bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel|null);\n\n                        /**\n                         * Creates a new DanmuPlayerViewConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmuPlayerViewConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmuPlayerViewConfig): bilibili.community.service.dm.v1.DanmuPlayerViewConfig;\n\n                        /**\n                         * Encodes the specified DanmuPlayerViewConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerViewConfig.verify|verify} messages.\n                         * @param message DanmuPlayerViewConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmuPlayerViewConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmuPlayerViewConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerViewConfig.verify|verify} messages.\n                         * @param message DanmuPlayerViewConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmuPlayerViewConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmuPlayerViewConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmuPlayerViewConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmuPlayerViewConfig;\n\n                        /**\n                         * Decodes a DanmuPlayerViewConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmuPlayerViewConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmuPlayerViewConfig;\n\n                        /**\n                         * Verifies a DanmuPlayerViewConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmuPlayerViewConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmuPlayerViewConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmuPlayerViewConfig;\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerViewConfig message. Also converts values to other types if specified.\n                         * @param message DanmuPlayerViewConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmuPlayerViewConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmuPlayerViewConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerViewConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DanmuWebPlayerConfig. */\n                    interface IDanmuWebPlayerConfig {\n\n                        /** DanmuWebPlayerConfig dmSwitch */\n                        dmSwitch?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig aiSwitch */\n                        aiSwitch?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig aiLevel */\n                        aiLevel?: (number|null);\n\n                        /** DanmuWebPlayerConfig blocktop */\n                        blocktop?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig blockscroll */\n                        blockscroll?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig blockbottom */\n                        blockbottom?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig blockcolor */\n                        blockcolor?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig blockspecial */\n                        blockspecial?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig preventshade */\n                        preventshade?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig dmask */\n                        dmask?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig opacity */\n                        opacity?: (number|null);\n\n                        /** DanmuWebPlayerConfig dmarea */\n                        dmarea?: (number|null);\n\n                        /** DanmuWebPlayerConfig speedplus */\n                        speedplus?: (number|null);\n\n                        /** DanmuWebPlayerConfig fontsize */\n                        fontsize?: (number|null);\n\n                        /** DanmuWebPlayerConfig screensync */\n                        screensync?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig speedsync */\n                        speedsync?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig fontfamily */\n                        fontfamily?: (string|null);\n\n                        /** DanmuWebPlayerConfig bold */\n                        bold?: (boolean|null);\n\n                        /** DanmuWebPlayerConfig fontborder */\n                        fontborder?: (number|null);\n\n                        /** DanmuWebPlayerConfig drawType */\n                        drawType?: (string|null);\n\n                        /** DanmuWebPlayerConfig seniorModeSwitch */\n                        seniorModeSwitch?: (number|null);\n\n                        /** DanmuWebPlayerConfig aiLevelV2 */\n                        aiLevelV2?: (number|null);\n\n                        /** DanmuWebPlayerConfig aiLevelV2Map */\n                        aiLevelV2Map?: ({ [k: string]: number }|null);\n                    }\n\n                    /** Represents a DanmuWebPlayerConfig. */\n                    class DanmuWebPlayerConfig implements IDanmuWebPlayerConfig {\n\n                        /**\n                         * Constructs a new DanmuWebPlayerConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDanmuWebPlayerConfig);\n\n                        /** DanmuWebPlayerConfig dmSwitch. */\n                        public dmSwitch: boolean;\n\n                        /** DanmuWebPlayerConfig aiSwitch. */\n                        public aiSwitch: boolean;\n\n                        /** DanmuWebPlayerConfig aiLevel. */\n                        public aiLevel: number;\n\n                        /** DanmuWebPlayerConfig blocktop. */\n                        public blocktop: boolean;\n\n                        /** DanmuWebPlayerConfig blockscroll. */\n                        public blockscroll: boolean;\n\n                        /** DanmuWebPlayerConfig blockbottom. */\n                        public blockbottom: boolean;\n\n                        /** DanmuWebPlayerConfig blockcolor. */\n                        public blockcolor: boolean;\n\n                        /** DanmuWebPlayerConfig blockspecial. */\n                        public blockspecial: boolean;\n\n                        /** DanmuWebPlayerConfig preventshade. */\n                        public preventshade: boolean;\n\n                        /** DanmuWebPlayerConfig dmask. */\n                        public dmask: boolean;\n\n                        /** DanmuWebPlayerConfig opacity. */\n                        public opacity: number;\n\n                        /** DanmuWebPlayerConfig dmarea. */\n                        public dmarea: number;\n\n                        /** DanmuWebPlayerConfig speedplus. */\n                        public speedplus: number;\n\n                        /** DanmuWebPlayerConfig fontsize. */\n                        public fontsize: number;\n\n                        /** DanmuWebPlayerConfig screensync. */\n                        public screensync: boolean;\n\n                        /** DanmuWebPlayerConfig speedsync. */\n                        public speedsync: boolean;\n\n                        /** DanmuWebPlayerConfig fontfamily. */\n                        public fontfamily: string;\n\n                        /** DanmuWebPlayerConfig bold. */\n                        public bold: boolean;\n\n                        /** DanmuWebPlayerConfig fontborder. */\n                        public fontborder: number;\n\n                        /** DanmuWebPlayerConfig drawType. */\n                        public drawType: string;\n\n                        /** DanmuWebPlayerConfig seniorModeSwitch. */\n                        public seniorModeSwitch: number;\n\n                        /** DanmuWebPlayerConfig aiLevelV2. */\n                        public aiLevelV2: number;\n\n                        /** DanmuWebPlayerConfig aiLevelV2Map. */\n                        public aiLevelV2Map: { [k: string]: number };\n\n                        /**\n                         * Creates a new DanmuWebPlayerConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DanmuWebPlayerConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDanmuWebPlayerConfig): bilibili.community.service.dm.v1.DanmuWebPlayerConfig;\n\n                        /**\n                         * Encodes the specified DanmuWebPlayerConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuWebPlayerConfig.verify|verify} messages.\n                         * @param message DanmuWebPlayerConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDanmuWebPlayerConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DanmuWebPlayerConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuWebPlayerConfig.verify|verify} messages.\n                         * @param message DanmuWebPlayerConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDanmuWebPlayerConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DanmuWebPlayerConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DanmuWebPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DanmuWebPlayerConfig;\n\n                        /**\n                         * Decodes a DanmuWebPlayerConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DanmuWebPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DanmuWebPlayerConfig;\n\n                        /**\n                         * Verifies a DanmuWebPlayerConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DanmuWebPlayerConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DanmuWebPlayerConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DanmuWebPlayerConfig;\n\n                        /**\n                         * Creates a plain object from a DanmuWebPlayerConfig message. Also converts values to other types if specified.\n                         * @param message DanmuWebPlayerConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DanmuWebPlayerConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DanmuWebPlayerConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DanmuWebPlayerConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** DMAttrBit enum. */\n                    enum DMAttrBit {\n                        DMAttrBitProtect = 0,\n                        DMAttrBitFromLive = 1,\n                        DMAttrHighLike = 2\n                    }\n\n                    /** Properties of a DmColorful. */\n                    interface IDmColorful {\n\n                        /** DmColorful type */\n                        type?: (bilibili.community.service.dm.v1.DmColorfulType|null);\n\n                        /** DmColorful src */\n                        src?: (string|null);\n                    }\n\n                    /** Represents a DmColorful. */\n                    class DmColorful implements IDmColorful {\n\n                        /**\n                         * Constructs a new DmColorful.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmColorful);\n\n                        /** DmColorful type. */\n                        public type: bilibili.community.service.dm.v1.DmColorfulType;\n\n                        /** DmColorful src. */\n                        public src: string;\n\n                        /**\n                         * Creates a new DmColorful instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmColorful instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmColorful): bilibili.community.service.dm.v1.DmColorful;\n\n                        /**\n                         * Encodes the specified DmColorful message. Does not implicitly {@link bilibili.community.service.dm.v1.DmColorful.verify|verify} messages.\n                         * @param message DmColorful message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmColorful, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmColorful message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmColorful.verify|verify} messages.\n                         * @param message DmColorful message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmColorful, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmColorful message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmColorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmColorful;\n\n                        /**\n                         * Decodes a DmColorful message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmColorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmColorful;\n\n                        /**\n                         * Verifies a DmColorful message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmColorful message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmColorful\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmColorful;\n\n                        /**\n                         * Creates a plain object from a DmColorful message. Also converts values to other types if specified.\n                         * @param message DmColorful\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmColorful, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmColorful to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmColorful\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** DmColorfulType enum. */\n                    enum DmColorfulType {\n                        NoneType = 0,\n                        VipGradualColor = 60001\n                    }\n\n                    /** Properties of a DmExpoReportReq. */\n                    interface IDmExpoReportReq {\n\n                        /** DmExpoReportReq sessionId */\n                        sessionId?: (string|null);\n\n                        /** DmExpoReportReq oid */\n                        oid?: (number|Long|null);\n\n                        /** DmExpoReportReq spmid */\n                        spmid?: (string|null);\n                    }\n\n                    /** Represents a DmExpoReportReq. */\n                    class DmExpoReportReq implements IDmExpoReportReq {\n\n                        /**\n                         * Constructs a new DmExpoReportReq.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmExpoReportReq);\n\n                        /** DmExpoReportReq sessionId. */\n                        public sessionId: string;\n\n                        /** DmExpoReportReq oid. */\n                        public oid: (number|Long);\n\n                        /** DmExpoReportReq spmid. */\n                        public spmid: string;\n\n                        /**\n                         * Creates a new DmExpoReportReq instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmExpoReportReq instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmExpoReportReq): bilibili.community.service.dm.v1.DmExpoReportReq;\n\n                        /**\n                         * Encodes the specified DmExpoReportReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportReq.verify|verify} messages.\n                         * @param message DmExpoReportReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmExpoReportReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmExpoReportReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportReq.verify|verify} messages.\n                         * @param message DmExpoReportReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmExpoReportReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmExpoReportReq message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmExpoReportReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmExpoReportReq;\n\n                        /**\n                         * Decodes a DmExpoReportReq message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmExpoReportReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmExpoReportReq;\n\n                        /**\n                         * Verifies a DmExpoReportReq message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmExpoReportReq message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmExpoReportReq\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmExpoReportReq;\n\n                        /**\n                         * Creates a plain object from a DmExpoReportReq message. Also converts values to other types if specified.\n                         * @param message DmExpoReportReq\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmExpoReportReq, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmExpoReportReq to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmExpoReportReq\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmExpoReportRes. */\n                    interface IDmExpoReportRes {\n                    }\n\n                    /** Represents a DmExpoReportRes. */\n                    class DmExpoReportRes implements IDmExpoReportRes {\n\n                        /**\n                         * Constructs a new DmExpoReportRes.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmExpoReportRes);\n\n                        /**\n                         * Creates a new DmExpoReportRes instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmExpoReportRes instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmExpoReportRes): bilibili.community.service.dm.v1.DmExpoReportRes;\n\n                        /**\n                         * Encodes the specified DmExpoReportRes message. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportRes.verify|verify} messages.\n                         * @param message DmExpoReportRes message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmExpoReportRes, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmExpoReportRes message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportRes.verify|verify} messages.\n                         * @param message DmExpoReportRes message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmExpoReportRes, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmExpoReportRes message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmExpoReportRes\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmExpoReportRes;\n\n                        /**\n                         * Decodes a DmExpoReportRes message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmExpoReportRes\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmExpoReportRes;\n\n                        /**\n                         * Verifies a DmExpoReportRes message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmExpoReportRes message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmExpoReportRes\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmExpoReportRes;\n\n                        /**\n                         * Creates a plain object from a DmExpoReportRes message. Also converts values to other types if specified.\n                         * @param message DmExpoReportRes\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmExpoReportRes, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmExpoReportRes to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmExpoReportRes\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmPlayerConfigReq. */\n                    interface IDmPlayerConfigReq {\n\n                        /** DmPlayerConfigReq ts */\n                        ts?: (number|Long|null);\n\n                        /** DmPlayerConfigReq switch */\n                        \"switch\"?: (bilibili.community.service.dm.v1.IPlayerDanmakuSwitch|null);\n\n                        /** DmPlayerConfigReq switchSave */\n                        switchSave?: (bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave|null);\n\n                        /** DmPlayerConfigReq useDefaultConfig */\n                        useDefaultConfig?: (bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig|null);\n\n                        /** DmPlayerConfigReq aiRecommendedSwitch */\n                        aiRecommendedSwitch?: (bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch|null);\n\n                        /** DmPlayerConfigReq aiRecommendedLevel */\n                        aiRecommendedLevel?: (bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel|null);\n\n                        /** DmPlayerConfigReq blocktop */\n                        blocktop?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop|null);\n\n                        /** DmPlayerConfigReq blockscroll */\n                        blockscroll?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll|null);\n\n                        /** DmPlayerConfigReq blockbottom */\n                        blockbottom?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom|null);\n\n                        /** DmPlayerConfigReq blockcolorful */\n                        blockcolorful?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful|null);\n\n                        /** DmPlayerConfigReq blockrepeat */\n                        blockrepeat?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat|null);\n\n                        /** DmPlayerConfigReq blockspecial */\n                        blockspecial?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial|null);\n\n                        /** DmPlayerConfigReq opacity */\n                        opacity?: (bilibili.community.service.dm.v1.IPlayerDanmakuOpacity|null);\n\n                        /** DmPlayerConfigReq scalingfactor */\n                        scalingfactor?: (bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor|null);\n\n                        /** DmPlayerConfigReq domain */\n                        domain?: (bilibili.community.service.dm.v1.IPlayerDanmakuDomain|null);\n\n                        /** DmPlayerConfigReq speed */\n                        speed?: (bilibili.community.service.dm.v1.IPlayerDanmakuSpeed|null);\n\n                        /** DmPlayerConfigReq enableblocklist */\n                        enableblocklist?: (bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist|null);\n\n                        /** DmPlayerConfigReq inlinePlayerDanmakuSwitch */\n                        inlinePlayerDanmakuSwitch?: (bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch|null);\n\n                        /** DmPlayerConfigReq seniorModeSwitch */\n                        seniorModeSwitch?: (bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch|null);\n\n                        /** DmPlayerConfigReq aiRecommendedLevelV2 */\n                        aiRecommendedLevelV2?: (bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2|null);\n                    }\n\n                    /** Represents a DmPlayerConfigReq. */\n                    class DmPlayerConfigReq implements IDmPlayerConfigReq {\n\n                        /**\n                         * Constructs a new DmPlayerConfigReq.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmPlayerConfigReq);\n\n                        /** DmPlayerConfigReq ts. */\n                        public ts: (number|Long);\n\n                        /** DmPlayerConfigReq switch. */\n                        public switch?: (bilibili.community.service.dm.v1.IPlayerDanmakuSwitch|null);\n\n                        /** DmPlayerConfigReq switchSave. */\n                        public switchSave?: (bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave|null);\n\n                        /** DmPlayerConfigReq useDefaultConfig. */\n                        public useDefaultConfig?: (bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig|null);\n\n                        /** DmPlayerConfigReq aiRecommendedSwitch. */\n                        public aiRecommendedSwitch?: (bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch|null);\n\n                        /** DmPlayerConfigReq aiRecommendedLevel. */\n                        public aiRecommendedLevel?: (bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel|null);\n\n                        /** DmPlayerConfigReq blocktop. */\n                        public blocktop?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop|null);\n\n                        /** DmPlayerConfigReq blockscroll. */\n                        public blockscroll?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll|null);\n\n                        /** DmPlayerConfigReq blockbottom. */\n                        public blockbottom?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom|null);\n\n                        /** DmPlayerConfigReq blockcolorful. */\n                        public blockcolorful?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful|null);\n\n                        /** DmPlayerConfigReq blockrepeat. */\n                        public blockrepeat?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat|null);\n\n                        /** DmPlayerConfigReq blockspecial. */\n                        public blockspecial?: (bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial|null);\n\n                        /** DmPlayerConfigReq opacity. */\n                        public opacity?: (bilibili.community.service.dm.v1.IPlayerDanmakuOpacity|null);\n\n                        /** DmPlayerConfigReq scalingfactor. */\n                        public scalingfactor?: (bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor|null);\n\n                        /** DmPlayerConfigReq domain. */\n                        public domain?: (bilibili.community.service.dm.v1.IPlayerDanmakuDomain|null);\n\n                        /** DmPlayerConfigReq speed. */\n                        public speed?: (bilibili.community.service.dm.v1.IPlayerDanmakuSpeed|null);\n\n                        /** DmPlayerConfigReq enableblocklist. */\n                        public enableblocklist?: (bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist|null);\n\n                        /** DmPlayerConfigReq inlinePlayerDanmakuSwitch. */\n                        public inlinePlayerDanmakuSwitch?: (bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch|null);\n\n                        /** DmPlayerConfigReq seniorModeSwitch. */\n                        public seniorModeSwitch?: (bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch|null);\n\n                        /** DmPlayerConfigReq aiRecommendedLevelV2. */\n                        public aiRecommendedLevelV2?: (bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2|null);\n\n                        /**\n                         * Creates a new DmPlayerConfigReq instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmPlayerConfigReq instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmPlayerConfigReq): bilibili.community.service.dm.v1.DmPlayerConfigReq;\n\n                        /**\n                         * Encodes the specified DmPlayerConfigReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmPlayerConfigReq.verify|verify} messages.\n                         * @param message DmPlayerConfigReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmPlayerConfigReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmPlayerConfigReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmPlayerConfigReq.verify|verify} messages.\n                         * @param message DmPlayerConfigReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmPlayerConfigReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmPlayerConfigReq message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmPlayerConfigReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmPlayerConfigReq;\n\n                        /**\n                         * Decodes a DmPlayerConfigReq message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmPlayerConfigReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmPlayerConfigReq;\n\n                        /**\n                         * Verifies a DmPlayerConfigReq message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmPlayerConfigReq message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmPlayerConfigReq\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmPlayerConfigReq;\n\n                        /**\n                         * Creates a plain object from a DmPlayerConfigReq message. Also converts values to other types if specified.\n                         * @param message DmPlayerConfigReq\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmPlayerConfigReq, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmPlayerConfigReq to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmPlayerConfigReq\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegConfig. */\n                    interface IDmSegConfig {\n\n                        /** DmSegConfig pageSize */\n                        pageSize?: (number|Long|null);\n\n                        /** DmSegConfig total */\n                        total?: (number|Long|null);\n                    }\n\n                    /** Represents a DmSegConfig. */\n                    class DmSegConfig implements IDmSegConfig {\n\n                        /**\n                         * Constructs a new DmSegConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegConfig);\n\n                        /** DmSegConfig pageSize. */\n                        public pageSize: (number|Long);\n\n                        /** DmSegConfig total. */\n                        public total: (number|Long);\n\n                        /**\n                         * Creates a new DmSegConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegConfig): bilibili.community.service.dm.v1.DmSegConfig;\n\n                        /**\n                         * Encodes the specified DmSegConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegConfig.verify|verify} messages.\n                         * @param message DmSegConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegConfig.verify|verify} messages.\n                         * @param message DmSegConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegConfig;\n\n                        /**\n                         * Decodes a DmSegConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegConfig;\n\n                        /**\n                         * Verifies a DmSegConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegConfig;\n\n                        /**\n                         * Creates a plain object from a DmSegConfig message. Also converts values to other types if specified.\n                         * @param message DmSegConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegMobileReply. */\n                    interface IDmSegMobileReply {\n\n                        /** DmSegMobileReply elems */\n                        elems?: (bilibili.community.service.dm.v1.IDanmakuElem[]|null);\n\n                        /** DmSegMobileReply state */\n                        state?: (number|null);\n\n                        /** DmSegMobileReply aiFlag */\n                        aiFlag?: (bilibili.community.service.dm.v1.IDanmakuAIFlag|null);\n\n                        /** DmSegMobileReply colorfulSrc */\n                        colorfulSrc?: (bilibili.community.service.dm.v1.IDmColorful[]|null);\n                    }\n\n                    /** Represents a DmSegMobileReply. */\n                    class DmSegMobileReply implements IDmSegMobileReply {\n\n                        /**\n                         * Constructs a new DmSegMobileReply.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegMobileReply);\n\n                        /** DmSegMobileReply elems. */\n                        public elems: bilibili.community.service.dm.v1.IDanmakuElem[];\n\n                        /** DmSegMobileReply state. */\n                        public state: number;\n\n                        /** DmSegMobileReply aiFlag. */\n                        public aiFlag?: (bilibili.community.service.dm.v1.IDanmakuAIFlag|null);\n\n                        /** DmSegMobileReply colorfulSrc. */\n                        public colorfulSrc: bilibili.community.service.dm.v1.IDmColorful[];\n\n                        /**\n                         * Creates a new DmSegMobileReply instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegMobileReply instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegMobileReply): bilibili.community.service.dm.v1.DmSegMobileReply;\n\n                        /**\n                         * Encodes the specified DmSegMobileReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReply.verify|verify} messages.\n                         * @param message DmSegMobileReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegMobileReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegMobileReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReply.verify|verify} messages.\n                         * @param message DmSegMobileReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegMobileReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegMobileReply message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegMobileReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegMobileReply;\n\n                        /**\n                         * Decodes a DmSegMobileReply message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegMobileReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegMobileReply;\n\n                        /**\n                         * Verifies a DmSegMobileReply message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegMobileReply message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegMobileReply\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegMobileReply;\n\n                        /**\n                         * Creates a plain object from a DmSegMobileReply message. Also converts values to other types if specified.\n                         * @param message DmSegMobileReply\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegMobileReply, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegMobileReply to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegMobileReply\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegMobileReq. */\n                    interface IDmSegMobileReq {\n\n                        /** DmSegMobileReq pid */\n                        pid?: (number|Long|null);\n\n                        /** DmSegMobileReq oid */\n                        oid?: (number|Long|null);\n\n                        /** DmSegMobileReq type */\n                        type?: (number|null);\n\n                        /** DmSegMobileReq segmentIndex */\n                        segmentIndex?: (number|Long|null);\n\n                        /** DmSegMobileReq teenagersMode */\n                        teenagersMode?: (number|null);\n\n                        /** DmSegMobileReq ps */\n                        ps?: (number|Long|null);\n\n                        /** DmSegMobileReq pe */\n                        pe?: (number|Long|null);\n\n                        /** DmSegMobileReq pullMode */\n                        pullMode?: (number|null);\n\n                        /** DmSegMobileReq fromScene */\n                        fromScene?: (number|null);\n                    }\n\n                    /** Represents a DmSegMobileReq. */\n                    class DmSegMobileReq implements IDmSegMobileReq {\n\n                        /**\n                         * Constructs a new DmSegMobileReq.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegMobileReq);\n\n                        /** DmSegMobileReq pid. */\n                        public pid: (number|Long);\n\n                        /** DmSegMobileReq oid. */\n                        public oid: (number|Long);\n\n                        /** DmSegMobileReq type. */\n                        public type: number;\n\n                        /** DmSegMobileReq segmentIndex. */\n                        public segmentIndex: (number|Long);\n\n                        /** DmSegMobileReq teenagersMode. */\n                        public teenagersMode: number;\n\n                        /** DmSegMobileReq ps. */\n                        public ps: (number|Long);\n\n                        /** DmSegMobileReq pe. */\n                        public pe: (number|Long);\n\n                        /** DmSegMobileReq pullMode. */\n                        public pullMode: number;\n\n                        /** DmSegMobileReq fromScene. */\n                        public fromScene: number;\n\n                        /**\n                         * Creates a new DmSegMobileReq instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegMobileReq instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegMobileReq): bilibili.community.service.dm.v1.DmSegMobileReq;\n\n                        /**\n                         * Encodes the specified DmSegMobileReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReq.verify|verify} messages.\n                         * @param message DmSegMobileReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegMobileReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegMobileReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReq.verify|verify} messages.\n                         * @param message DmSegMobileReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegMobileReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegMobileReq message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegMobileReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegMobileReq;\n\n                        /**\n                         * Decodes a DmSegMobileReq message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegMobileReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegMobileReq;\n\n                        /**\n                         * Verifies a DmSegMobileReq message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegMobileReq message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegMobileReq\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegMobileReq;\n\n                        /**\n                         * Creates a plain object from a DmSegMobileReq message. Also converts values to other types if specified.\n                         * @param message DmSegMobileReq\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegMobileReq, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegMobileReq to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegMobileReq\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegOttReply. */\n                    interface IDmSegOttReply {\n\n                        /** DmSegOttReply closed */\n                        closed?: (boolean|null);\n\n                        /** DmSegOttReply elems */\n                        elems?: (bilibili.community.service.dm.v1.IDanmakuElem[]|null);\n                    }\n\n                    /** Represents a DmSegOttReply. */\n                    class DmSegOttReply implements IDmSegOttReply {\n\n                        /**\n                         * Constructs a new DmSegOttReply.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegOttReply);\n\n                        /** DmSegOttReply closed. */\n                        public closed: boolean;\n\n                        /** DmSegOttReply elems. */\n                        public elems: bilibili.community.service.dm.v1.IDanmakuElem[];\n\n                        /**\n                         * Creates a new DmSegOttReply instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegOttReply instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegOttReply): bilibili.community.service.dm.v1.DmSegOttReply;\n\n                        /**\n                         * Encodes the specified DmSegOttReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReply.verify|verify} messages.\n                         * @param message DmSegOttReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegOttReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegOttReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReply.verify|verify} messages.\n                         * @param message DmSegOttReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegOttReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegOttReply message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegOttReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegOttReply;\n\n                        /**\n                         * Decodes a DmSegOttReply message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegOttReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegOttReply;\n\n                        /**\n                         * Verifies a DmSegOttReply message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegOttReply message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegOttReply\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegOttReply;\n\n                        /**\n                         * Creates a plain object from a DmSegOttReply message. Also converts values to other types if specified.\n                         * @param message DmSegOttReply\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegOttReply, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegOttReply to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegOttReply\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegOttReq. */\n                    interface IDmSegOttReq {\n\n                        /** DmSegOttReq pid */\n                        pid?: (number|Long|null);\n\n                        /** DmSegOttReq oid */\n                        oid?: (number|Long|null);\n\n                        /** DmSegOttReq type */\n                        type?: (number|null);\n\n                        /** DmSegOttReq segmentIndex */\n                        segmentIndex?: (number|Long|null);\n                    }\n\n                    /** Represents a DmSegOttReq. */\n                    class DmSegOttReq implements IDmSegOttReq {\n\n                        /**\n                         * Constructs a new DmSegOttReq.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegOttReq);\n\n                        /** DmSegOttReq pid. */\n                        public pid: (number|Long);\n\n                        /** DmSegOttReq oid. */\n                        public oid: (number|Long);\n\n                        /** DmSegOttReq type. */\n                        public type: number;\n\n                        /** DmSegOttReq segmentIndex. */\n                        public segmentIndex: (number|Long);\n\n                        /**\n                         * Creates a new DmSegOttReq instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegOttReq instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegOttReq): bilibili.community.service.dm.v1.DmSegOttReq;\n\n                        /**\n                         * Encodes the specified DmSegOttReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReq.verify|verify} messages.\n                         * @param message DmSegOttReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegOttReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegOttReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReq.verify|verify} messages.\n                         * @param message DmSegOttReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegOttReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegOttReq message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegOttReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegOttReq;\n\n                        /**\n                         * Decodes a DmSegOttReq message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegOttReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegOttReq;\n\n                        /**\n                         * Verifies a DmSegOttReq message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegOttReq message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegOttReq\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegOttReq;\n\n                        /**\n                         * Creates a plain object from a DmSegOttReq message. Also converts values to other types if specified.\n                         * @param message DmSegOttReq\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegOttReq, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegOttReq to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegOttReq\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegSDKReply. */\n                    interface IDmSegSDKReply {\n\n                        /** DmSegSDKReply closed */\n                        closed?: (boolean|null);\n\n                        /** DmSegSDKReply elems */\n                        elems?: (bilibili.community.service.dm.v1.IDanmakuElem[]|null);\n                    }\n\n                    /** Represents a DmSegSDKReply. */\n                    class DmSegSDKReply implements IDmSegSDKReply {\n\n                        /**\n                         * Constructs a new DmSegSDKReply.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegSDKReply);\n\n                        /** DmSegSDKReply closed. */\n                        public closed: boolean;\n\n                        /** DmSegSDKReply elems. */\n                        public elems: bilibili.community.service.dm.v1.IDanmakuElem[];\n\n                        /**\n                         * Creates a new DmSegSDKReply instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegSDKReply instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegSDKReply): bilibili.community.service.dm.v1.DmSegSDKReply;\n\n                        /**\n                         * Encodes the specified DmSegSDKReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReply.verify|verify} messages.\n                         * @param message DmSegSDKReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegSDKReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegSDKReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReply.verify|verify} messages.\n                         * @param message DmSegSDKReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegSDKReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegSDKReply message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegSDKReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegSDKReply;\n\n                        /**\n                         * Decodes a DmSegSDKReply message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegSDKReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegSDKReply;\n\n                        /**\n                         * Verifies a DmSegSDKReply message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegSDKReply message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegSDKReply\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegSDKReply;\n\n                        /**\n                         * Creates a plain object from a DmSegSDKReply message. Also converts values to other types if specified.\n                         * @param message DmSegSDKReply\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegSDKReply, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegSDKReply to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegSDKReply\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmSegSDKReq. */\n                    interface IDmSegSDKReq {\n\n                        /** DmSegSDKReq pid */\n                        pid?: (number|Long|null);\n\n                        /** DmSegSDKReq oid */\n                        oid?: (number|Long|null);\n\n                        /** DmSegSDKReq type */\n                        type?: (number|null);\n\n                        /** DmSegSDKReq segmentIndex */\n                        segmentIndex?: (number|Long|null);\n                    }\n\n                    /** Represents a DmSegSDKReq. */\n                    class DmSegSDKReq implements IDmSegSDKReq {\n\n                        /**\n                         * Constructs a new DmSegSDKReq.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmSegSDKReq);\n\n                        /** DmSegSDKReq pid. */\n                        public pid: (number|Long);\n\n                        /** DmSegSDKReq oid. */\n                        public oid: (number|Long);\n\n                        /** DmSegSDKReq type. */\n                        public type: number;\n\n                        /** DmSegSDKReq segmentIndex. */\n                        public segmentIndex: (number|Long);\n\n                        /**\n                         * Creates a new DmSegSDKReq instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmSegSDKReq instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmSegSDKReq): bilibili.community.service.dm.v1.DmSegSDKReq;\n\n                        /**\n                         * Encodes the specified DmSegSDKReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReq.verify|verify} messages.\n                         * @param message DmSegSDKReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmSegSDKReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmSegSDKReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReq.verify|verify} messages.\n                         * @param message DmSegSDKReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmSegSDKReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmSegSDKReq message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmSegSDKReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmSegSDKReq;\n\n                        /**\n                         * Decodes a DmSegSDKReq message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmSegSDKReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmSegSDKReq;\n\n                        /**\n                         * Verifies a DmSegSDKReq message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmSegSDKReq message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmSegSDKReq\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmSegSDKReq;\n\n                        /**\n                         * Creates a plain object from a DmSegSDKReq message. Also converts values to other types if specified.\n                         * @param message DmSegSDKReq\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmSegSDKReq, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmSegSDKReq to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmSegSDKReq\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmViewReply. */\n                    interface IDmViewReply {\n\n                        /** DmViewReply closed */\n                        closed?: (boolean|null);\n\n                        /** DmViewReply mask */\n                        mask?: (bilibili.community.service.dm.v1.IVideoMask|null);\n\n                        /** DmViewReply subtitle */\n                        subtitle?: (bilibili.community.service.dm.v1.IVideoSubtitle|null);\n\n                        /** DmViewReply specialDms */\n                        specialDms?: (string[]|null);\n\n                        /** DmViewReply aiFlag */\n                        aiFlag?: (bilibili.community.service.dm.v1.IDanmakuFlagConfig|null);\n\n                        /** DmViewReply playerConfig */\n                        playerConfig?: (bilibili.community.service.dm.v1.IDanmuPlayerViewConfig|null);\n\n                        /** DmViewReply sendBoxStyle */\n                        sendBoxStyle?: (number|null);\n\n                        /** DmViewReply allow */\n                        allow?: (boolean|null);\n\n                        /** DmViewReply checkBox */\n                        checkBox?: (string|null);\n\n                        /** DmViewReply checkBoxShowMsg */\n                        checkBoxShowMsg?: (string|null);\n\n                        /** DmViewReply textPlaceholder */\n                        textPlaceholder?: (string|null);\n\n                        /** DmViewReply inputPlaceholder */\n                        inputPlaceholder?: (string|null);\n\n                        /** DmViewReply reportFilterContent */\n                        reportFilterContent?: (string[]|null);\n\n                        /** DmViewReply expoReport */\n                        expoReport?: (bilibili.community.service.dm.v1.IExpoReport|null);\n\n                        /** DmViewReply buzzwordConfig */\n                        buzzwordConfig?: (bilibili.community.service.dm.v1.IBuzzwordConfig|null);\n\n                        /** DmViewReply expressions */\n                        expressions?: (bilibili.community.service.dm.v1.IExpressions[]|null);\n\n                        /** DmViewReply postPanel */\n                        postPanel?: (bilibili.community.service.dm.v1.IPostPanel[]|null);\n\n                        /** DmViewReply activityMeta */\n                        activityMeta?: (string[]|null);\n\n                        /** DmViewReply postPanel2 */\n                        postPanel2?: (bilibili.community.service.dm.v1.IPostPanelV2[]|null);\n                    }\n\n                    /** Represents a DmViewReply. */\n                    class DmViewReply implements IDmViewReply {\n\n                        /**\n                         * Constructs a new DmViewReply.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmViewReply);\n\n                        /** DmViewReply closed. */\n                        public closed: boolean;\n\n                        /** DmViewReply mask. */\n                        public mask?: (bilibili.community.service.dm.v1.IVideoMask|null);\n\n                        /** DmViewReply subtitle. */\n                        public subtitle?: (bilibili.community.service.dm.v1.IVideoSubtitle|null);\n\n                        /** DmViewReply specialDms. */\n                        public specialDms: string[];\n\n                        /** DmViewReply aiFlag. */\n                        public aiFlag?: (bilibili.community.service.dm.v1.IDanmakuFlagConfig|null);\n\n                        /** DmViewReply playerConfig. */\n                        public playerConfig?: (bilibili.community.service.dm.v1.IDanmuPlayerViewConfig|null);\n\n                        /** DmViewReply sendBoxStyle. */\n                        public sendBoxStyle: number;\n\n                        /** DmViewReply allow. */\n                        public allow: boolean;\n\n                        /** DmViewReply checkBox. */\n                        public checkBox: string;\n\n                        /** DmViewReply checkBoxShowMsg. */\n                        public checkBoxShowMsg: string;\n\n                        /** DmViewReply textPlaceholder. */\n                        public textPlaceholder: string;\n\n                        /** DmViewReply inputPlaceholder. */\n                        public inputPlaceholder: string;\n\n                        /** DmViewReply reportFilterContent. */\n                        public reportFilterContent: string[];\n\n                        /** DmViewReply expoReport. */\n                        public expoReport?: (bilibili.community.service.dm.v1.IExpoReport|null);\n\n                        /** DmViewReply buzzwordConfig. */\n                        public buzzwordConfig?: (bilibili.community.service.dm.v1.IBuzzwordConfig|null);\n\n                        /** DmViewReply expressions. */\n                        public expressions: bilibili.community.service.dm.v1.IExpressions[];\n\n                        /** DmViewReply postPanel. */\n                        public postPanel: bilibili.community.service.dm.v1.IPostPanel[];\n\n                        /** DmViewReply activityMeta. */\n                        public activityMeta: string[];\n\n                        /** DmViewReply postPanel2. */\n                        public postPanel2: bilibili.community.service.dm.v1.IPostPanelV2[];\n\n                        /**\n                         * Creates a new DmViewReply instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmViewReply instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmViewReply): bilibili.community.service.dm.v1.DmViewReply;\n\n                        /**\n                         * Encodes the specified DmViewReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReply.verify|verify} messages.\n                         * @param message DmViewReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmViewReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmViewReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReply.verify|verify} messages.\n                         * @param message DmViewReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmViewReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmViewReply message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmViewReply;\n\n                        /**\n                         * Decodes a DmViewReply message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmViewReply;\n\n                        /**\n                         * Verifies a DmViewReply message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmViewReply message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmViewReply\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmViewReply;\n\n                        /**\n                         * Creates a plain object from a DmViewReply message. Also converts values to other types if specified.\n                         * @param message DmViewReply\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmViewReply, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmViewReply to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmViewReply\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmViewReq. */\n                    interface IDmViewReq {\n\n                        /** DmViewReq pid */\n                        pid?: (number|Long|null);\n\n                        /** DmViewReq oid */\n                        oid?: (number|Long|null);\n\n                        /** DmViewReq type */\n                        type?: (number|null);\n\n                        /** DmViewReq spmid */\n                        spmid?: (string|null);\n\n                        /** DmViewReq isHardBoot */\n                        isHardBoot?: (number|null);\n                    }\n\n                    /** Represents a DmViewReq. */\n                    class DmViewReq implements IDmViewReq {\n\n                        /**\n                         * Constructs a new DmViewReq.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmViewReq);\n\n                        /** DmViewReq pid. */\n                        public pid: (number|Long);\n\n                        /** DmViewReq oid. */\n                        public oid: (number|Long);\n\n                        /** DmViewReq type. */\n                        public type: number;\n\n                        /** DmViewReq spmid. */\n                        public spmid: string;\n\n                        /** DmViewReq isHardBoot. */\n                        public isHardBoot: number;\n\n                        /**\n                         * Creates a new DmViewReq instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmViewReq instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmViewReq): bilibili.community.service.dm.v1.DmViewReq;\n\n                        /**\n                         * Encodes the specified DmViewReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReq.verify|verify} messages.\n                         * @param message DmViewReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmViewReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmViewReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReq.verify|verify} messages.\n                         * @param message DmViewReq message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmViewReq, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmViewReq message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmViewReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmViewReq;\n\n                        /**\n                         * Decodes a DmViewReq message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmViewReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmViewReq;\n\n                        /**\n                         * Verifies a DmViewReq message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmViewReq message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmViewReq\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmViewReq;\n\n                        /**\n                         * Creates a plain object from a DmViewReq message. Also converts values to other types if specified.\n                         * @param message DmViewReq\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmViewReq, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmViewReq to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmViewReq\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a DmWebViewReply. */\n                    interface IDmWebViewReply {\n\n                        /** DmWebViewReply state */\n                        state?: (number|null);\n\n                        /** DmWebViewReply text */\n                        text?: (string|null);\n\n                        /** DmWebViewReply textSide */\n                        textSide?: (string|null);\n\n                        /** DmWebViewReply dmSge */\n                        dmSge?: (bilibili.community.service.dm.v1.IDmSegConfig|null);\n\n                        /** DmWebViewReply flag */\n                        flag?: (bilibili.community.service.dm.v1.IDanmakuFlagConfig|null);\n\n                        /** DmWebViewReply specialDms */\n                        specialDms?: (string[]|null);\n\n                        /** DmWebViewReply checkBox */\n                        checkBox?: (boolean|null);\n\n                        /** DmWebViewReply count */\n                        count?: (number|Long|null);\n\n                        /** DmWebViewReply commandDms */\n                        commandDms?: (bilibili.community.service.dm.v1.ICommandDm[]|null);\n\n                        /** DmWebViewReply playerConfig */\n                        playerConfig?: (bilibili.community.service.dm.v1.IDanmuWebPlayerConfig|null);\n\n                        /** DmWebViewReply reportFilterContent */\n                        reportFilterContent?: (string[]|null);\n\n                        /** DmWebViewReply expressions */\n                        expressions?: (bilibili.community.service.dm.v1.IExpressions[]|null);\n\n                        /** DmWebViewReply postPanel */\n                        postPanel?: (bilibili.community.service.dm.v1.IPostPanel[]|null);\n\n                        /** DmWebViewReply activityMeta */\n                        activityMeta?: (string[]|null);\n                    }\n\n                    /** Represents a DmWebViewReply. */\n                    class DmWebViewReply implements IDmWebViewReply {\n\n                        /**\n                         * Constructs a new DmWebViewReply.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IDmWebViewReply);\n\n                        /** DmWebViewReply state. */\n                        public state: number;\n\n                        /** DmWebViewReply text. */\n                        public text: string;\n\n                        /** DmWebViewReply textSide. */\n                        public textSide: string;\n\n                        /** DmWebViewReply dmSge. */\n                        public dmSge?: (bilibili.community.service.dm.v1.IDmSegConfig|null);\n\n                        /** DmWebViewReply flag. */\n                        public flag?: (bilibili.community.service.dm.v1.IDanmakuFlagConfig|null);\n\n                        /** DmWebViewReply specialDms. */\n                        public specialDms: string[];\n\n                        /** DmWebViewReply checkBox. */\n                        public checkBox: boolean;\n\n                        /** DmWebViewReply count. */\n                        public count: (number|Long);\n\n                        /** DmWebViewReply commandDms. */\n                        public commandDms: bilibili.community.service.dm.v1.ICommandDm[];\n\n                        /** DmWebViewReply playerConfig. */\n                        public playerConfig?: (bilibili.community.service.dm.v1.IDanmuWebPlayerConfig|null);\n\n                        /** DmWebViewReply reportFilterContent. */\n                        public reportFilterContent: string[];\n\n                        /** DmWebViewReply expressions. */\n                        public expressions: bilibili.community.service.dm.v1.IExpressions[];\n\n                        /** DmWebViewReply postPanel. */\n                        public postPanel: bilibili.community.service.dm.v1.IPostPanel[];\n\n                        /** DmWebViewReply activityMeta. */\n                        public activityMeta: string[];\n\n                        /**\n                         * Creates a new DmWebViewReply instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns DmWebViewReply instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IDmWebViewReply): bilibili.community.service.dm.v1.DmWebViewReply;\n\n                        /**\n                         * Encodes the specified DmWebViewReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmWebViewReply.verify|verify} messages.\n                         * @param message DmWebViewReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IDmWebViewReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified DmWebViewReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmWebViewReply.verify|verify} messages.\n                         * @param message DmWebViewReply message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IDmWebViewReply, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a DmWebViewReply message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns DmWebViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.DmWebViewReply;\n\n                        /**\n                         * Decodes a DmWebViewReply message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns DmWebViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.DmWebViewReply;\n\n                        /**\n                         * Verifies a DmWebViewReply message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a DmWebViewReply message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns DmWebViewReply\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.DmWebViewReply;\n\n                        /**\n                         * Creates a plain object from a DmWebViewReply message. Also converts values to other types if specified.\n                         * @param message DmWebViewReply\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.DmWebViewReply, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this DmWebViewReply to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for DmWebViewReply\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of an ExpoReport. */\n                    interface IExpoReport {\n\n                        /** ExpoReport shouldReportAtEnd */\n                        shouldReportAtEnd?: (boolean|null);\n                    }\n\n                    /** Represents an ExpoReport. */\n                    class ExpoReport implements IExpoReport {\n\n                        /**\n                         * Constructs a new ExpoReport.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IExpoReport);\n\n                        /** ExpoReport shouldReportAtEnd. */\n                        public shouldReportAtEnd: boolean;\n\n                        /**\n                         * Creates a new ExpoReport instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns ExpoReport instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IExpoReport): bilibili.community.service.dm.v1.ExpoReport;\n\n                        /**\n                         * Encodes the specified ExpoReport message. Does not implicitly {@link bilibili.community.service.dm.v1.ExpoReport.verify|verify} messages.\n                         * @param message ExpoReport message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IExpoReport, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified ExpoReport message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ExpoReport.verify|verify} messages.\n                         * @param message ExpoReport message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IExpoReport, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes an ExpoReport message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns ExpoReport\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.ExpoReport;\n\n                        /**\n                         * Decodes an ExpoReport message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns ExpoReport\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.ExpoReport;\n\n                        /**\n                         * Verifies an ExpoReport message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates an ExpoReport message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns ExpoReport\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.ExpoReport;\n\n                        /**\n                         * Creates a plain object from an ExpoReport message. Also converts values to other types if specified.\n                         * @param message ExpoReport\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.ExpoReport, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this ExpoReport to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for ExpoReport\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** ExposureType enum. */\n                    enum ExposureType {\n                        ExposureTypeNone = 0,\n                        ExposureTypeDMSend = 1\n                    }\n\n                    /** Properties of an Expression. */\n                    interface IExpression {\n\n                        /** Expression keyword */\n                        keyword?: (string[]|null);\n\n                        /** Expression url */\n                        url?: (string|null);\n\n                        /** Expression period */\n                        period?: (bilibili.community.service.dm.v1.IPeriod[]|null);\n                    }\n\n                    /** Represents an Expression. */\n                    class Expression implements IExpression {\n\n                        /**\n                         * Constructs a new Expression.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IExpression);\n\n                        /** Expression keyword. */\n                        public keyword: string[];\n\n                        /** Expression url. */\n                        public url: string;\n\n                        /** Expression period. */\n                        public period: bilibili.community.service.dm.v1.IPeriod[];\n\n                        /**\n                         * Creates a new Expression instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Expression instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IExpression): bilibili.community.service.dm.v1.Expression;\n\n                        /**\n                         * Encodes the specified Expression message. Does not implicitly {@link bilibili.community.service.dm.v1.Expression.verify|verify} messages.\n                         * @param message Expression message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IExpression, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Expression message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Expression.verify|verify} messages.\n                         * @param message Expression message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IExpression, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes an Expression message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Expression\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Expression;\n\n                        /**\n                         * Decodes an Expression message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Expression\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Expression;\n\n                        /**\n                         * Verifies an Expression message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates an Expression message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Expression\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Expression;\n\n                        /**\n                         * Creates a plain object from an Expression message. Also converts values to other types if specified.\n                         * @param message Expression\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Expression, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Expression to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Expression\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of an Expressions. */\n                    interface IExpressions {\n\n                        /** Expressions data */\n                        data?: (bilibili.community.service.dm.v1.IExpression[]|null);\n                    }\n\n                    /** Represents an Expressions. */\n                    class Expressions implements IExpressions {\n\n                        /**\n                         * Constructs a new Expressions.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IExpressions);\n\n                        /** Expressions data. */\n                        public data: bilibili.community.service.dm.v1.IExpression[];\n\n                        /**\n                         * Creates a new Expressions instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Expressions instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IExpressions): bilibili.community.service.dm.v1.Expressions;\n\n                        /**\n                         * Encodes the specified Expressions message. Does not implicitly {@link bilibili.community.service.dm.v1.Expressions.verify|verify} messages.\n                         * @param message Expressions message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IExpressions, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Expressions message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Expressions.verify|verify} messages.\n                         * @param message Expressions message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IExpressions, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes an Expressions message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Expressions\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Expressions;\n\n                        /**\n                         * Decodes an Expressions message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Expressions\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Expressions;\n\n                        /**\n                         * Verifies an Expressions message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates an Expressions message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Expressions\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Expressions;\n\n                        /**\n                         * Creates a plain object from an Expressions message. Also converts values to other types if specified.\n                         * @param message Expressions\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Expressions, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Expressions to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Expressions\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of an InlinePlayerDanmakuSwitch. */\n                    interface IInlinePlayerDanmakuSwitch {\n\n                        /** InlinePlayerDanmakuSwitch value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents an InlinePlayerDanmakuSwitch. */\n                    class InlinePlayerDanmakuSwitch implements IInlinePlayerDanmakuSwitch {\n\n                        /**\n                         * Constructs a new InlinePlayerDanmakuSwitch.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch);\n\n                        /** InlinePlayerDanmakuSwitch value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new InlinePlayerDanmakuSwitch instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns InlinePlayerDanmakuSwitch instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch): bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch;\n\n                        /**\n                         * Encodes the specified InlinePlayerDanmakuSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.verify|verify} messages.\n                         * @param message InlinePlayerDanmakuSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified InlinePlayerDanmakuSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.verify|verify} messages.\n                         * @param message InlinePlayerDanmakuSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes an InlinePlayerDanmakuSwitch message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns InlinePlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch;\n\n                        /**\n                         * Decodes an InlinePlayerDanmakuSwitch message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns InlinePlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch;\n\n                        /**\n                         * Verifies an InlinePlayerDanmakuSwitch message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates an InlinePlayerDanmakuSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns InlinePlayerDanmakuSwitch\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch;\n\n                        /**\n                         * Creates a plain object from an InlinePlayerDanmakuSwitch message. Also converts values to other types if specified.\n                         * @param message InlinePlayerDanmakuSwitch\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this InlinePlayerDanmakuSwitch to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for InlinePlayerDanmakuSwitch\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a Label. */\n                    interface ILabel {\n\n                        /** Label title */\n                        title?: (string|null);\n\n                        /** Label content */\n                        content?: (string[]|null);\n                    }\n\n                    /** Represents a Label. */\n                    class Label implements ILabel {\n\n                        /**\n                         * Constructs a new Label.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ILabel);\n\n                        /** Label title. */\n                        public title: string;\n\n                        /** Label content. */\n                        public content: string[];\n\n                        /**\n                         * Creates a new Label instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Label instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ILabel): bilibili.community.service.dm.v1.Label;\n\n                        /**\n                         * Encodes the specified Label message. Does not implicitly {@link bilibili.community.service.dm.v1.Label.verify|verify} messages.\n                         * @param message Label message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ILabel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Label message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Label.verify|verify} messages.\n                         * @param message Label message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ILabel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a Label message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Label\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Label;\n\n                        /**\n                         * Decodes a Label message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Label\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Label;\n\n                        /**\n                         * Verifies a Label message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a Label message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Label\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Label;\n\n                        /**\n                         * Creates a plain object from a Label message. Also converts values to other types if specified.\n                         * @param message Label\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Label, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Label to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Label\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a LabelV2. */\n                    interface ILabelV2 {\n\n                        /** LabelV2 title */\n                        title?: (string|null);\n\n                        /** LabelV2 content */\n                        content?: (string[]|null);\n\n                        /** LabelV2 exposureOnce */\n                        exposureOnce?: (boolean|null);\n\n                        /** LabelV2 exposureType */\n                        exposureType?: (number|null);\n                    }\n\n                    /** Represents a LabelV2. */\n                    class LabelV2 implements ILabelV2 {\n\n                        /**\n                         * Constructs a new LabelV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ILabelV2);\n\n                        /** LabelV2 title. */\n                        public title: string;\n\n                        /** LabelV2 content. */\n                        public content: string[];\n\n                        /** LabelV2 exposureOnce. */\n                        public exposureOnce: boolean;\n\n                        /** LabelV2 exposureType. */\n                        public exposureType: number;\n\n                        /**\n                         * Creates a new LabelV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns LabelV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ILabelV2): bilibili.community.service.dm.v1.LabelV2;\n\n                        /**\n                         * Encodes the specified LabelV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.LabelV2.verify|verify} messages.\n                         * @param message LabelV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ILabelV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified LabelV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.LabelV2.verify|verify} messages.\n                         * @param message LabelV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ILabelV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a LabelV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns LabelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.LabelV2;\n\n                        /**\n                         * Decodes a LabelV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns LabelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.LabelV2;\n\n                        /**\n                         * Verifies a LabelV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a LabelV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns LabelV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.LabelV2;\n\n                        /**\n                         * Creates a plain object from a LabelV2 message. Also converts values to other types if specified.\n                         * @param message LabelV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.LabelV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this LabelV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for LabelV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a Period. */\n                    interface IPeriod {\n\n                        /** Period start */\n                        start?: (number|Long|null);\n\n                        /** Period end */\n                        end?: (number|Long|null);\n                    }\n\n                    /** Represents a Period. */\n                    class Period implements IPeriod {\n\n                        /**\n                         * Constructs a new Period.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPeriod);\n\n                        /** Period start. */\n                        public start: (number|Long);\n\n                        /** Period end. */\n                        public end: (number|Long);\n\n                        /**\n                         * Creates a new Period instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Period instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPeriod): bilibili.community.service.dm.v1.Period;\n\n                        /**\n                         * Encodes the specified Period message. Does not implicitly {@link bilibili.community.service.dm.v1.Period.verify|verify} messages.\n                         * @param message Period message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPeriod, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Period message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Period.verify|verify} messages.\n                         * @param message Period message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPeriod, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a Period message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Period\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Period;\n\n                        /**\n                         * Decodes a Period message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Period\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Period;\n\n                        /**\n                         * Verifies a Period message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a Period message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Period\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Period;\n\n                        /**\n                         * Creates a plain object from a Period message. Also converts values to other types if specified.\n                         * @param message Period\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Period, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Period to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Period\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuAiRecommendedLevel. */\n                    interface IPlayerDanmakuAiRecommendedLevel {\n\n                        /** PlayerDanmakuAiRecommendedLevel value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuAiRecommendedLevel. */\n                    class PlayerDanmakuAiRecommendedLevel implements IPlayerDanmakuAiRecommendedLevel {\n\n                        /**\n                         * Constructs a new PlayerDanmakuAiRecommendedLevel.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel);\n\n                        /** PlayerDanmakuAiRecommendedLevel value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuAiRecommendedLevel instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuAiRecommendedLevel instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevel message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.verify|verify} messages.\n                         * @param message PlayerDanmakuAiRecommendedLevel message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevel message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.verify|verify} messages.\n                         * @param message PlayerDanmakuAiRecommendedLevel message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevel message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuAiRecommendedLevel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel;\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevel message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuAiRecommendedLevel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel;\n\n                        /**\n                         * Verifies a PlayerDanmakuAiRecommendedLevel message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuAiRecommendedLevel message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuAiRecommendedLevel\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuAiRecommendedLevel message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuAiRecommendedLevel\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuAiRecommendedLevel to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuAiRecommendedLevel\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuAiRecommendedLevelV2. */\n                    interface IPlayerDanmakuAiRecommendedLevelV2 {\n\n                        /** PlayerDanmakuAiRecommendedLevelV2 value */\n                        value?: (number|null);\n                    }\n\n                    /** Represents a PlayerDanmakuAiRecommendedLevelV2. */\n                    class PlayerDanmakuAiRecommendedLevelV2 implements IPlayerDanmakuAiRecommendedLevelV2 {\n\n                        /**\n                         * Constructs a new PlayerDanmakuAiRecommendedLevelV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2);\n\n                        /** PlayerDanmakuAiRecommendedLevelV2 value. */\n                        public value: number;\n\n                        /**\n                         * Creates a new PlayerDanmakuAiRecommendedLevelV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuAiRecommendedLevelV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevelV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.verify|verify} messages.\n                         * @param message PlayerDanmakuAiRecommendedLevelV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevelV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.verify|verify} messages.\n                         * @param message PlayerDanmakuAiRecommendedLevelV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevelV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuAiRecommendedLevelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2;\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevelV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuAiRecommendedLevelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2;\n\n                        /**\n                         * Verifies a PlayerDanmakuAiRecommendedLevelV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuAiRecommendedLevelV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuAiRecommendedLevelV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuAiRecommendedLevelV2 message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuAiRecommendedLevelV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuAiRecommendedLevelV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuAiRecommendedLevelV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuAiRecommendedSwitch. */\n                    interface IPlayerDanmakuAiRecommendedSwitch {\n\n                        /** PlayerDanmakuAiRecommendedSwitch value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuAiRecommendedSwitch. */\n                    class PlayerDanmakuAiRecommendedSwitch implements IPlayerDanmakuAiRecommendedSwitch {\n\n                        /**\n                         * Constructs a new PlayerDanmakuAiRecommendedSwitch.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch);\n\n                        /** PlayerDanmakuAiRecommendedSwitch value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuAiRecommendedSwitch instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuAiRecommendedSwitch instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.verify|verify} messages.\n                         * @param message PlayerDanmakuAiRecommendedSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.verify|verify} messages.\n                         * @param message PlayerDanmakuAiRecommendedSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedSwitch message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuAiRecommendedSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch;\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedSwitch message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuAiRecommendedSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch;\n\n                        /**\n                         * Verifies a PlayerDanmakuAiRecommendedSwitch message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuAiRecommendedSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuAiRecommendedSwitch\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuAiRecommendedSwitch message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuAiRecommendedSwitch\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuAiRecommendedSwitch to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuAiRecommendedSwitch\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuBlockbottom. */\n                    interface IPlayerDanmakuBlockbottom {\n\n                        /** PlayerDanmakuBlockbottom value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuBlockbottom. */\n                    class PlayerDanmakuBlockbottom implements IPlayerDanmakuBlockbottom {\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockbottom.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom);\n\n                        /** PlayerDanmakuBlockbottom value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockbottom instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuBlockbottom instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom): bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockbottom message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockbottom message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockbottom message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockbottom message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockbottom message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuBlockbottom\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockbottom message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuBlockbottom\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom;\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockbottom message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuBlockbottom message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuBlockbottom\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockbottom message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuBlockbottom\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockbottom to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockbottom\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuBlockcolorful. */\n                    interface IPlayerDanmakuBlockcolorful {\n\n                        /** PlayerDanmakuBlockcolorful value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuBlockcolorful. */\n                    class PlayerDanmakuBlockcolorful implements IPlayerDanmakuBlockcolorful {\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockcolorful.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful);\n\n                        /** PlayerDanmakuBlockcolorful value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockcolorful instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuBlockcolorful instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful): bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockcolorful message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockcolorful message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockcolorful message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockcolorful message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockcolorful message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuBlockcolorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockcolorful message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuBlockcolorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful;\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockcolorful message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuBlockcolorful message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuBlockcolorful\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockcolorful message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuBlockcolorful\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockcolorful to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockcolorful\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuBlockrepeat. */\n                    interface IPlayerDanmakuBlockrepeat {\n\n                        /** PlayerDanmakuBlockrepeat value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuBlockrepeat. */\n                    class PlayerDanmakuBlockrepeat implements IPlayerDanmakuBlockrepeat {\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockrepeat.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat);\n\n                        /** PlayerDanmakuBlockrepeat value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockrepeat instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuBlockrepeat instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat): bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockrepeat message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockrepeat message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockrepeat message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockrepeat message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockrepeat message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuBlockrepeat\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockrepeat message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuBlockrepeat\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat;\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockrepeat message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuBlockrepeat message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuBlockrepeat\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockrepeat message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuBlockrepeat\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockrepeat to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockrepeat\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuBlockscroll. */\n                    interface IPlayerDanmakuBlockscroll {\n\n                        /** PlayerDanmakuBlockscroll value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuBlockscroll. */\n                    class PlayerDanmakuBlockscroll implements IPlayerDanmakuBlockscroll {\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockscroll.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll);\n\n                        /** PlayerDanmakuBlockscroll value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockscroll instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuBlockscroll instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll): bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockscroll message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockscroll message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockscroll message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockscroll message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockscroll message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuBlockscroll\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockscroll message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuBlockscroll\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll;\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockscroll message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuBlockscroll message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuBlockscroll\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockscroll message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuBlockscroll\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockscroll to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockscroll\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuBlockspecial. */\n                    interface IPlayerDanmakuBlockspecial {\n\n                        /** PlayerDanmakuBlockspecial value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuBlockspecial. */\n                    class PlayerDanmakuBlockspecial implements IPlayerDanmakuBlockspecial {\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockspecial.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial);\n\n                        /** PlayerDanmakuBlockspecial value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockspecial instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuBlockspecial instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial): bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockspecial message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockspecial message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockspecial message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.verify|verify} messages.\n                         * @param message PlayerDanmakuBlockspecial message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockspecial message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuBlockspecial\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockspecial message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuBlockspecial\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial;\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockspecial message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuBlockspecial message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuBlockspecial\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockspecial message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuBlockspecial\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockspecial to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockspecial\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuBlocktop. */\n                    interface IPlayerDanmakuBlocktop {\n\n                        /** PlayerDanmakuBlocktop value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuBlocktop. */\n                    class PlayerDanmakuBlocktop implements IPlayerDanmakuBlocktop {\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlocktop.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop);\n\n                        /** PlayerDanmakuBlocktop value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlocktop instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuBlocktop instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop): bilibili.community.service.dm.v1.PlayerDanmakuBlocktop;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlocktop message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.verify|verify} messages.\n                         * @param message PlayerDanmakuBlocktop message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlocktop message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.verify|verify} messages.\n                         * @param message PlayerDanmakuBlocktop message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlocktop message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuBlocktop\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuBlocktop;\n\n                        /**\n                         * Decodes a PlayerDanmakuBlocktop message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuBlocktop\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuBlocktop;\n\n                        /**\n                         * Verifies a PlayerDanmakuBlocktop message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuBlocktop message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuBlocktop\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuBlocktop;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlocktop message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuBlocktop\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuBlocktop, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuBlocktop to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlocktop\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuDomain. */\n                    interface IPlayerDanmakuDomain {\n\n                        /** PlayerDanmakuDomain value */\n                        value?: (number|null);\n                    }\n\n                    /** Represents a PlayerDanmakuDomain. */\n                    class PlayerDanmakuDomain implements IPlayerDanmakuDomain {\n\n                        /**\n                         * Constructs a new PlayerDanmakuDomain.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuDomain);\n\n                        /** PlayerDanmakuDomain value. */\n                        public value: number;\n\n                        /**\n                         * Creates a new PlayerDanmakuDomain instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuDomain instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuDomain): bilibili.community.service.dm.v1.PlayerDanmakuDomain;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuDomain message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuDomain.verify|verify} messages.\n                         * @param message PlayerDanmakuDomain message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuDomain, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuDomain message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuDomain.verify|verify} messages.\n                         * @param message PlayerDanmakuDomain message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuDomain, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuDomain message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuDomain\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuDomain;\n\n                        /**\n                         * Decodes a PlayerDanmakuDomain message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuDomain\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuDomain;\n\n                        /**\n                         * Verifies a PlayerDanmakuDomain message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuDomain message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuDomain\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuDomain;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuDomain message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuDomain\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuDomain, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuDomain to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuDomain\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuEnableblocklist. */\n                    interface IPlayerDanmakuEnableblocklist {\n\n                        /** PlayerDanmakuEnableblocklist value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuEnableblocklist. */\n                    class PlayerDanmakuEnableblocklist implements IPlayerDanmakuEnableblocklist {\n\n                        /**\n                         * Constructs a new PlayerDanmakuEnableblocklist.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist);\n\n                        /** PlayerDanmakuEnableblocklist value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuEnableblocklist instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuEnableblocklist instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist): bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuEnableblocklist message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.verify|verify} messages.\n                         * @param message PlayerDanmakuEnableblocklist message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuEnableblocklist message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.verify|verify} messages.\n                         * @param message PlayerDanmakuEnableblocklist message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuEnableblocklist message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuEnableblocklist\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist;\n\n                        /**\n                         * Decodes a PlayerDanmakuEnableblocklist message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuEnableblocklist\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist;\n\n                        /**\n                         * Verifies a PlayerDanmakuEnableblocklist message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuEnableblocklist message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuEnableblocklist\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuEnableblocklist message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuEnableblocklist\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuEnableblocklist to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuEnableblocklist\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuOpacity. */\n                    interface IPlayerDanmakuOpacity {\n\n                        /** PlayerDanmakuOpacity value */\n                        value?: (number|null);\n                    }\n\n                    /** Represents a PlayerDanmakuOpacity. */\n                    class PlayerDanmakuOpacity implements IPlayerDanmakuOpacity {\n\n                        /**\n                         * Constructs a new PlayerDanmakuOpacity.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuOpacity);\n\n                        /** PlayerDanmakuOpacity value. */\n                        public value: number;\n\n                        /**\n                         * Creates a new PlayerDanmakuOpacity instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuOpacity instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuOpacity): bilibili.community.service.dm.v1.PlayerDanmakuOpacity;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuOpacity message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuOpacity.verify|verify} messages.\n                         * @param message PlayerDanmakuOpacity message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuOpacity, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuOpacity message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuOpacity.verify|verify} messages.\n                         * @param message PlayerDanmakuOpacity message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuOpacity, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuOpacity message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuOpacity\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuOpacity;\n\n                        /**\n                         * Decodes a PlayerDanmakuOpacity message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuOpacity\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuOpacity;\n\n                        /**\n                         * Verifies a PlayerDanmakuOpacity message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuOpacity message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuOpacity\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuOpacity;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuOpacity message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuOpacity\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuOpacity, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuOpacity to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuOpacity\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuScalingfactor. */\n                    interface IPlayerDanmakuScalingfactor {\n\n                        /** PlayerDanmakuScalingfactor value */\n                        value?: (number|null);\n                    }\n\n                    /** Represents a PlayerDanmakuScalingfactor. */\n                    class PlayerDanmakuScalingfactor implements IPlayerDanmakuScalingfactor {\n\n                        /**\n                         * Constructs a new PlayerDanmakuScalingfactor.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor);\n\n                        /** PlayerDanmakuScalingfactor value. */\n                        public value: number;\n\n                        /**\n                         * Creates a new PlayerDanmakuScalingfactor instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuScalingfactor instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor): bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuScalingfactor message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.verify|verify} messages.\n                         * @param message PlayerDanmakuScalingfactor message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuScalingfactor message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.verify|verify} messages.\n                         * @param message PlayerDanmakuScalingfactor message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuScalingfactor message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuScalingfactor\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor;\n\n                        /**\n                         * Decodes a PlayerDanmakuScalingfactor message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuScalingfactor\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor;\n\n                        /**\n                         * Verifies a PlayerDanmakuScalingfactor message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuScalingfactor message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuScalingfactor\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuScalingfactor message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuScalingfactor\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuScalingfactor to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuScalingfactor\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuSeniorModeSwitch. */\n                    interface IPlayerDanmakuSeniorModeSwitch {\n\n                        /** PlayerDanmakuSeniorModeSwitch value */\n                        value?: (number|null);\n                    }\n\n                    /** Represents a PlayerDanmakuSeniorModeSwitch. */\n                    class PlayerDanmakuSeniorModeSwitch implements IPlayerDanmakuSeniorModeSwitch {\n\n                        /**\n                         * Constructs a new PlayerDanmakuSeniorModeSwitch.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch);\n\n                        /** PlayerDanmakuSeniorModeSwitch value. */\n                        public value: number;\n\n                        /**\n                         * Creates a new PlayerDanmakuSeniorModeSwitch instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuSeniorModeSwitch instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch): bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSeniorModeSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.verify|verify} messages.\n                         * @param message PlayerDanmakuSeniorModeSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSeniorModeSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.verify|verify} messages.\n                         * @param message PlayerDanmakuSeniorModeSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuSeniorModeSwitch message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuSeniorModeSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch;\n\n                        /**\n                         * Decodes a PlayerDanmakuSeniorModeSwitch message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuSeniorModeSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch;\n\n                        /**\n                         * Verifies a PlayerDanmakuSeniorModeSwitch message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuSeniorModeSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuSeniorModeSwitch\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSeniorModeSwitch message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuSeniorModeSwitch\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuSeniorModeSwitch to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSeniorModeSwitch\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuSpeed. */\n                    interface IPlayerDanmakuSpeed {\n\n                        /** PlayerDanmakuSpeed value */\n                        value?: (number|null);\n                    }\n\n                    /** Represents a PlayerDanmakuSpeed. */\n                    class PlayerDanmakuSpeed implements IPlayerDanmakuSpeed {\n\n                        /**\n                         * Constructs a new PlayerDanmakuSpeed.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSpeed);\n\n                        /** PlayerDanmakuSpeed value. */\n                        public value: number;\n\n                        /**\n                         * Creates a new PlayerDanmakuSpeed instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuSpeed instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSpeed): bilibili.community.service.dm.v1.PlayerDanmakuSpeed;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSpeed message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSpeed.verify|verify} messages.\n                         * @param message PlayerDanmakuSpeed message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuSpeed, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSpeed message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSpeed.verify|verify} messages.\n                         * @param message PlayerDanmakuSpeed message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuSpeed, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuSpeed message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuSpeed\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuSpeed;\n\n                        /**\n                         * Decodes a PlayerDanmakuSpeed message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuSpeed\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuSpeed;\n\n                        /**\n                         * Verifies a PlayerDanmakuSpeed message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuSpeed message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuSpeed\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuSpeed;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSpeed message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuSpeed\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuSpeed, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuSpeed to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSpeed\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuSwitch. */\n                    interface IPlayerDanmakuSwitch {\n\n                        /** PlayerDanmakuSwitch value */\n                        value?: (boolean|null);\n\n                        /** PlayerDanmakuSwitch canIgnore */\n                        canIgnore?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuSwitch. */\n                    class PlayerDanmakuSwitch implements IPlayerDanmakuSwitch {\n\n                        /**\n                         * Constructs a new PlayerDanmakuSwitch.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSwitch);\n\n                        /** PlayerDanmakuSwitch value. */\n                        public value: boolean;\n\n                        /** PlayerDanmakuSwitch canIgnore. */\n                        public canIgnore: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuSwitch instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuSwitch instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSwitch): bilibili.community.service.dm.v1.PlayerDanmakuSwitch;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitch.verify|verify} messages.\n                         * @param message PlayerDanmakuSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitch.verify|verify} messages.\n                         * @param message PlayerDanmakuSwitch message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuSwitch, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitch message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuSwitch;\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitch message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuSwitch;\n\n                        /**\n                         * Verifies a PlayerDanmakuSwitch message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuSwitch\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuSwitch;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSwitch message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuSwitch\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuSwitch, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuSwitch to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSwitch\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuSwitchSave. */\n                    interface IPlayerDanmakuSwitchSave {\n\n                        /** PlayerDanmakuSwitchSave value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuSwitchSave. */\n                    class PlayerDanmakuSwitchSave implements IPlayerDanmakuSwitchSave {\n\n                        /**\n                         * Constructs a new PlayerDanmakuSwitchSave.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave);\n\n                        /** PlayerDanmakuSwitchSave value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuSwitchSave instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuSwitchSave instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave): bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitchSave message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.verify|verify} messages.\n                         * @param message PlayerDanmakuSwitchSave message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitchSave message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.verify|verify} messages.\n                         * @param message PlayerDanmakuSwitchSave message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitchSave message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuSwitchSave\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave;\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitchSave message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuSwitchSave\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave;\n\n                        /**\n                         * Verifies a PlayerDanmakuSwitchSave message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuSwitchSave message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuSwitchSave\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSwitchSave message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuSwitchSave\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuSwitchSave to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSwitchSave\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PlayerDanmakuUseDefaultConfig. */\n                    interface IPlayerDanmakuUseDefaultConfig {\n\n                        /** PlayerDanmakuUseDefaultConfig value */\n                        value?: (boolean|null);\n                    }\n\n                    /** Represents a PlayerDanmakuUseDefaultConfig. */\n                    class PlayerDanmakuUseDefaultConfig implements IPlayerDanmakuUseDefaultConfig {\n\n                        /**\n                         * Constructs a new PlayerDanmakuUseDefaultConfig.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig);\n\n                        /** PlayerDanmakuUseDefaultConfig value. */\n                        public value: boolean;\n\n                        /**\n                         * Creates a new PlayerDanmakuUseDefaultConfig instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PlayerDanmakuUseDefaultConfig instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig): bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuUseDefaultConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.verify|verify} messages.\n                         * @param message PlayerDanmakuUseDefaultConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PlayerDanmakuUseDefaultConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.verify|verify} messages.\n                         * @param message PlayerDanmakuUseDefaultConfig message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PlayerDanmakuUseDefaultConfig message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PlayerDanmakuUseDefaultConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig;\n\n                        /**\n                         * Decodes a PlayerDanmakuUseDefaultConfig message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PlayerDanmakuUseDefaultConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig;\n\n                        /**\n                         * Verifies a PlayerDanmakuUseDefaultConfig message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PlayerDanmakuUseDefaultConfig message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PlayerDanmakuUseDefaultConfig\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig;\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuUseDefaultConfig message. Also converts values to other types if specified.\n                         * @param message PlayerDanmakuUseDefaultConfig\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PlayerDanmakuUseDefaultConfig to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuUseDefaultConfig\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a PostPanel. */\n                    interface IPostPanel {\n\n                        /** PostPanel start */\n                        start?: (number|Long|null);\n\n                        /** PostPanel end */\n                        end?: (number|Long|null);\n\n                        /** PostPanel priority */\n                        priority?: (number|Long|null);\n\n                        /** PostPanel bizId */\n                        bizId?: (number|Long|null);\n\n                        /** PostPanel bizType */\n                        bizType?: (bilibili.community.service.dm.v1.PostPanelBizType|null);\n\n                        /** PostPanel clickButton */\n                        clickButton?: (bilibili.community.service.dm.v1.IClickButton|null);\n\n                        /** PostPanel textInput */\n                        textInput?: (bilibili.community.service.dm.v1.ITextInput|null);\n\n                        /** PostPanel checkBox */\n                        checkBox?: (bilibili.community.service.dm.v1.ICheckBox|null);\n\n                        /** PostPanel toast */\n                        toast?: (bilibili.community.service.dm.v1.IToast|null);\n                    }\n\n                    /** Represents a PostPanel. */\n                    class PostPanel implements IPostPanel {\n\n                        /**\n                         * Constructs a new PostPanel.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPostPanel);\n\n                        /** PostPanel start. */\n                        public start: (number|Long);\n\n                        /** PostPanel end. */\n                        public end: (number|Long);\n\n                        /** PostPanel priority. */\n                        public priority: (number|Long);\n\n                        /** PostPanel bizId. */\n                        public bizId: (number|Long);\n\n                        /** PostPanel bizType. */\n                        public bizType: bilibili.community.service.dm.v1.PostPanelBizType;\n\n                        /** PostPanel clickButton. */\n                        public clickButton?: (bilibili.community.service.dm.v1.IClickButton|null);\n\n                        /** PostPanel textInput. */\n                        public textInput?: (bilibili.community.service.dm.v1.ITextInput|null);\n\n                        /** PostPanel checkBox. */\n                        public checkBox?: (bilibili.community.service.dm.v1.ICheckBox|null);\n\n                        /** PostPanel toast. */\n                        public toast?: (bilibili.community.service.dm.v1.IToast|null);\n\n                        /**\n                         * Creates a new PostPanel instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PostPanel instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPostPanel): bilibili.community.service.dm.v1.PostPanel;\n\n                        /**\n                         * Encodes the specified PostPanel message. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanel.verify|verify} messages.\n                         * @param message PostPanel message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPostPanel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PostPanel message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanel.verify|verify} messages.\n                         * @param message PostPanel message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPostPanel, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PostPanel message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PostPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PostPanel;\n\n                        /**\n                         * Decodes a PostPanel message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PostPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PostPanel;\n\n                        /**\n                         * Verifies a PostPanel message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PostPanel message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PostPanel\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PostPanel;\n\n                        /**\n                         * Creates a plain object from a PostPanel message. Also converts values to other types if specified.\n                         * @param message PostPanel\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PostPanel, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PostPanel to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PostPanel\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** PostPanelBizType enum. */\n                    enum PostPanelBizType {\n                        PostPanelBizTypeNone = 0,\n                        PostPanelBizTypeEncourage = 1,\n                        PostPanelBizTypeColorDM = 2,\n                        PostPanelBizTypeNFTDM = 3,\n                        PostPanelBizTypeFragClose = 4,\n                        PostPanelBizTypeRecommend = 5\n                    }\n\n                    /** Properties of a PostPanelV2. */\n                    interface IPostPanelV2 {\n\n                        /** PostPanelV2 start */\n                        start?: (number|Long|null);\n\n                        /** PostPanelV2 end */\n                        end?: (number|Long|null);\n\n                        /** PostPanelV2 bizType */\n                        bizType?: (number|null);\n\n                        /** PostPanelV2 clickButton */\n                        clickButton?: (bilibili.community.service.dm.v1.IClickButtonV2|null);\n\n                        /** PostPanelV2 textInput */\n                        textInput?: (bilibili.community.service.dm.v1.ITextInputV2|null);\n\n                        /** PostPanelV2 checkBox */\n                        checkBox?: (bilibili.community.service.dm.v1.ICheckBoxV2|null);\n\n                        /** PostPanelV2 toast */\n                        toast?: (bilibili.community.service.dm.v1.IToastV2|null);\n\n                        /** PostPanelV2 bubble */\n                        bubble?: (bilibili.community.service.dm.v1.IBubbleV2|null);\n\n                        /** PostPanelV2 label */\n                        label?: (bilibili.community.service.dm.v1.ILabelV2|null);\n\n                        /** PostPanelV2 postStatus */\n                        postStatus?: (number|null);\n                    }\n\n                    /** Represents a PostPanelV2. */\n                    class PostPanelV2 implements IPostPanelV2 {\n\n                        /**\n                         * Constructs a new PostPanelV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IPostPanelV2);\n\n                        /** PostPanelV2 start. */\n                        public start: (number|Long);\n\n                        /** PostPanelV2 end. */\n                        public end: (number|Long);\n\n                        /** PostPanelV2 bizType. */\n                        public bizType: number;\n\n                        /** PostPanelV2 clickButton. */\n                        public clickButton?: (bilibili.community.service.dm.v1.IClickButtonV2|null);\n\n                        /** PostPanelV2 textInput. */\n                        public textInput?: (bilibili.community.service.dm.v1.ITextInputV2|null);\n\n                        /** PostPanelV2 checkBox. */\n                        public checkBox?: (bilibili.community.service.dm.v1.ICheckBoxV2|null);\n\n                        /** PostPanelV2 toast. */\n                        public toast?: (bilibili.community.service.dm.v1.IToastV2|null);\n\n                        /** PostPanelV2 bubble. */\n                        public bubble?: (bilibili.community.service.dm.v1.IBubbleV2|null);\n\n                        /** PostPanelV2 label. */\n                        public label?: (bilibili.community.service.dm.v1.ILabelV2|null);\n\n                        /** PostPanelV2 postStatus. */\n                        public postStatus: number;\n\n                        /**\n                         * Creates a new PostPanelV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns PostPanelV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IPostPanelV2): bilibili.community.service.dm.v1.PostPanelV2;\n\n                        /**\n                         * Encodes the specified PostPanelV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanelV2.verify|verify} messages.\n                         * @param message PostPanelV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IPostPanelV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified PostPanelV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanelV2.verify|verify} messages.\n                         * @param message PostPanelV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IPostPanelV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a PostPanelV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns PostPanelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.PostPanelV2;\n\n                        /**\n                         * Decodes a PostPanelV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns PostPanelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.PostPanelV2;\n\n                        /**\n                         * Verifies a PostPanelV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a PostPanelV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns PostPanelV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.PostPanelV2;\n\n                        /**\n                         * Creates a plain object from a PostPanelV2 message. Also converts values to other types if specified.\n                         * @param message PostPanelV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.PostPanelV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this PostPanelV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for PostPanelV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** PostStatus enum. */\n                    enum PostStatus {\n                        PostStatusNormal = 0,\n                        PostStatusClosed = 1\n                    }\n\n                    /** RenderType enum. */\n                    enum RenderType {\n                        RenderTypeNone = 0,\n                        RenderTypeSingle = 1,\n                        RenderTypeRotation = 2\n                    }\n\n                    /** Properties of a Response. */\n                    interface IResponse {\n\n                        /** Response code */\n                        code?: (number|null);\n\n                        /** Response message */\n                        message?: (string|null);\n                    }\n\n                    /** Represents a Response. */\n                    class Response implements IResponse {\n\n                        /**\n                         * Constructs a new Response.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IResponse);\n\n                        /** Response code. */\n                        public code: number;\n\n                        /** Response message. */\n                        public message: string;\n\n                        /**\n                         * Creates a new Response instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Response instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IResponse): bilibili.community.service.dm.v1.Response;\n\n                        /**\n                         * Encodes the specified Response message. Does not implicitly {@link bilibili.community.service.dm.v1.Response.verify|verify} messages.\n                         * @param message Response message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IResponse, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Response message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Response.verify|verify} messages.\n                         * @param message Response message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IResponse, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a Response message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Response\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Response;\n\n                        /**\n                         * Decodes a Response message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Response\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Response;\n\n                        /**\n                         * Verifies a Response message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a Response message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Response\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Response;\n\n                        /**\n                         * Creates a plain object from a Response message. Also converts values to other types if specified.\n                         * @param message Response\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Response, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Response to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Response\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** SubtitleAiStatus enum. */\n                    enum SubtitleAiStatus {\n                        None = 0,\n                        Exposure = 1,\n                        Assist = 2\n                    }\n\n                    /** SubtitleAiType enum. */\n                    enum SubtitleAiType {\n                        Normal = 0,\n                        Translate = 1\n                    }\n\n                    /** Properties of a SubtitleItem. */\n                    interface ISubtitleItem {\n\n                        /** SubtitleItem id */\n                        id?: (number|Long|null);\n\n                        /** SubtitleItem idStr */\n                        idStr?: (string|null);\n\n                        /** SubtitleItem lan */\n                        lan?: (string|null);\n\n                        /** SubtitleItem lanDoc */\n                        lanDoc?: (string|null);\n\n                        /** SubtitleItem subtitleUrl */\n                        subtitleUrl?: (string|null);\n\n                        /** SubtitleItem author */\n                        author?: (bilibili.community.service.dm.v1.IUserInfo|null);\n\n                        /** SubtitleItem type */\n                        type?: (bilibili.community.service.dm.v1.SubtitleType|null);\n\n                        /** SubtitleItem lanDocBrief */\n                        lanDocBrief?: (string|null);\n\n                        /** SubtitleItem aiType */\n                        aiType?: (bilibili.community.service.dm.v1.SubtitleAiType|null);\n\n                        /** SubtitleItem aiStatus */\n                        aiStatus?: (bilibili.community.service.dm.v1.SubtitleAiStatus|null);\n                    }\n\n                    /** Represents a SubtitleItem. */\n                    class SubtitleItem implements ISubtitleItem {\n\n                        /**\n                         * Constructs a new SubtitleItem.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ISubtitleItem);\n\n                        /** SubtitleItem id. */\n                        public id: (number|Long);\n\n                        /** SubtitleItem idStr. */\n                        public idStr: string;\n\n                        /** SubtitleItem lan. */\n                        public lan: string;\n\n                        /** SubtitleItem lanDoc. */\n                        public lanDoc: string;\n\n                        /** SubtitleItem subtitleUrl. */\n                        public subtitleUrl: string;\n\n                        /** SubtitleItem author. */\n                        public author?: (bilibili.community.service.dm.v1.IUserInfo|null);\n\n                        /** SubtitleItem type. */\n                        public type: bilibili.community.service.dm.v1.SubtitleType;\n\n                        /** SubtitleItem lanDocBrief. */\n                        public lanDocBrief: string;\n\n                        /** SubtitleItem aiType. */\n                        public aiType: bilibili.community.service.dm.v1.SubtitleAiType;\n\n                        /** SubtitleItem aiStatus. */\n                        public aiStatus: bilibili.community.service.dm.v1.SubtitleAiStatus;\n\n                        /**\n                         * Creates a new SubtitleItem instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns SubtitleItem instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ISubtitleItem): bilibili.community.service.dm.v1.SubtitleItem;\n\n                        /**\n                         * Encodes the specified SubtitleItem message. Does not implicitly {@link bilibili.community.service.dm.v1.SubtitleItem.verify|verify} messages.\n                         * @param message SubtitleItem message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ISubtitleItem, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified SubtitleItem message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.SubtitleItem.verify|verify} messages.\n                         * @param message SubtitleItem message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ISubtitleItem, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a SubtitleItem message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns SubtitleItem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.SubtitleItem;\n\n                        /**\n                         * Decodes a SubtitleItem message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns SubtitleItem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.SubtitleItem;\n\n                        /**\n                         * Verifies a SubtitleItem message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a SubtitleItem message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns SubtitleItem\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.SubtitleItem;\n\n                        /**\n                         * Creates a plain object from a SubtitleItem message. Also converts values to other types if specified.\n                         * @param message SubtitleItem\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.SubtitleItem, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this SubtitleItem to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for SubtitleItem\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** SubtitleType enum. */\n                    enum SubtitleType {\n                        CC = 0,\n                        AI = 1\n                    }\n\n                    /** Properties of a TextInput. */\n                    interface ITextInput {\n\n                        /** TextInput portraitPlaceholder */\n                        portraitPlaceholder?: (string[]|null);\n\n                        /** TextInput landscapePlaceholder */\n                        landscapePlaceholder?: (string[]|null);\n\n                        /** TextInput renderType */\n                        renderType?: (bilibili.community.service.dm.v1.RenderType|null);\n\n                        /** TextInput placeholderPost */\n                        placeholderPost?: (boolean|null);\n\n                        /** TextInput show */\n                        show?: (boolean|null);\n\n                        /** TextInput avatar */\n                        avatar?: (bilibili.community.service.dm.v1.IAvatar[]|null);\n\n                        /** TextInput postStatus */\n                        postStatus?: (bilibili.community.service.dm.v1.PostStatus|null);\n\n                        /** TextInput label */\n                        label?: (bilibili.community.service.dm.v1.ILabel|null);\n                    }\n\n                    /** Represents a TextInput. */\n                    class TextInput implements ITextInput {\n\n                        /**\n                         * Constructs a new TextInput.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ITextInput);\n\n                        /** TextInput portraitPlaceholder. */\n                        public portraitPlaceholder: string[];\n\n                        /** TextInput landscapePlaceholder. */\n                        public landscapePlaceholder: string[];\n\n                        /** TextInput renderType. */\n                        public renderType: bilibili.community.service.dm.v1.RenderType;\n\n                        /** TextInput placeholderPost. */\n                        public placeholderPost: boolean;\n\n                        /** TextInput show. */\n                        public show: boolean;\n\n                        /** TextInput avatar. */\n                        public avatar: bilibili.community.service.dm.v1.IAvatar[];\n\n                        /** TextInput postStatus. */\n                        public postStatus: bilibili.community.service.dm.v1.PostStatus;\n\n                        /** TextInput label. */\n                        public label?: (bilibili.community.service.dm.v1.ILabel|null);\n\n                        /**\n                         * Creates a new TextInput instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns TextInput instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ITextInput): bilibili.community.service.dm.v1.TextInput;\n\n                        /**\n                         * Encodes the specified TextInput message. Does not implicitly {@link bilibili.community.service.dm.v1.TextInput.verify|verify} messages.\n                         * @param message TextInput message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ITextInput, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified TextInput message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.TextInput.verify|verify} messages.\n                         * @param message TextInput message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ITextInput, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a TextInput message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns TextInput\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.TextInput;\n\n                        /**\n                         * Decodes a TextInput message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns TextInput\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.TextInput;\n\n                        /**\n                         * Verifies a TextInput message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a TextInput message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns TextInput\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.TextInput;\n\n                        /**\n                         * Creates a plain object from a TextInput message. Also converts values to other types if specified.\n                         * @param message TextInput\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.TextInput, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this TextInput to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for TextInput\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a TextInputV2. */\n                    interface ITextInputV2 {\n\n                        /** TextInputV2 portraitPlaceholder */\n                        portraitPlaceholder?: (string[]|null);\n\n                        /** TextInputV2 landscapePlaceholder */\n                        landscapePlaceholder?: (string[]|null);\n\n                        /** TextInputV2 renderType */\n                        renderType?: (bilibili.community.service.dm.v1.RenderType|null);\n\n                        /** TextInputV2 placeholderPost */\n                        placeholderPost?: (boolean|null);\n\n                        /** TextInputV2 avatar */\n                        avatar?: (bilibili.community.service.dm.v1.IAvatar[]|null);\n\n                        /** TextInputV2 textInputLimit */\n                        textInputLimit?: (number|null);\n                    }\n\n                    /** Represents a TextInputV2. */\n                    class TextInputV2 implements ITextInputV2 {\n\n                        /**\n                         * Constructs a new TextInputV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.ITextInputV2);\n\n                        /** TextInputV2 portraitPlaceholder. */\n                        public portraitPlaceholder: string[];\n\n                        /** TextInputV2 landscapePlaceholder. */\n                        public landscapePlaceholder: string[];\n\n                        /** TextInputV2 renderType. */\n                        public renderType: bilibili.community.service.dm.v1.RenderType;\n\n                        /** TextInputV2 placeholderPost. */\n                        public placeholderPost: boolean;\n\n                        /** TextInputV2 avatar. */\n                        public avatar: bilibili.community.service.dm.v1.IAvatar[];\n\n                        /** TextInputV2 textInputLimit. */\n                        public textInputLimit: number;\n\n                        /**\n                         * Creates a new TextInputV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns TextInputV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.ITextInputV2): bilibili.community.service.dm.v1.TextInputV2;\n\n                        /**\n                         * Encodes the specified TextInputV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.TextInputV2.verify|verify} messages.\n                         * @param message TextInputV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.ITextInputV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified TextInputV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.TextInputV2.verify|verify} messages.\n                         * @param message TextInputV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.ITextInputV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a TextInputV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns TextInputV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.TextInputV2;\n\n                        /**\n                         * Decodes a TextInputV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns TextInputV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.TextInputV2;\n\n                        /**\n                         * Verifies a TextInputV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a TextInputV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns TextInputV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.TextInputV2;\n\n                        /**\n                         * Creates a plain object from a TextInputV2 message. Also converts values to other types if specified.\n                         * @param message TextInputV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.TextInputV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this TextInputV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for TextInputV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a Toast. */\n                    interface IToast {\n\n                        /** Toast text */\n                        text?: (string|null);\n\n                        /** Toast duration */\n                        duration?: (number|null);\n\n                        /** Toast show */\n                        show?: (boolean|null);\n\n                        /** Toast button */\n                        button?: (bilibili.community.service.dm.v1.IButton|null);\n                    }\n\n                    /** Represents a Toast. */\n                    class Toast implements IToast {\n\n                        /**\n                         * Constructs a new Toast.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IToast);\n\n                        /** Toast text. */\n                        public text: string;\n\n                        /** Toast duration. */\n                        public duration: number;\n\n                        /** Toast show. */\n                        public show: boolean;\n\n                        /** Toast button. */\n                        public button?: (bilibili.community.service.dm.v1.IButton|null);\n\n                        /**\n                         * Creates a new Toast instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns Toast instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IToast): bilibili.community.service.dm.v1.Toast;\n\n                        /**\n                         * Encodes the specified Toast message. Does not implicitly {@link bilibili.community.service.dm.v1.Toast.verify|verify} messages.\n                         * @param message Toast message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IToast, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified Toast message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Toast.verify|verify} messages.\n                         * @param message Toast message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IToast, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a Toast message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns Toast\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.Toast;\n\n                        /**\n                         * Decodes a Toast message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns Toast\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.Toast;\n\n                        /**\n                         * Verifies a Toast message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a Toast message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns Toast\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.Toast;\n\n                        /**\n                         * Creates a plain object from a Toast message. Also converts values to other types if specified.\n                         * @param message Toast\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.Toast, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this Toast to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for Toast\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a ToastButtonV2. */\n                    interface IToastButtonV2 {\n\n                        /** ToastButtonV2 text */\n                        text?: (string|null);\n\n                        /** ToastButtonV2 action */\n                        action?: (number|null);\n                    }\n\n                    /** Represents a ToastButtonV2. */\n                    class ToastButtonV2 implements IToastButtonV2 {\n\n                        /**\n                         * Constructs a new ToastButtonV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IToastButtonV2);\n\n                        /** ToastButtonV2 text. */\n                        public text: string;\n\n                        /** ToastButtonV2 action. */\n                        public action: number;\n\n                        /**\n                         * Creates a new ToastButtonV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns ToastButtonV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IToastButtonV2): bilibili.community.service.dm.v1.ToastButtonV2;\n\n                        /**\n                         * Encodes the specified ToastButtonV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.ToastButtonV2.verify|verify} messages.\n                         * @param message ToastButtonV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IToastButtonV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified ToastButtonV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ToastButtonV2.verify|verify} messages.\n                         * @param message ToastButtonV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IToastButtonV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a ToastButtonV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns ToastButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.ToastButtonV2;\n\n                        /**\n                         * Decodes a ToastButtonV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns ToastButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.ToastButtonV2;\n\n                        /**\n                         * Verifies a ToastButtonV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a ToastButtonV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns ToastButtonV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.ToastButtonV2;\n\n                        /**\n                         * Creates a plain object from a ToastButtonV2 message. Also converts values to other types if specified.\n                         * @param message ToastButtonV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.ToastButtonV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this ToastButtonV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for ToastButtonV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** ToastFunctionType enum. */\n                    enum ToastFunctionType {\n                        ToastFunctionTypeNone = 0,\n                        ToastFunctionTypePostPanel = 1\n                    }\n\n                    /** Properties of a ToastV2. */\n                    interface IToastV2 {\n\n                        /** ToastV2 text */\n                        text?: (string|null);\n\n                        /** ToastV2 duration */\n                        duration?: (number|null);\n\n                        /** ToastV2 toastButtonV2 */\n                        toastButtonV2?: (bilibili.community.service.dm.v1.IToastButtonV2|null);\n                    }\n\n                    /** Represents a ToastV2. */\n                    class ToastV2 implements IToastV2 {\n\n                        /**\n                         * Constructs a new ToastV2.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IToastV2);\n\n                        /** ToastV2 text. */\n                        public text: string;\n\n                        /** ToastV2 duration. */\n                        public duration: number;\n\n                        /** ToastV2 toastButtonV2. */\n                        public toastButtonV2?: (bilibili.community.service.dm.v1.IToastButtonV2|null);\n\n                        /**\n                         * Creates a new ToastV2 instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns ToastV2 instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IToastV2): bilibili.community.service.dm.v1.ToastV2;\n\n                        /**\n                         * Encodes the specified ToastV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.ToastV2.verify|verify} messages.\n                         * @param message ToastV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IToastV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified ToastV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ToastV2.verify|verify} messages.\n                         * @param message ToastV2 message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IToastV2, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a ToastV2 message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns ToastV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.ToastV2;\n\n                        /**\n                         * Decodes a ToastV2 message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns ToastV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.ToastV2;\n\n                        /**\n                         * Verifies a ToastV2 message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a ToastV2 message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns ToastV2\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.ToastV2;\n\n                        /**\n                         * Creates a plain object from a ToastV2 message. Also converts values to other types if specified.\n                         * @param message ToastV2\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.ToastV2, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this ToastV2 to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for ToastV2\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a UserInfo. */\n                    interface IUserInfo {\n\n                        /** UserInfo mid */\n                        mid?: (number|Long|null);\n\n                        /** UserInfo name */\n                        name?: (string|null);\n\n                        /** UserInfo sex */\n                        sex?: (string|null);\n\n                        /** UserInfo face */\n                        face?: (string|null);\n\n                        /** UserInfo sign */\n                        sign?: (string|null);\n\n                        /** UserInfo rank */\n                        rank?: (number|null);\n                    }\n\n                    /** Represents a UserInfo. */\n                    class UserInfo implements IUserInfo {\n\n                        /**\n                         * Constructs a new UserInfo.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IUserInfo);\n\n                        /** UserInfo mid. */\n                        public mid: (number|Long);\n\n                        /** UserInfo name. */\n                        public name: string;\n\n                        /** UserInfo sex. */\n                        public sex: string;\n\n                        /** UserInfo face. */\n                        public face: string;\n\n                        /** UserInfo sign. */\n                        public sign: string;\n\n                        /** UserInfo rank. */\n                        public rank: number;\n\n                        /**\n                         * Creates a new UserInfo instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns UserInfo instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IUserInfo): bilibili.community.service.dm.v1.UserInfo;\n\n                        /**\n                         * Encodes the specified UserInfo message. Does not implicitly {@link bilibili.community.service.dm.v1.UserInfo.verify|verify} messages.\n                         * @param message UserInfo message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IUserInfo, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified UserInfo message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.UserInfo.verify|verify} messages.\n                         * @param message UserInfo message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IUserInfo, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a UserInfo message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns UserInfo\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.UserInfo;\n\n                        /**\n                         * Decodes a UserInfo message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns UserInfo\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.UserInfo;\n\n                        /**\n                         * Verifies a UserInfo message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a UserInfo message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns UserInfo\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.UserInfo;\n\n                        /**\n                         * Creates a plain object from a UserInfo message. Also converts values to other types if specified.\n                         * @param message UserInfo\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.UserInfo, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this UserInfo to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for UserInfo\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a VideoMask. */\n                    interface IVideoMask {\n\n                        /** VideoMask cid */\n                        cid?: (number|Long|null);\n\n                        /** VideoMask plat */\n                        plat?: (number|null);\n\n                        /** VideoMask fps */\n                        fps?: (number|null);\n\n                        /** VideoMask time */\n                        time?: (number|Long|null);\n\n                        /** VideoMask maskUrl */\n                        maskUrl?: (string|null);\n                    }\n\n                    /** Represents a VideoMask. */\n                    class VideoMask implements IVideoMask {\n\n                        /**\n                         * Constructs a new VideoMask.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IVideoMask);\n\n                        /** VideoMask cid. */\n                        public cid: (number|Long);\n\n                        /** VideoMask plat. */\n                        public plat: number;\n\n                        /** VideoMask fps. */\n                        public fps: number;\n\n                        /** VideoMask time. */\n                        public time: (number|Long);\n\n                        /** VideoMask maskUrl. */\n                        public maskUrl: string;\n\n                        /**\n                         * Creates a new VideoMask instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns VideoMask instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IVideoMask): bilibili.community.service.dm.v1.VideoMask;\n\n                        /**\n                         * Encodes the specified VideoMask message. Does not implicitly {@link bilibili.community.service.dm.v1.VideoMask.verify|verify} messages.\n                         * @param message VideoMask message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IVideoMask, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified VideoMask message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.VideoMask.verify|verify} messages.\n                         * @param message VideoMask message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IVideoMask, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a VideoMask message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns VideoMask\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.VideoMask;\n\n                        /**\n                         * Decodes a VideoMask message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns VideoMask\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.VideoMask;\n\n                        /**\n                         * Verifies a VideoMask message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a VideoMask message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns VideoMask\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.VideoMask;\n\n                        /**\n                         * Creates a plain object from a VideoMask message. Also converts values to other types if specified.\n                         * @param message VideoMask\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.VideoMask, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this VideoMask to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for VideoMask\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n\n                    /** Properties of a VideoSubtitle. */\n                    interface IVideoSubtitle {\n\n                        /** VideoSubtitle lan */\n                        lan?: (string|null);\n\n                        /** VideoSubtitle lanDoc */\n                        lanDoc?: (string|null);\n\n                        /** VideoSubtitle subtitles */\n                        subtitles?: (bilibili.community.service.dm.v1.ISubtitleItem[]|null);\n                    }\n\n                    /** Represents a VideoSubtitle. */\n                    class VideoSubtitle implements IVideoSubtitle {\n\n                        /**\n                         * Constructs a new VideoSubtitle.\n                         * @param [properties] Properties to set\n                         */\n                        constructor(properties?: bilibili.community.service.dm.v1.IVideoSubtitle);\n\n                        /** VideoSubtitle lan. */\n                        public lan: string;\n\n                        /** VideoSubtitle lanDoc. */\n                        public lanDoc: string;\n\n                        /** VideoSubtitle subtitles. */\n                        public subtitles: bilibili.community.service.dm.v1.ISubtitleItem[];\n\n                        /**\n                         * Creates a new VideoSubtitle instance using the specified properties.\n                         * @param [properties] Properties to set\n                         * @returns VideoSubtitle instance\n                         */\n                        public static create(properties?: bilibili.community.service.dm.v1.IVideoSubtitle): bilibili.community.service.dm.v1.VideoSubtitle;\n\n                        /**\n                         * Encodes the specified VideoSubtitle message. Does not implicitly {@link bilibili.community.service.dm.v1.VideoSubtitle.verify|verify} messages.\n                         * @param message VideoSubtitle message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encode(message: bilibili.community.service.dm.v1.IVideoSubtitle, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Encodes the specified VideoSubtitle message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.VideoSubtitle.verify|verify} messages.\n                         * @param message VideoSubtitle message or plain object to encode\n                         * @param [writer] Writer to encode to\n                         * @returns Writer\n                         */\n                        public static encodeDelimited(message: bilibili.community.service.dm.v1.IVideoSubtitle, writer?: $protobuf.Writer): $protobuf.Writer;\n\n                        /**\n                         * Decodes a VideoSubtitle message from the specified reader or buffer.\n                         * @param reader Reader or buffer to decode from\n                         * @param [length] Message length if known beforehand\n                         * @returns VideoSubtitle\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): bilibili.community.service.dm.v1.VideoSubtitle;\n\n                        /**\n                         * Decodes a VideoSubtitle message from the specified reader or buffer, length delimited.\n                         * @param reader Reader or buffer to decode from\n                         * @returns VideoSubtitle\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): bilibili.community.service.dm.v1.VideoSubtitle;\n\n                        /**\n                         * Verifies a VideoSubtitle message.\n                         * @param message Plain object to verify\n                         * @returns `null` if valid, otherwise the reason why it is not\n                         */\n                        public static verify(message: { [k: string]: any }): (string|null);\n\n                        /**\n                         * Creates a VideoSubtitle message from a plain object. Also converts values to their respective internal types.\n                         * @param object Plain object\n                         * @returns VideoSubtitle\n                         */\n                        public static fromObject(object: { [k: string]: any }): bilibili.community.service.dm.v1.VideoSubtitle;\n\n                        /**\n                         * Creates a plain object from a VideoSubtitle message. Also converts values to other types if specified.\n                         * @param message VideoSubtitle\n                         * @param [options] Conversion options\n                         * @returns Plain object\n                         */\n                        public static toObject(message: bilibili.community.service.dm.v1.VideoSubtitle, options?: $protobuf.IConversionOptions): { [k: string]: any };\n\n                        /**\n                         * Converts this VideoSubtitle to JSON.\n                         * @returns JSON object\n                         */\n                        public toJSON(): { [k: string]: any };\n\n                        /**\n                         * Gets the default type url for VideoSubtitle\n                         * @param [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns The default type url\n                         */\n                        public static getTypeUrl(typeUrlPrefix?: string): string;\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/proto/dm.js",
    "content": "/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars*/\n\"use strict\";\n\nvar $protobuf = require(\"protobufjs/minimal\");\n\n// Common aliases\nvar $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;\n\n// Exported root namespace\nvar $root = $protobuf.roots[\"default\"] || ($protobuf.roots[\"default\"] = {});\n\n$root.bilibili = (function() {\n\n    /**\n     * Namespace bilibili.\n     * @exports bilibili\n     * @namespace\n     */\n    var bilibili = {};\n\n    bilibili.community = (function() {\n\n        /**\n         * Namespace community.\n         * @memberof bilibili\n         * @namespace\n         */\n        var community = {};\n\n        community.service = (function() {\n\n            /**\n             * Namespace service.\n             * @memberof bilibili.community\n             * @namespace\n             */\n            var service = {};\n\n            service.dm = (function() {\n\n                /**\n                 * Namespace dm.\n                 * @memberof bilibili.community.service\n                 * @namespace\n                 */\n                var dm = {};\n\n                dm.v1 = (function() {\n\n                    /**\n                     * Namespace v1.\n                     * @memberof bilibili.community.service.dm\n                     * @namespace\n                     */\n                    var v1 = {};\n\n                    v1.DM = (function() {\n\n                        /**\n                         * Constructs a new DM service.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DM\n                         * @extends $protobuf.rpc.Service\n                         * @constructor\n                         * @param {$protobuf.RPCImpl} rpcImpl RPC implementation\n                         * @param {boolean} [requestDelimited=false] Whether requests are length-delimited\n                         * @param {boolean} [responseDelimited=false] Whether responses are length-delimited\n                         */\n                        function DM(rpcImpl, requestDelimited, responseDelimited) {\n                            $protobuf.rpc.Service.call(this, rpcImpl, requestDelimited, responseDelimited);\n                        }\n\n                        (DM.prototype = Object.create($protobuf.rpc.Service.prototype)).constructor = DM;\n\n                        /**\n                         * Creates new DM service using the specified rpc implementation.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @static\n                         * @param {$protobuf.RPCImpl} rpcImpl RPC implementation\n                         * @param {boolean} [requestDelimited=false] Whether requests are length-delimited\n                         * @param {boolean} [responseDelimited=false] Whether responses are length-delimited\n                         * @returns {DM} RPC service. Useful where requests and/or responses are streamed.\n                         */\n                        DM.create = function create(rpcImpl, requestDelimited, responseDelimited) {\n                            return new this(rpcImpl, requestDelimited, responseDelimited);\n                        };\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmSegMobile}.\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @typedef DmSegMobileCallback\n                         * @type {function}\n                         * @param {Error|null} error Error, if any\n                         * @param {bilibili.community.service.dm.v1.DmSegMobileReply} [response] DmSegMobileReply\n                         */\n\n                        /**\n                         * Calls DmSegMobile.\n                         * @function dmSegMobile\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReq} request DmSegMobileReq message or plain object\n                         * @param {bilibili.community.service.dm.v1.DM.DmSegMobileCallback} callback Node-style callback called with the error, if any, and DmSegMobileReply\n                         * @returns {undefined}\n                         * @variation 1\n                         */\n                        Object.defineProperty(DM.prototype.dmSegMobile = function dmSegMobile(request, callback) {\n                            return this.rpcCall(dmSegMobile, $root.bilibili.community.service.dm.v1.DmSegMobileReq, $root.bilibili.community.service.dm.v1.DmSegMobileReply, request, callback);\n                        }, \"name\", { value: \"DmSegMobile\" });\n\n                        /**\n                         * Calls DmSegMobile.\n                         * @function dmSegMobile\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReq} request DmSegMobileReq message or plain object\n                         * @returns {Promise<bilibili.community.service.dm.v1.DmSegMobileReply>} Promise\n                         * @variation 2\n                         */\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmView}.\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @typedef DmViewCallback\n                         * @type {function}\n                         * @param {Error|null} error Error, if any\n                         * @param {bilibili.community.service.dm.v1.DmViewReply} [response] DmViewReply\n                         */\n\n                        /**\n                         * Calls DmView.\n                         * @function dmView\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmViewReq} request DmViewReq message or plain object\n                         * @param {bilibili.community.service.dm.v1.DM.DmViewCallback} callback Node-style callback called with the error, if any, and DmViewReply\n                         * @returns {undefined}\n                         * @variation 1\n                         */\n                        Object.defineProperty(DM.prototype.dmView = function dmView(request, callback) {\n                            return this.rpcCall(dmView, $root.bilibili.community.service.dm.v1.DmViewReq, $root.bilibili.community.service.dm.v1.DmViewReply, request, callback);\n                        }, \"name\", { value: \"DmView\" });\n\n                        /**\n                         * Calls DmView.\n                         * @function dmView\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmViewReq} request DmViewReq message or plain object\n                         * @returns {Promise<bilibili.community.service.dm.v1.DmViewReply>} Promise\n                         * @variation 2\n                         */\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmPlayerConfig}.\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @typedef DmPlayerConfigCallback\n                         * @type {function}\n                         * @param {Error|null} error Error, if any\n                         * @param {bilibili.community.service.dm.v1.Response} [response] Response\n                         */\n\n                        /**\n                         * Calls DmPlayerConfig.\n                         * @function dmPlayerConfig\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmPlayerConfigReq} request DmPlayerConfigReq message or plain object\n                         * @param {bilibili.community.service.dm.v1.DM.DmPlayerConfigCallback} callback Node-style callback called with the error, if any, and Response\n                         * @returns {undefined}\n                         * @variation 1\n                         */\n                        Object.defineProperty(DM.prototype.dmPlayerConfig = function dmPlayerConfig(request, callback) {\n                            return this.rpcCall(dmPlayerConfig, $root.bilibili.community.service.dm.v1.DmPlayerConfigReq, $root.bilibili.community.service.dm.v1.Response, request, callback);\n                        }, \"name\", { value: \"DmPlayerConfig\" });\n\n                        /**\n                         * Calls DmPlayerConfig.\n                         * @function dmPlayerConfig\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmPlayerConfigReq} request DmPlayerConfigReq message or plain object\n                         * @returns {Promise<bilibili.community.service.dm.v1.Response>} Promise\n                         * @variation 2\n                         */\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmSegOtt}.\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @typedef DmSegOttCallback\n                         * @type {function}\n                         * @param {Error|null} error Error, if any\n                         * @param {bilibili.community.service.dm.v1.DmSegOttReply} [response] DmSegOttReply\n                         */\n\n                        /**\n                         * Calls DmSegOtt.\n                         * @function dmSegOtt\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReq} request DmSegOttReq message or plain object\n                         * @param {bilibili.community.service.dm.v1.DM.DmSegOttCallback} callback Node-style callback called with the error, if any, and DmSegOttReply\n                         * @returns {undefined}\n                         * @variation 1\n                         */\n                        Object.defineProperty(DM.prototype.dmSegOtt = function dmSegOtt(request, callback) {\n                            return this.rpcCall(dmSegOtt, $root.bilibili.community.service.dm.v1.DmSegOttReq, $root.bilibili.community.service.dm.v1.DmSegOttReply, request, callback);\n                        }, \"name\", { value: \"DmSegOtt\" });\n\n                        /**\n                         * Calls DmSegOtt.\n                         * @function dmSegOtt\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReq} request DmSegOttReq message or plain object\n                         * @returns {Promise<bilibili.community.service.dm.v1.DmSegOttReply>} Promise\n                         * @variation 2\n                         */\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmSegSDK}.\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @typedef DmSegSDKCallback\n                         * @type {function}\n                         * @param {Error|null} error Error, if any\n                         * @param {bilibili.community.service.dm.v1.DmSegSDKReply} [response] DmSegSDKReply\n                         */\n\n                        /**\n                         * Calls DmSegSDK.\n                         * @function dmSegSDK\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReq} request DmSegSDKReq message or plain object\n                         * @param {bilibili.community.service.dm.v1.DM.DmSegSDKCallback} callback Node-style callback called with the error, if any, and DmSegSDKReply\n                         * @returns {undefined}\n                         * @variation 1\n                         */\n                        Object.defineProperty(DM.prototype.dmSegSDK = function dmSegSDK(request, callback) {\n                            return this.rpcCall(dmSegSDK, $root.bilibili.community.service.dm.v1.DmSegSDKReq, $root.bilibili.community.service.dm.v1.DmSegSDKReply, request, callback);\n                        }, \"name\", { value: \"DmSegSDK\" });\n\n                        /**\n                         * Calls DmSegSDK.\n                         * @function dmSegSDK\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReq} request DmSegSDKReq message or plain object\n                         * @returns {Promise<bilibili.community.service.dm.v1.DmSegSDKReply>} Promise\n                         * @variation 2\n                         */\n\n                        /**\n                         * Callback as used by {@link bilibili.community.service.dm.v1.DM#dmExpoReport}.\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @typedef DmExpoReportCallback\n                         * @type {function}\n                         * @param {Error|null} error Error, if any\n                         * @param {bilibili.community.service.dm.v1.DmExpoReportRes} [response] DmExpoReportRes\n                         */\n\n                        /**\n                         * Calls DmExpoReport.\n                         * @function dmExpoReport\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportReq} request DmExpoReportReq message or plain object\n                         * @param {bilibili.community.service.dm.v1.DM.DmExpoReportCallback} callback Node-style callback called with the error, if any, and DmExpoReportRes\n                         * @returns {undefined}\n                         * @variation 1\n                         */\n                        Object.defineProperty(DM.prototype.dmExpoReport = function dmExpoReport(request, callback) {\n                            return this.rpcCall(dmExpoReport, $root.bilibili.community.service.dm.v1.DmExpoReportReq, $root.bilibili.community.service.dm.v1.DmExpoReportRes, request, callback);\n                        }, \"name\", { value: \"DmExpoReport\" });\n\n                        /**\n                         * Calls DmExpoReport.\n                         * @function dmExpoReport\n                         * @memberof bilibili.community.service.dm.v1.DM\n                         * @instance\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportReq} request DmExpoReportReq message or plain object\n                         * @returns {Promise<bilibili.community.service.dm.v1.DmExpoReportRes>} Promise\n                         * @variation 2\n                         */\n\n                        return DM;\n                    })();\n\n                    v1.Avatar = (function() {\n\n                        /**\n                         * Properties of an Avatar.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IAvatar\n                         * @property {string|null} [id] Avatar id\n                         * @property {string|null} [url] Avatar url\n                         * @property {bilibili.community.service.dm.v1.AvatarType|null} [avatarType] Avatar avatarType\n                         */\n\n                        /**\n                         * Constructs a new Avatar.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents an Avatar.\n                         * @implements IAvatar\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IAvatar=} [properties] Properties to set\n                         */\n                        function Avatar(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Avatar id.\n                         * @member {string} id\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @instance\n                         */\n                        Avatar.prototype.id = \"\";\n\n                        /**\n                         * Avatar url.\n                         * @member {string} url\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @instance\n                         */\n                        Avatar.prototype.url = \"\";\n\n                        /**\n                         * Avatar avatarType.\n                         * @member {bilibili.community.service.dm.v1.AvatarType} avatarType\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @instance\n                         */\n                        Avatar.prototype.avatarType = 0;\n\n                        /**\n                         * Creates a new Avatar instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IAvatar=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Avatar} Avatar instance\n                         */\n                        Avatar.create = function create(properties) {\n                            return new Avatar(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Avatar message. Does not implicitly {@link bilibili.community.service.dm.v1.Avatar.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IAvatar} message Avatar message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Avatar.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.id != null && Object.hasOwnProperty.call(message, \"id\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.id);\n                            if (message.url != null && Object.hasOwnProperty.call(message, \"url\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.url);\n                            if (message.avatarType != null && Object.hasOwnProperty.call(message, \"avatarType\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.avatarType);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Avatar message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Avatar.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IAvatar} message Avatar message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Avatar.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes an Avatar message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Avatar} Avatar\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Avatar.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Avatar();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.id = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.url = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.avatarType = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes an Avatar message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Avatar} Avatar\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Avatar.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies an Avatar message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Avatar.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (!$util.isString(message.id))\n                                    return \"id: string expected\";\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                if (!$util.isString(message.url))\n                                    return \"url: string expected\";\n                            if (message.avatarType != null && message.hasOwnProperty(\"avatarType\"))\n                                switch (message.avatarType) {\n                                default:\n                                    return \"avatarType: enum value expected\";\n                                case 0:\n                                case 1:\n                                    break;\n                                }\n                            return null;\n                        };\n\n                        /**\n                         * Creates an Avatar message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Avatar} Avatar\n                         */\n                        Avatar.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Avatar)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Avatar();\n                            if (object.id != null)\n                                message.id = String(object.id);\n                            if (object.url != null)\n                                message.url = String(object.url);\n                            switch (object.avatarType) {\n                            default:\n                                if (typeof object.avatarType === \"number\") {\n                                    message.avatarType = object.avatarType;\n                                    break;\n                                }\n                                break;\n                            case \"AvatarTypeNone\":\n                            case 0:\n                                message.avatarType = 0;\n                                break;\n                            case \"AvatarTypeNFT\":\n                            case 1:\n                                message.avatarType = 1;\n                                break;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from an Avatar message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Avatar} message Avatar\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Avatar.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.id = \"\";\n                                object.url = \"\";\n                                object.avatarType = options.enums === String ? \"AvatarTypeNone\" : 0;\n                            }\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                object.id = message.id;\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                object.url = message.url;\n                            if (message.avatarType != null && message.hasOwnProperty(\"avatarType\"))\n                                object.avatarType = options.enums === String ? $root.bilibili.community.service.dm.v1.AvatarType[message.avatarType] === undefined ? message.avatarType : $root.bilibili.community.service.dm.v1.AvatarType[message.avatarType] : message.avatarType;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Avatar to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Avatar.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Avatar\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Avatar\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Avatar.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Avatar\";\n                        };\n\n                        return Avatar;\n                    })();\n\n                    /**\n                     * AvatarType enum.\n                     * @name bilibili.community.service.dm.v1.AvatarType\n                     * @enum {number}\n                     * @property {number} AvatarTypeNone=0 AvatarTypeNone value\n                     * @property {number} AvatarTypeNFT=1 AvatarTypeNFT value\n                     */\n                    v1.AvatarType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"AvatarTypeNone\"] = 0;\n                        values[valuesById[1] = \"AvatarTypeNFT\"] = 1;\n                        return values;\n                    })();\n\n                    v1.Bubble = (function() {\n\n                        /**\n                         * Properties of a Bubble.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IBubble\n                         * @property {string|null} [text] Bubble text\n                         * @property {string|null} [url] Bubble url\n                         */\n\n                        /**\n                         * Constructs a new Bubble.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a Bubble.\n                         * @implements IBubble\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IBubble=} [properties] Properties to set\n                         */\n                        function Bubble(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Bubble text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @instance\n                         */\n                        Bubble.prototype.text = \"\";\n\n                        /**\n                         * Bubble url.\n                         * @member {string} url\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @instance\n                         */\n                        Bubble.prototype.url = \"\";\n\n                        /**\n                         * Creates a new Bubble instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBubble=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Bubble} Bubble instance\n                         */\n                        Bubble.create = function create(properties) {\n                            return new Bubble(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Bubble message. Does not implicitly {@link bilibili.community.service.dm.v1.Bubble.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBubble} message Bubble message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Bubble.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.url != null && Object.hasOwnProperty.call(message, \"url\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.url);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Bubble message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Bubble.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBubble} message Bubble message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Bubble.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a Bubble message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Bubble} Bubble\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Bubble.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Bubble();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.url = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a Bubble message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Bubble} Bubble\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Bubble.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a Bubble message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Bubble.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                if (!$util.isString(message.url))\n                                    return \"url: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a Bubble message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Bubble} Bubble\n                         */\n                        Bubble.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Bubble)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Bubble();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.url != null)\n                                message.url = String(object.url);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a Bubble message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Bubble} message Bubble\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Bubble.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.url = \"\";\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                object.url = message.url;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Bubble to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Bubble.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Bubble\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Bubble\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Bubble.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Bubble\";\n                        };\n\n                        return Bubble;\n                    })();\n\n                    /**\n                     * BubbleType enum.\n                     * @name bilibili.community.service.dm.v1.BubbleType\n                     * @enum {number}\n                     * @property {number} BubbleTypeNone=0 BubbleTypeNone value\n                     * @property {number} BubbleTypeClickButton=1 BubbleTypeClickButton value\n                     * @property {number} BubbleTypeDmSettingPanel=2 BubbleTypeDmSettingPanel value\n                     */\n                    v1.BubbleType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"BubbleTypeNone\"] = 0;\n                        values[valuesById[1] = \"BubbleTypeClickButton\"] = 1;\n                        values[valuesById[2] = \"BubbleTypeDmSettingPanel\"] = 2;\n                        return values;\n                    })();\n\n                    v1.BubbleV2 = (function() {\n\n                        /**\n                         * Properties of a BubbleV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IBubbleV2\n                         * @property {string|null} [text] BubbleV2 text\n                         * @property {string|null} [url] BubbleV2 url\n                         * @property {bilibili.community.service.dm.v1.BubbleType|null} [bubbleType] BubbleV2 bubbleType\n                         * @property {boolean|null} [exposureOnce] BubbleV2 exposureOnce\n                         * @property {bilibili.community.service.dm.v1.ExposureType|null} [exposureType] BubbleV2 exposureType\n                         */\n\n                        /**\n                         * Constructs a new BubbleV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a BubbleV2.\n                         * @implements IBubbleV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IBubbleV2=} [properties] Properties to set\n                         */\n                        function BubbleV2(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * BubbleV2 text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @instance\n                         */\n                        BubbleV2.prototype.text = \"\";\n\n                        /**\n                         * BubbleV2 url.\n                         * @member {string} url\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @instance\n                         */\n                        BubbleV2.prototype.url = \"\";\n\n                        /**\n                         * BubbleV2 bubbleType.\n                         * @member {bilibili.community.service.dm.v1.BubbleType} bubbleType\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @instance\n                         */\n                        BubbleV2.prototype.bubbleType = 0;\n\n                        /**\n                         * BubbleV2 exposureOnce.\n                         * @member {boolean} exposureOnce\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @instance\n                         */\n                        BubbleV2.prototype.exposureOnce = false;\n\n                        /**\n                         * BubbleV2 exposureType.\n                         * @member {bilibili.community.service.dm.v1.ExposureType} exposureType\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @instance\n                         */\n                        BubbleV2.prototype.exposureType = 0;\n\n                        /**\n                         * Creates a new BubbleV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBubbleV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.BubbleV2} BubbleV2 instance\n                         */\n                        BubbleV2.create = function create(properties) {\n                            return new BubbleV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified BubbleV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.BubbleV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBubbleV2} message BubbleV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        BubbleV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.url != null && Object.hasOwnProperty.call(message, \"url\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.url);\n                            if (message.bubbleType != null && Object.hasOwnProperty.call(message, \"bubbleType\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.bubbleType);\n                            if (message.exposureOnce != null && Object.hasOwnProperty.call(message, \"exposureOnce\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.exposureOnce);\n                            if (message.exposureType != null && Object.hasOwnProperty.call(message, \"exposureType\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.exposureType);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified BubbleV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.BubbleV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBubbleV2} message BubbleV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        BubbleV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a BubbleV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.BubbleV2} BubbleV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        BubbleV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.BubbleV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.url = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.bubbleType = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.exposureOnce = reader.bool();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.exposureType = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a BubbleV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.BubbleV2} BubbleV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        BubbleV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a BubbleV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        BubbleV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                if (!$util.isString(message.url))\n                                    return \"url: string expected\";\n                            if (message.bubbleType != null && message.hasOwnProperty(\"bubbleType\"))\n                                switch (message.bubbleType) {\n                                default:\n                                    return \"bubbleType: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                    break;\n                                }\n                            if (message.exposureOnce != null && message.hasOwnProperty(\"exposureOnce\"))\n                                if (typeof message.exposureOnce !== \"boolean\")\n                                    return \"exposureOnce: boolean expected\";\n                            if (message.exposureType != null && message.hasOwnProperty(\"exposureType\"))\n                                switch (message.exposureType) {\n                                default:\n                                    return \"exposureType: enum value expected\";\n                                case 0:\n                                case 1:\n                                    break;\n                                }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a BubbleV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.BubbleV2} BubbleV2\n                         */\n                        BubbleV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.BubbleV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.BubbleV2();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.url != null)\n                                message.url = String(object.url);\n                            switch (object.bubbleType) {\n                            default:\n                                if (typeof object.bubbleType === \"number\") {\n                                    message.bubbleType = object.bubbleType;\n                                    break;\n                                }\n                                break;\n                            case \"BubbleTypeNone\":\n                            case 0:\n                                message.bubbleType = 0;\n                                break;\n                            case \"BubbleTypeClickButton\":\n                            case 1:\n                                message.bubbleType = 1;\n                                break;\n                            case \"BubbleTypeDmSettingPanel\":\n                            case 2:\n                                message.bubbleType = 2;\n                                break;\n                            }\n                            if (object.exposureOnce != null)\n                                message.exposureOnce = Boolean(object.exposureOnce);\n                            switch (object.exposureType) {\n                            default:\n                                if (typeof object.exposureType === \"number\") {\n                                    message.exposureType = object.exposureType;\n                                    break;\n                                }\n                                break;\n                            case \"ExposureTypeNone\":\n                            case 0:\n                                message.exposureType = 0;\n                                break;\n                            case \"ExposureTypeDMSend\":\n                            case 1:\n                                message.exposureType = 1;\n                                break;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a BubbleV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.BubbleV2} message BubbleV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        BubbleV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.url = \"\";\n                                object.bubbleType = options.enums === String ? \"BubbleTypeNone\" : 0;\n                                object.exposureOnce = false;\n                                object.exposureType = options.enums === String ? \"ExposureTypeNone\" : 0;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                object.url = message.url;\n                            if (message.bubbleType != null && message.hasOwnProperty(\"bubbleType\"))\n                                object.bubbleType = options.enums === String ? $root.bilibili.community.service.dm.v1.BubbleType[message.bubbleType] === undefined ? message.bubbleType : $root.bilibili.community.service.dm.v1.BubbleType[message.bubbleType] : message.bubbleType;\n                            if (message.exposureOnce != null && message.hasOwnProperty(\"exposureOnce\"))\n                                object.exposureOnce = message.exposureOnce;\n                            if (message.exposureType != null && message.hasOwnProperty(\"exposureType\"))\n                                object.exposureType = options.enums === String ? $root.bilibili.community.service.dm.v1.ExposureType[message.exposureType] === undefined ? message.exposureType : $root.bilibili.community.service.dm.v1.ExposureType[message.exposureType] : message.exposureType;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this BubbleV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        BubbleV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for BubbleV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.BubbleV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        BubbleV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.BubbleV2\";\n                        };\n\n                        return BubbleV2;\n                    })();\n\n                    v1.Button = (function() {\n\n                        /**\n                         * Properties of a Button.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IButton\n                         * @property {string|null} [text] Button text\n                         * @property {number|null} [action] Button action\n                         */\n\n                        /**\n                         * Constructs a new Button.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a Button.\n                         * @implements IButton\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IButton=} [properties] Properties to set\n                         */\n                        function Button(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Button text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @instance\n                         */\n                        Button.prototype.text = \"\";\n\n                        /**\n                         * Button action.\n                         * @member {number} action\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @instance\n                         */\n                        Button.prototype.action = 0;\n\n                        /**\n                         * Creates a new Button instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IButton=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Button} Button instance\n                         */\n                        Button.create = function create(properties) {\n                            return new Button(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Button message. Does not implicitly {@link bilibili.community.service.dm.v1.Button.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IButton} message Button message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Button.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.action != null && Object.hasOwnProperty.call(message, \"action\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.action);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Button message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Button.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IButton} message Button message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Button.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a Button message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Button} Button\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Button.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Button();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.action = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a Button message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Button} Button\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Button.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a Button message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Button.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.action != null && message.hasOwnProperty(\"action\"))\n                                if (!$util.isInteger(message.action))\n                                    return \"action: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a Button message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Button} Button\n                         */\n                        Button.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Button)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Button();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.action != null)\n                                message.action = object.action | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a Button message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Button} message Button\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Button.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.action = 0;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.action != null && message.hasOwnProperty(\"action\"))\n                                object.action = message.action;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Button to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Button.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Button\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Button\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Button.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Button\";\n                        };\n\n                        return Button;\n                    })();\n\n                    v1.BuzzwordConfig = (function() {\n\n                        /**\n                         * Properties of a BuzzwordConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IBuzzwordConfig\n                         * @property {Array.<bilibili.community.service.dm.v1.IBuzzwordShowConfig>|null} [keywords] BuzzwordConfig keywords\n                         */\n\n                        /**\n                         * Constructs a new BuzzwordConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a BuzzwordConfig.\n                         * @implements IBuzzwordConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordConfig=} [properties] Properties to set\n                         */\n                        function BuzzwordConfig(properties) {\n                            this.keywords = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * BuzzwordConfig keywords.\n                         * @member {Array.<bilibili.community.service.dm.v1.IBuzzwordShowConfig>} keywords\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @instance\n                         */\n                        BuzzwordConfig.prototype.keywords = $util.emptyArray;\n\n                        /**\n                         * Creates a new BuzzwordConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordConfig} BuzzwordConfig instance\n                         */\n                        BuzzwordConfig.create = function create(properties) {\n                            return new BuzzwordConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified BuzzwordConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordConfig} message BuzzwordConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        BuzzwordConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.keywords != null && message.keywords.length)\n                                for (var i = 0; i < message.keywords.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.BuzzwordShowConfig.encode(message.keywords[i], writer.uint32(/* id 1, wireType 2 =*/10).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified BuzzwordConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordConfig} message BuzzwordConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        BuzzwordConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a BuzzwordConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordConfig} BuzzwordConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        BuzzwordConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.BuzzwordConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.keywords && message.keywords.length))\n                                            message.keywords = [];\n                                        message.keywords.push($root.bilibili.community.service.dm.v1.BuzzwordShowConfig.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a BuzzwordConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordConfig} BuzzwordConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        BuzzwordConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a BuzzwordConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        BuzzwordConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.keywords != null && message.hasOwnProperty(\"keywords\")) {\n                                if (!Array.isArray(message.keywords))\n                                    return \"keywords: array expected\";\n                                for (var i = 0; i < message.keywords.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.BuzzwordShowConfig.verify(message.keywords[i]);\n                                    if (error)\n                                        return \"keywords.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a BuzzwordConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordConfig} BuzzwordConfig\n                         */\n                        BuzzwordConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.BuzzwordConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.BuzzwordConfig();\n                            if (object.keywords) {\n                                if (!Array.isArray(object.keywords))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.BuzzwordConfig.keywords: array expected\");\n                                message.keywords = [];\n                                for (var i = 0; i < object.keywords.length; ++i) {\n                                    if (typeof object.keywords[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.BuzzwordConfig.keywords: object expected\");\n                                    message.keywords[i] = $root.bilibili.community.service.dm.v1.BuzzwordShowConfig.fromObject(object.keywords[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a BuzzwordConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.BuzzwordConfig} message BuzzwordConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        BuzzwordConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.keywords = [];\n                            if (message.keywords && message.keywords.length) {\n                                object.keywords = [];\n                                for (var j = 0; j < message.keywords.length; ++j)\n                                    object.keywords[j] = $root.bilibili.community.service.dm.v1.BuzzwordShowConfig.toObject(message.keywords[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this BuzzwordConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        BuzzwordConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for BuzzwordConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        BuzzwordConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.BuzzwordConfig\";\n                        };\n\n                        return BuzzwordConfig;\n                    })();\n\n                    v1.BuzzwordShowConfig = (function() {\n\n                        /**\n                         * Properties of a BuzzwordShowConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IBuzzwordShowConfig\n                         * @property {string|null} [name] BuzzwordShowConfig name\n                         * @property {string|null} [schema] BuzzwordShowConfig schema\n                         * @property {number|null} [source] BuzzwordShowConfig source\n                         * @property {number|Long|null} [id] BuzzwordShowConfig id\n                         * @property {number|Long|null} [buzzwordId] BuzzwordShowConfig buzzwordId\n                         * @property {number|null} [schemaType] BuzzwordShowConfig schemaType\n                         */\n\n                        /**\n                         * Constructs a new BuzzwordShowConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a BuzzwordShowConfig.\n                         * @implements IBuzzwordShowConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordShowConfig=} [properties] Properties to set\n                         */\n                        function BuzzwordShowConfig(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * BuzzwordShowConfig name.\n                         * @member {string} name\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         */\n                        BuzzwordShowConfig.prototype.name = \"\";\n\n                        /**\n                         * BuzzwordShowConfig schema.\n                         * @member {string} schema\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         */\n                        BuzzwordShowConfig.prototype.schema = \"\";\n\n                        /**\n                         * BuzzwordShowConfig source.\n                         * @member {number} source\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         */\n                        BuzzwordShowConfig.prototype.source = 0;\n\n                        /**\n                         * BuzzwordShowConfig id.\n                         * @member {number|Long} id\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         */\n                        BuzzwordShowConfig.prototype.id = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * BuzzwordShowConfig buzzwordId.\n                         * @member {number|Long} buzzwordId\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         */\n                        BuzzwordShowConfig.prototype.buzzwordId = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * BuzzwordShowConfig schemaType.\n                         * @member {number} schemaType\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         */\n                        BuzzwordShowConfig.prototype.schemaType = 0;\n\n                        /**\n                         * Creates a new BuzzwordShowConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordShowConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordShowConfig} BuzzwordShowConfig instance\n                         */\n                        BuzzwordShowConfig.create = function create(properties) {\n                            return new BuzzwordShowConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified BuzzwordShowConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordShowConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordShowConfig} message BuzzwordShowConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        BuzzwordShowConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.name != null && Object.hasOwnProperty.call(message, \"name\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.name);\n                            if (message.schema != null && Object.hasOwnProperty.call(message, \"schema\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.schema);\n                            if (message.source != null && Object.hasOwnProperty.call(message, \"source\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.source);\n                            if (message.id != null && Object.hasOwnProperty.call(message, \"id\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int64(message.id);\n                            if (message.buzzwordId != null && Object.hasOwnProperty.call(message, \"buzzwordId\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int64(message.buzzwordId);\n                            if (message.schemaType != null && Object.hasOwnProperty.call(message, \"schemaType\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).int32(message.schemaType);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified BuzzwordShowConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.BuzzwordShowConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IBuzzwordShowConfig} message BuzzwordShowConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        BuzzwordShowConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a BuzzwordShowConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordShowConfig} BuzzwordShowConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        BuzzwordShowConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.BuzzwordShowConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.name = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.schema = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.source = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.id = reader.int64();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.buzzwordId = reader.int64();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.schemaType = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a BuzzwordShowConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordShowConfig} BuzzwordShowConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        BuzzwordShowConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a BuzzwordShowConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        BuzzwordShowConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.name != null && message.hasOwnProperty(\"name\"))\n                                if (!$util.isString(message.name))\n                                    return \"name: string expected\";\n                            if (message.schema != null && message.hasOwnProperty(\"schema\"))\n                                if (!$util.isString(message.schema))\n                                    return \"schema: string expected\";\n                            if (message.source != null && message.hasOwnProperty(\"source\"))\n                                if (!$util.isInteger(message.source))\n                                    return \"source: integer expected\";\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (!$util.isInteger(message.id) && !(message.id && $util.isInteger(message.id.low) && $util.isInteger(message.id.high)))\n                                    return \"id: integer|Long expected\";\n                            if (message.buzzwordId != null && message.hasOwnProperty(\"buzzwordId\"))\n                                if (!$util.isInteger(message.buzzwordId) && !(message.buzzwordId && $util.isInteger(message.buzzwordId.low) && $util.isInteger(message.buzzwordId.high)))\n                                    return \"buzzwordId: integer|Long expected\";\n                            if (message.schemaType != null && message.hasOwnProperty(\"schemaType\"))\n                                if (!$util.isInteger(message.schemaType))\n                                    return \"schemaType: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a BuzzwordShowConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.BuzzwordShowConfig} BuzzwordShowConfig\n                         */\n                        BuzzwordShowConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.BuzzwordShowConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.BuzzwordShowConfig();\n                            if (object.name != null)\n                                message.name = String(object.name);\n                            if (object.schema != null)\n                                message.schema = String(object.schema);\n                            if (object.source != null)\n                                message.source = object.source | 0;\n                            if (object.id != null)\n                                if ($util.Long)\n                                    (message.id = $util.Long.fromValue(object.id)).unsigned = false;\n                                else if (typeof object.id === \"string\")\n                                    message.id = parseInt(object.id, 10);\n                                else if (typeof object.id === \"number\")\n                                    message.id = object.id;\n                                else if (typeof object.id === \"object\")\n                                    message.id = new $util.LongBits(object.id.low >>> 0, object.id.high >>> 0).toNumber();\n                            if (object.buzzwordId != null)\n                                if ($util.Long)\n                                    (message.buzzwordId = $util.Long.fromValue(object.buzzwordId)).unsigned = false;\n                                else if (typeof object.buzzwordId === \"string\")\n                                    message.buzzwordId = parseInt(object.buzzwordId, 10);\n                                else if (typeof object.buzzwordId === \"number\")\n                                    message.buzzwordId = object.buzzwordId;\n                                else if (typeof object.buzzwordId === \"object\")\n                                    message.buzzwordId = new $util.LongBits(object.buzzwordId.low >>> 0, object.buzzwordId.high >>> 0).toNumber();\n                            if (object.schemaType != null)\n                                message.schemaType = object.schemaType | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a BuzzwordShowConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.BuzzwordShowConfig} message BuzzwordShowConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        BuzzwordShowConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.name = \"\";\n                                object.schema = \"\";\n                                object.source = 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.id = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.id = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.buzzwordId = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.buzzwordId = options.longs === String ? \"0\" : 0;\n                                object.schemaType = 0;\n                            }\n                            if (message.name != null && message.hasOwnProperty(\"name\"))\n                                object.name = message.name;\n                            if (message.schema != null && message.hasOwnProperty(\"schema\"))\n                                object.schema = message.schema;\n                            if (message.source != null && message.hasOwnProperty(\"source\"))\n                                object.source = message.source;\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (typeof message.id === \"number\")\n                                    object.id = options.longs === String ? String(message.id) : message.id;\n                                else\n                                    object.id = options.longs === String ? $util.Long.prototype.toString.call(message.id) : options.longs === Number ? new $util.LongBits(message.id.low >>> 0, message.id.high >>> 0).toNumber() : message.id;\n                            if (message.buzzwordId != null && message.hasOwnProperty(\"buzzwordId\"))\n                                if (typeof message.buzzwordId === \"number\")\n                                    object.buzzwordId = options.longs === String ? String(message.buzzwordId) : message.buzzwordId;\n                                else\n                                    object.buzzwordId = options.longs === String ? $util.Long.prototype.toString.call(message.buzzwordId) : options.longs === Number ? new $util.LongBits(message.buzzwordId.low >>> 0, message.buzzwordId.high >>> 0).toNumber() : message.buzzwordId;\n                            if (message.schemaType != null && message.hasOwnProperty(\"schemaType\"))\n                                object.schemaType = message.schemaType;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this BuzzwordShowConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        BuzzwordShowConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for BuzzwordShowConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.BuzzwordShowConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        BuzzwordShowConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.BuzzwordShowConfig\";\n                        };\n\n                        return BuzzwordShowConfig;\n                    })();\n\n                    v1.CheckBox = (function() {\n\n                        /**\n                         * Properties of a CheckBox.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ICheckBox\n                         * @property {string|null} [text] CheckBox text\n                         * @property {bilibili.community.service.dm.v1.CheckboxType|null} [type] CheckBox type\n                         * @property {boolean|null} [defaultValue] CheckBox defaultValue\n                         * @property {boolean|null} [show] CheckBox show\n                         */\n\n                        /**\n                         * Constructs a new CheckBox.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a CheckBox.\n                         * @implements ICheckBox\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ICheckBox=} [properties] Properties to set\n                         */\n                        function CheckBox(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * CheckBox text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @instance\n                         */\n                        CheckBox.prototype.text = \"\";\n\n                        /**\n                         * CheckBox type.\n                         * @member {bilibili.community.service.dm.v1.CheckboxType} type\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @instance\n                         */\n                        CheckBox.prototype.type = 0;\n\n                        /**\n                         * CheckBox defaultValue.\n                         * @member {boolean} defaultValue\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @instance\n                         */\n                        CheckBox.prototype.defaultValue = false;\n\n                        /**\n                         * CheckBox show.\n                         * @member {boolean} show\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @instance\n                         */\n                        CheckBox.prototype.show = false;\n\n                        /**\n                         * Creates a new CheckBox instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICheckBox=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.CheckBox} CheckBox instance\n                         */\n                        CheckBox.create = function create(properties) {\n                            return new CheckBox(properties);\n                        };\n\n                        /**\n                         * Encodes the specified CheckBox message. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBox.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICheckBox} message CheckBox message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        CheckBox.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.type);\n                            if (message.defaultValue != null && Object.hasOwnProperty.call(message, \"defaultValue\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).bool(message.defaultValue);\n                            if (message.show != null && Object.hasOwnProperty.call(message, \"show\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.show);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified CheckBox message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBox.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICheckBox} message CheckBox message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        CheckBox.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a CheckBox message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.CheckBox} CheckBox\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        CheckBox.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.CheckBox();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.defaultValue = reader.bool();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.show = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a CheckBox message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.CheckBox} CheckBox\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        CheckBox.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a CheckBox message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        CheckBox.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                switch (message.type) {\n                                default:\n                                    return \"type: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                    break;\n                                }\n                            if (message.defaultValue != null && message.hasOwnProperty(\"defaultValue\"))\n                                if (typeof message.defaultValue !== \"boolean\")\n                                    return \"defaultValue: boolean expected\";\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                if (typeof message.show !== \"boolean\")\n                                    return \"show: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a CheckBox message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.CheckBox} CheckBox\n                         */\n                        CheckBox.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.CheckBox)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.CheckBox();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            switch (object.type) {\n                            default:\n                                if (typeof object.type === \"number\") {\n                                    message.type = object.type;\n                                    break;\n                                }\n                                break;\n                            case \"CheckboxTypeNone\":\n                            case 0:\n                                message.type = 0;\n                                break;\n                            case \"CheckboxTypeEncourage\":\n                            case 1:\n                                message.type = 1;\n                                break;\n                            case \"CheckboxTypeColorDM\":\n                            case 2:\n                                message.type = 2;\n                                break;\n                            }\n                            if (object.defaultValue != null)\n                                message.defaultValue = Boolean(object.defaultValue);\n                            if (object.show != null)\n                                message.show = Boolean(object.show);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a CheckBox message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.CheckBox} message CheckBox\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        CheckBox.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.type = options.enums === String ? \"CheckboxTypeNone\" : 0;\n                                object.defaultValue = false;\n                                object.show = false;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = options.enums === String ? $root.bilibili.community.service.dm.v1.CheckboxType[message.type] === undefined ? message.type : $root.bilibili.community.service.dm.v1.CheckboxType[message.type] : message.type;\n                            if (message.defaultValue != null && message.hasOwnProperty(\"defaultValue\"))\n                                object.defaultValue = message.defaultValue;\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                object.show = message.show;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this CheckBox to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        CheckBox.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for CheckBox\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.CheckBox\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        CheckBox.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.CheckBox\";\n                        };\n\n                        return CheckBox;\n                    })();\n\n                    /**\n                     * CheckboxType enum.\n                     * @name bilibili.community.service.dm.v1.CheckboxType\n                     * @enum {number}\n                     * @property {number} CheckboxTypeNone=0 CheckboxTypeNone value\n                     * @property {number} CheckboxTypeEncourage=1 CheckboxTypeEncourage value\n                     * @property {number} CheckboxTypeColorDM=2 CheckboxTypeColorDM value\n                     */\n                    v1.CheckboxType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"CheckboxTypeNone\"] = 0;\n                        values[valuesById[1] = \"CheckboxTypeEncourage\"] = 1;\n                        values[valuesById[2] = \"CheckboxTypeColorDM\"] = 2;\n                        return values;\n                    })();\n\n                    v1.CheckBoxV2 = (function() {\n\n                        /**\n                         * Properties of a CheckBoxV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ICheckBoxV2\n                         * @property {string|null} [text] CheckBoxV2 text\n                         * @property {number|null} [type] CheckBoxV2 type\n                         * @property {boolean|null} [defaultValue] CheckBoxV2 defaultValue\n                         */\n\n                        /**\n                         * Constructs a new CheckBoxV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a CheckBoxV2.\n                         * @implements ICheckBoxV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ICheckBoxV2=} [properties] Properties to set\n                         */\n                        function CheckBoxV2(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * CheckBoxV2 text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @instance\n                         */\n                        CheckBoxV2.prototype.text = \"\";\n\n                        /**\n                         * CheckBoxV2 type.\n                         * @member {number} type\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @instance\n                         */\n                        CheckBoxV2.prototype.type = 0;\n\n                        /**\n                         * CheckBoxV2 defaultValue.\n                         * @member {boolean} defaultValue\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @instance\n                         */\n                        CheckBoxV2.prototype.defaultValue = false;\n\n                        /**\n                         * Creates a new CheckBoxV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICheckBoxV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.CheckBoxV2} CheckBoxV2 instance\n                         */\n                        CheckBoxV2.create = function create(properties) {\n                            return new CheckBoxV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified CheckBoxV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBoxV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICheckBoxV2} message CheckBoxV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        CheckBoxV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.type);\n                            if (message.defaultValue != null && Object.hasOwnProperty.call(message, \"defaultValue\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).bool(message.defaultValue);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified CheckBoxV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.CheckBoxV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICheckBoxV2} message CheckBoxV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        CheckBoxV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a CheckBoxV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.CheckBoxV2} CheckBoxV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        CheckBoxV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.CheckBoxV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.defaultValue = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a CheckBoxV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.CheckBoxV2} CheckBoxV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        CheckBoxV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a CheckBoxV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        CheckBoxV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                if (!$util.isInteger(message.type))\n                                    return \"type: integer expected\";\n                            if (message.defaultValue != null && message.hasOwnProperty(\"defaultValue\"))\n                                if (typeof message.defaultValue !== \"boolean\")\n                                    return \"defaultValue: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a CheckBoxV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.CheckBoxV2} CheckBoxV2\n                         */\n                        CheckBoxV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.CheckBoxV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.CheckBoxV2();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.type != null)\n                                message.type = object.type | 0;\n                            if (object.defaultValue != null)\n                                message.defaultValue = Boolean(object.defaultValue);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a CheckBoxV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.CheckBoxV2} message CheckBoxV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        CheckBoxV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.type = 0;\n                                object.defaultValue = false;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = message.type;\n                            if (message.defaultValue != null && message.hasOwnProperty(\"defaultValue\"))\n                                object.defaultValue = message.defaultValue;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this CheckBoxV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        CheckBoxV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for CheckBoxV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.CheckBoxV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        CheckBoxV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.CheckBoxV2\";\n                        };\n\n                        return CheckBoxV2;\n                    })();\n\n                    v1.ClickButton = (function() {\n\n                        /**\n                         * Properties of a ClickButton.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IClickButton\n                         * @property {Array.<string>|null} [portraitText] ClickButton portraitText\n                         * @property {Array.<string>|null} [landscapeText] ClickButton landscapeText\n                         * @property {Array.<string>|null} [portraitTextFocus] ClickButton portraitTextFocus\n                         * @property {Array.<string>|null} [landscapeTextFocus] ClickButton landscapeTextFocus\n                         * @property {bilibili.community.service.dm.v1.RenderType|null} [renderType] ClickButton renderType\n                         * @property {boolean|null} [show] ClickButton show\n                         * @property {bilibili.community.service.dm.v1.IBubble|null} [bubble] ClickButton bubble\n                         */\n\n                        /**\n                         * Constructs a new ClickButton.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a ClickButton.\n                         * @implements IClickButton\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IClickButton=} [properties] Properties to set\n                         */\n                        function ClickButton(properties) {\n                            this.portraitText = [];\n                            this.landscapeText = [];\n                            this.portraitTextFocus = [];\n                            this.landscapeTextFocus = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * ClickButton portraitText.\n                         * @member {Array.<string>} portraitText\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.portraitText = $util.emptyArray;\n\n                        /**\n                         * ClickButton landscapeText.\n                         * @member {Array.<string>} landscapeText\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.landscapeText = $util.emptyArray;\n\n                        /**\n                         * ClickButton portraitTextFocus.\n                         * @member {Array.<string>} portraitTextFocus\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.portraitTextFocus = $util.emptyArray;\n\n                        /**\n                         * ClickButton landscapeTextFocus.\n                         * @member {Array.<string>} landscapeTextFocus\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.landscapeTextFocus = $util.emptyArray;\n\n                        /**\n                         * ClickButton renderType.\n                         * @member {bilibili.community.service.dm.v1.RenderType} renderType\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.renderType = 0;\n\n                        /**\n                         * ClickButton show.\n                         * @member {boolean} show\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.show = false;\n\n                        /**\n                         * ClickButton bubble.\n                         * @member {bilibili.community.service.dm.v1.IBubble|null|undefined} bubble\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         */\n                        ClickButton.prototype.bubble = null;\n\n                        /**\n                         * Creates a new ClickButton instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IClickButton=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.ClickButton} ClickButton instance\n                         */\n                        ClickButton.create = function create(properties) {\n                            return new ClickButton(properties);\n                        };\n\n                        /**\n                         * Encodes the specified ClickButton message. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButton.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IClickButton} message ClickButton message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ClickButton.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.portraitText != null && message.portraitText.length)\n                                for (var i = 0; i < message.portraitText.length; ++i)\n                                    writer.uint32(/* id 1, wireType 2 =*/10).string(message.portraitText[i]);\n                            if (message.landscapeText != null && message.landscapeText.length)\n                                for (var i = 0; i < message.landscapeText.length; ++i)\n                                    writer.uint32(/* id 2, wireType 2 =*/18).string(message.landscapeText[i]);\n                            if (message.portraitTextFocus != null && message.portraitTextFocus.length)\n                                for (var i = 0; i < message.portraitTextFocus.length; ++i)\n                                    writer.uint32(/* id 3, wireType 2 =*/26).string(message.portraitTextFocus[i]);\n                            if (message.landscapeTextFocus != null && message.landscapeTextFocus.length)\n                                for (var i = 0; i < message.landscapeTextFocus.length; ++i)\n                                    writer.uint32(/* id 4, wireType 2 =*/34).string(message.landscapeTextFocus[i]);\n                            if (message.renderType != null && Object.hasOwnProperty.call(message, \"renderType\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.renderType);\n                            if (message.show != null && Object.hasOwnProperty.call(message, \"show\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).bool(message.show);\n                            if (message.bubble != null && Object.hasOwnProperty.call(message, \"bubble\"))\n                                $root.bilibili.community.service.dm.v1.Bubble.encode(message.bubble, writer.uint32(/* id 7, wireType 2 =*/58).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified ClickButton message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButton.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IClickButton} message ClickButton message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ClickButton.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a ClickButton message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.ClickButton} ClickButton\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ClickButton.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.ClickButton();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.portraitText && message.portraitText.length))\n                                            message.portraitText = [];\n                                        message.portraitText.push(reader.string());\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.landscapeText && message.landscapeText.length))\n                                            message.landscapeText = [];\n                                        message.landscapeText.push(reader.string());\n                                        break;\n                                    }\n                                case 3: {\n                                        if (!(message.portraitTextFocus && message.portraitTextFocus.length))\n                                            message.portraitTextFocus = [];\n                                        message.portraitTextFocus.push(reader.string());\n                                        break;\n                                    }\n                                case 4: {\n                                        if (!(message.landscapeTextFocus && message.landscapeTextFocus.length))\n                                            message.landscapeTextFocus = [];\n                                        message.landscapeTextFocus.push(reader.string());\n                                        break;\n                                    }\n                                case 5: {\n                                        message.renderType = reader.int32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.show = reader.bool();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.bubble = $root.bilibili.community.service.dm.v1.Bubble.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a ClickButton message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.ClickButton} ClickButton\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ClickButton.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a ClickButton message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        ClickButton.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.portraitText != null && message.hasOwnProperty(\"portraitText\")) {\n                                if (!Array.isArray(message.portraitText))\n                                    return \"portraitText: array expected\";\n                                for (var i = 0; i < message.portraitText.length; ++i)\n                                    if (!$util.isString(message.portraitText[i]))\n                                        return \"portraitText: string[] expected\";\n                            }\n                            if (message.landscapeText != null && message.hasOwnProperty(\"landscapeText\")) {\n                                if (!Array.isArray(message.landscapeText))\n                                    return \"landscapeText: array expected\";\n                                for (var i = 0; i < message.landscapeText.length; ++i)\n                                    if (!$util.isString(message.landscapeText[i]))\n                                        return \"landscapeText: string[] expected\";\n                            }\n                            if (message.portraitTextFocus != null && message.hasOwnProperty(\"portraitTextFocus\")) {\n                                if (!Array.isArray(message.portraitTextFocus))\n                                    return \"portraitTextFocus: array expected\";\n                                for (var i = 0; i < message.portraitTextFocus.length; ++i)\n                                    if (!$util.isString(message.portraitTextFocus[i]))\n                                        return \"portraitTextFocus: string[] expected\";\n                            }\n                            if (message.landscapeTextFocus != null && message.hasOwnProperty(\"landscapeTextFocus\")) {\n                                if (!Array.isArray(message.landscapeTextFocus))\n                                    return \"landscapeTextFocus: array expected\";\n                                for (var i = 0; i < message.landscapeTextFocus.length; ++i)\n                                    if (!$util.isString(message.landscapeTextFocus[i]))\n                                        return \"landscapeTextFocus: string[] expected\";\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                switch (message.renderType) {\n                                default:\n                                    return \"renderType: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                    break;\n                                }\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                if (typeof message.show !== \"boolean\")\n                                    return \"show: boolean expected\";\n                            if (message.bubble != null && message.hasOwnProperty(\"bubble\")) {\n                                var error = $root.bilibili.community.service.dm.v1.Bubble.verify(message.bubble);\n                                if (error)\n                                    return \"bubble.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a ClickButton message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.ClickButton} ClickButton\n                         */\n                        ClickButton.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.ClickButton)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.ClickButton();\n                            if (object.portraitText) {\n                                if (!Array.isArray(object.portraitText))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButton.portraitText: array expected\");\n                                message.portraitText = [];\n                                for (var i = 0; i < object.portraitText.length; ++i)\n                                    message.portraitText[i] = String(object.portraitText[i]);\n                            }\n                            if (object.landscapeText) {\n                                if (!Array.isArray(object.landscapeText))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButton.landscapeText: array expected\");\n                                message.landscapeText = [];\n                                for (var i = 0; i < object.landscapeText.length; ++i)\n                                    message.landscapeText[i] = String(object.landscapeText[i]);\n                            }\n                            if (object.portraitTextFocus) {\n                                if (!Array.isArray(object.portraitTextFocus))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButton.portraitTextFocus: array expected\");\n                                message.portraitTextFocus = [];\n                                for (var i = 0; i < object.portraitTextFocus.length; ++i)\n                                    message.portraitTextFocus[i] = String(object.portraitTextFocus[i]);\n                            }\n                            if (object.landscapeTextFocus) {\n                                if (!Array.isArray(object.landscapeTextFocus))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButton.landscapeTextFocus: array expected\");\n                                message.landscapeTextFocus = [];\n                                for (var i = 0; i < object.landscapeTextFocus.length; ++i)\n                                    message.landscapeTextFocus[i] = String(object.landscapeTextFocus[i]);\n                            }\n                            switch (object.renderType) {\n                            default:\n                                if (typeof object.renderType === \"number\") {\n                                    message.renderType = object.renderType;\n                                    break;\n                                }\n                                break;\n                            case \"RenderTypeNone\":\n                            case 0:\n                                message.renderType = 0;\n                                break;\n                            case \"RenderTypeSingle\":\n                            case 1:\n                                message.renderType = 1;\n                                break;\n                            case \"RenderTypeRotation\":\n                            case 2:\n                                message.renderType = 2;\n                                break;\n                            }\n                            if (object.show != null)\n                                message.show = Boolean(object.show);\n                            if (object.bubble != null) {\n                                if (typeof object.bubble !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButton.bubble: object expected\");\n                                message.bubble = $root.bilibili.community.service.dm.v1.Bubble.fromObject(object.bubble);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a ClickButton message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ClickButton} message ClickButton\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        ClickButton.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.portraitText = [];\n                                object.landscapeText = [];\n                                object.portraitTextFocus = [];\n                                object.landscapeTextFocus = [];\n                            }\n                            if (options.defaults) {\n                                object.renderType = options.enums === String ? \"RenderTypeNone\" : 0;\n                                object.show = false;\n                                object.bubble = null;\n                            }\n                            if (message.portraitText && message.portraitText.length) {\n                                object.portraitText = [];\n                                for (var j = 0; j < message.portraitText.length; ++j)\n                                    object.portraitText[j] = message.portraitText[j];\n                            }\n                            if (message.landscapeText && message.landscapeText.length) {\n                                object.landscapeText = [];\n                                for (var j = 0; j < message.landscapeText.length; ++j)\n                                    object.landscapeText[j] = message.landscapeText[j];\n                            }\n                            if (message.portraitTextFocus && message.portraitTextFocus.length) {\n                                object.portraitTextFocus = [];\n                                for (var j = 0; j < message.portraitTextFocus.length; ++j)\n                                    object.portraitTextFocus[j] = message.portraitTextFocus[j];\n                            }\n                            if (message.landscapeTextFocus && message.landscapeTextFocus.length) {\n                                object.landscapeTextFocus = [];\n                                for (var j = 0; j < message.landscapeTextFocus.length; ++j)\n                                    object.landscapeTextFocus[j] = message.landscapeTextFocus[j];\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                object.renderType = options.enums === String ? $root.bilibili.community.service.dm.v1.RenderType[message.renderType] === undefined ? message.renderType : $root.bilibili.community.service.dm.v1.RenderType[message.renderType] : message.renderType;\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                object.show = message.show;\n                            if (message.bubble != null && message.hasOwnProperty(\"bubble\"))\n                                object.bubble = $root.bilibili.community.service.dm.v1.Bubble.toObject(message.bubble, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this ClickButton to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        ClickButton.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for ClickButton\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.ClickButton\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        ClickButton.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.ClickButton\";\n                        };\n\n                        return ClickButton;\n                    })();\n\n                    v1.ClickButtonV2 = (function() {\n\n                        /**\n                         * Properties of a ClickButtonV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IClickButtonV2\n                         * @property {Array.<string>|null} [portraitText] ClickButtonV2 portraitText\n                         * @property {Array.<string>|null} [landscapeText] ClickButtonV2 landscapeText\n                         * @property {Array.<string>|null} [portraitTextFocus] ClickButtonV2 portraitTextFocus\n                         * @property {Array.<string>|null} [landscapeTextFocus] ClickButtonV2 landscapeTextFocus\n                         * @property {number|null} [renderType] ClickButtonV2 renderType\n                         * @property {boolean|null} [textInputPost] ClickButtonV2 textInputPost\n                         * @property {boolean|null} [exposureOnce] ClickButtonV2 exposureOnce\n                         * @property {number|null} [exposureType] ClickButtonV2 exposureType\n                         */\n\n                        /**\n                         * Constructs a new ClickButtonV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a ClickButtonV2.\n                         * @implements IClickButtonV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IClickButtonV2=} [properties] Properties to set\n                         */\n                        function ClickButtonV2(properties) {\n                            this.portraitText = [];\n                            this.landscapeText = [];\n                            this.portraitTextFocus = [];\n                            this.landscapeTextFocus = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * ClickButtonV2 portraitText.\n                         * @member {Array.<string>} portraitText\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.portraitText = $util.emptyArray;\n\n                        /**\n                         * ClickButtonV2 landscapeText.\n                         * @member {Array.<string>} landscapeText\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.landscapeText = $util.emptyArray;\n\n                        /**\n                         * ClickButtonV2 portraitTextFocus.\n                         * @member {Array.<string>} portraitTextFocus\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.portraitTextFocus = $util.emptyArray;\n\n                        /**\n                         * ClickButtonV2 landscapeTextFocus.\n                         * @member {Array.<string>} landscapeTextFocus\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.landscapeTextFocus = $util.emptyArray;\n\n                        /**\n                         * ClickButtonV2 renderType.\n                         * @member {number} renderType\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.renderType = 0;\n\n                        /**\n                         * ClickButtonV2 textInputPost.\n                         * @member {boolean} textInputPost\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.textInputPost = false;\n\n                        /**\n                         * ClickButtonV2 exposureOnce.\n                         * @member {boolean} exposureOnce\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.exposureOnce = false;\n\n                        /**\n                         * ClickButtonV2 exposureType.\n                         * @member {number} exposureType\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         */\n                        ClickButtonV2.prototype.exposureType = 0;\n\n                        /**\n                         * Creates a new ClickButtonV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IClickButtonV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.ClickButtonV2} ClickButtonV2 instance\n                         */\n                        ClickButtonV2.create = function create(properties) {\n                            return new ClickButtonV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified ClickButtonV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButtonV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IClickButtonV2} message ClickButtonV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ClickButtonV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.portraitText != null && message.portraitText.length)\n                                for (var i = 0; i < message.portraitText.length; ++i)\n                                    writer.uint32(/* id 1, wireType 2 =*/10).string(message.portraitText[i]);\n                            if (message.landscapeText != null && message.landscapeText.length)\n                                for (var i = 0; i < message.landscapeText.length; ++i)\n                                    writer.uint32(/* id 2, wireType 2 =*/18).string(message.landscapeText[i]);\n                            if (message.portraitTextFocus != null && message.portraitTextFocus.length)\n                                for (var i = 0; i < message.portraitTextFocus.length; ++i)\n                                    writer.uint32(/* id 3, wireType 2 =*/26).string(message.portraitTextFocus[i]);\n                            if (message.landscapeTextFocus != null && message.landscapeTextFocus.length)\n                                for (var i = 0; i < message.landscapeTextFocus.length; ++i)\n                                    writer.uint32(/* id 4, wireType 2 =*/34).string(message.landscapeTextFocus[i]);\n                            if (message.renderType != null && Object.hasOwnProperty.call(message, \"renderType\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.renderType);\n                            if (message.textInputPost != null && Object.hasOwnProperty.call(message, \"textInputPost\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).bool(message.textInputPost);\n                            if (message.exposureOnce != null && Object.hasOwnProperty.call(message, \"exposureOnce\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).bool(message.exposureOnce);\n                            if (message.exposureType != null && Object.hasOwnProperty.call(message, \"exposureType\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).int32(message.exposureType);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified ClickButtonV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ClickButtonV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IClickButtonV2} message ClickButtonV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ClickButtonV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a ClickButtonV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.ClickButtonV2} ClickButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ClickButtonV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.ClickButtonV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.portraitText && message.portraitText.length))\n                                            message.portraitText = [];\n                                        message.portraitText.push(reader.string());\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.landscapeText && message.landscapeText.length))\n                                            message.landscapeText = [];\n                                        message.landscapeText.push(reader.string());\n                                        break;\n                                    }\n                                case 3: {\n                                        if (!(message.portraitTextFocus && message.portraitTextFocus.length))\n                                            message.portraitTextFocus = [];\n                                        message.portraitTextFocus.push(reader.string());\n                                        break;\n                                    }\n                                case 4: {\n                                        if (!(message.landscapeTextFocus && message.landscapeTextFocus.length))\n                                            message.landscapeTextFocus = [];\n                                        message.landscapeTextFocus.push(reader.string());\n                                        break;\n                                    }\n                                case 5: {\n                                        message.renderType = reader.int32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.textInputPost = reader.bool();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.exposureOnce = reader.bool();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.exposureType = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a ClickButtonV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.ClickButtonV2} ClickButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ClickButtonV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a ClickButtonV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        ClickButtonV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.portraitText != null && message.hasOwnProperty(\"portraitText\")) {\n                                if (!Array.isArray(message.portraitText))\n                                    return \"portraitText: array expected\";\n                                for (var i = 0; i < message.portraitText.length; ++i)\n                                    if (!$util.isString(message.portraitText[i]))\n                                        return \"portraitText: string[] expected\";\n                            }\n                            if (message.landscapeText != null && message.hasOwnProperty(\"landscapeText\")) {\n                                if (!Array.isArray(message.landscapeText))\n                                    return \"landscapeText: array expected\";\n                                for (var i = 0; i < message.landscapeText.length; ++i)\n                                    if (!$util.isString(message.landscapeText[i]))\n                                        return \"landscapeText: string[] expected\";\n                            }\n                            if (message.portraitTextFocus != null && message.hasOwnProperty(\"portraitTextFocus\")) {\n                                if (!Array.isArray(message.portraitTextFocus))\n                                    return \"portraitTextFocus: array expected\";\n                                for (var i = 0; i < message.portraitTextFocus.length; ++i)\n                                    if (!$util.isString(message.portraitTextFocus[i]))\n                                        return \"portraitTextFocus: string[] expected\";\n                            }\n                            if (message.landscapeTextFocus != null && message.hasOwnProperty(\"landscapeTextFocus\")) {\n                                if (!Array.isArray(message.landscapeTextFocus))\n                                    return \"landscapeTextFocus: array expected\";\n                                for (var i = 0; i < message.landscapeTextFocus.length; ++i)\n                                    if (!$util.isString(message.landscapeTextFocus[i]))\n                                        return \"landscapeTextFocus: string[] expected\";\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                if (!$util.isInteger(message.renderType))\n                                    return \"renderType: integer expected\";\n                            if (message.textInputPost != null && message.hasOwnProperty(\"textInputPost\"))\n                                if (typeof message.textInputPost !== \"boolean\")\n                                    return \"textInputPost: boolean expected\";\n                            if (message.exposureOnce != null && message.hasOwnProperty(\"exposureOnce\"))\n                                if (typeof message.exposureOnce !== \"boolean\")\n                                    return \"exposureOnce: boolean expected\";\n                            if (message.exposureType != null && message.hasOwnProperty(\"exposureType\"))\n                                if (!$util.isInteger(message.exposureType))\n                                    return \"exposureType: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a ClickButtonV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.ClickButtonV2} ClickButtonV2\n                         */\n                        ClickButtonV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.ClickButtonV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.ClickButtonV2();\n                            if (object.portraitText) {\n                                if (!Array.isArray(object.portraitText))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButtonV2.portraitText: array expected\");\n                                message.portraitText = [];\n                                for (var i = 0; i < object.portraitText.length; ++i)\n                                    message.portraitText[i] = String(object.portraitText[i]);\n                            }\n                            if (object.landscapeText) {\n                                if (!Array.isArray(object.landscapeText))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButtonV2.landscapeText: array expected\");\n                                message.landscapeText = [];\n                                for (var i = 0; i < object.landscapeText.length; ++i)\n                                    message.landscapeText[i] = String(object.landscapeText[i]);\n                            }\n                            if (object.portraitTextFocus) {\n                                if (!Array.isArray(object.portraitTextFocus))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButtonV2.portraitTextFocus: array expected\");\n                                message.portraitTextFocus = [];\n                                for (var i = 0; i < object.portraitTextFocus.length; ++i)\n                                    message.portraitTextFocus[i] = String(object.portraitTextFocus[i]);\n                            }\n                            if (object.landscapeTextFocus) {\n                                if (!Array.isArray(object.landscapeTextFocus))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ClickButtonV2.landscapeTextFocus: array expected\");\n                                message.landscapeTextFocus = [];\n                                for (var i = 0; i < object.landscapeTextFocus.length; ++i)\n                                    message.landscapeTextFocus[i] = String(object.landscapeTextFocus[i]);\n                            }\n                            if (object.renderType != null)\n                                message.renderType = object.renderType | 0;\n                            if (object.textInputPost != null)\n                                message.textInputPost = Boolean(object.textInputPost);\n                            if (object.exposureOnce != null)\n                                message.exposureOnce = Boolean(object.exposureOnce);\n                            if (object.exposureType != null)\n                                message.exposureType = object.exposureType | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a ClickButtonV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ClickButtonV2} message ClickButtonV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        ClickButtonV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.portraitText = [];\n                                object.landscapeText = [];\n                                object.portraitTextFocus = [];\n                                object.landscapeTextFocus = [];\n                            }\n                            if (options.defaults) {\n                                object.renderType = 0;\n                                object.textInputPost = false;\n                                object.exposureOnce = false;\n                                object.exposureType = 0;\n                            }\n                            if (message.portraitText && message.portraitText.length) {\n                                object.portraitText = [];\n                                for (var j = 0; j < message.portraitText.length; ++j)\n                                    object.portraitText[j] = message.portraitText[j];\n                            }\n                            if (message.landscapeText && message.landscapeText.length) {\n                                object.landscapeText = [];\n                                for (var j = 0; j < message.landscapeText.length; ++j)\n                                    object.landscapeText[j] = message.landscapeText[j];\n                            }\n                            if (message.portraitTextFocus && message.portraitTextFocus.length) {\n                                object.portraitTextFocus = [];\n                                for (var j = 0; j < message.portraitTextFocus.length; ++j)\n                                    object.portraitTextFocus[j] = message.portraitTextFocus[j];\n                            }\n                            if (message.landscapeTextFocus && message.landscapeTextFocus.length) {\n                                object.landscapeTextFocus = [];\n                                for (var j = 0; j < message.landscapeTextFocus.length; ++j)\n                                    object.landscapeTextFocus[j] = message.landscapeTextFocus[j];\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                object.renderType = message.renderType;\n                            if (message.textInputPost != null && message.hasOwnProperty(\"textInputPost\"))\n                                object.textInputPost = message.textInputPost;\n                            if (message.exposureOnce != null && message.hasOwnProperty(\"exposureOnce\"))\n                                object.exposureOnce = message.exposureOnce;\n                            if (message.exposureType != null && message.hasOwnProperty(\"exposureType\"))\n                                object.exposureType = message.exposureType;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this ClickButtonV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        ClickButtonV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for ClickButtonV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.ClickButtonV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        ClickButtonV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.ClickButtonV2\";\n                        };\n\n                        return ClickButtonV2;\n                    })();\n\n                    v1.CommandDm = (function() {\n\n                        /**\n                         * Properties of a CommandDm.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ICommandDm\n                         * @property {number|Long|null} [id] CommandDm id\n                         * @property {number|Long|null} [oid] CommandDm oid\n                         * @property {string|null} [mid] CommandDm mid\n                         * @property {string|null} [command] CommandDm command\n                         * @property {string|null} [content] CommandDm content\n                         * @property {number|null} [progress] CommandDm progress\n                         * @property {string|null} [ctime] CommandDm ctime\n                         * @property {string|null} [mtime] CommandDm mtime\n                         * @property {string|null} [extra] CommandDm extra\n                         * @property {string|null} [idStr] CommandDm idStr\n                         */\n\n                        /**\n                         * Constructs a new CommandDm.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a CommandDm.\n                         * @implements ICommandDm\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ICommandDm=} [properties] Properties to set\n                         */\n                        function CommandDm(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * CommandDm id.\n                         * @member {number|Long} id\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.id = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * CommandDm oid.\n                         * @member {number|Long} oid\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.oid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * CommandDm mid.\n                         * @member {string} mid\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.mid = \"\";\n\n                        /**\n                         * CommandDm command.\n                         * @member {string} command\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.command = \"\";\n\n                        /**\n                         * CommandDm content.\n                         * @member {string} content\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.content = \"\";\n\n                        /**\n                         * CommandDm progress.\n                         * @member {number} progress\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.progress = 0;\n\n                        /**\n                         * CommandDm ctime.\n                         * @member {string} ctime\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.ctime = \"\";\n\n                        /**\n                         * CommandDm mtime.\n                         * @member {string} mtime\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.mtime = \"\";\n\n                        /**\n                         * CommandDm extra.\n                         * @member {string} extra\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.extra = \"\";\n\n                        /**\n                         * CommandDm idStr.\n                         * @member {string} idStr\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         */\n                        CommandDm.prototype.idStr = \"\";\n\n                        /**\n                         * Creates a new CommandDm instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICommandDm=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.CommandDm} CommandDm instance\n                         */\n                        CommandDm.create = function create(properties) {\n                            return new CommandDm(properties);\n                        };\n\n                        /**\n                         * Encodes the specified CommandDm message. Does not implicitly {@link bilibili.community.service.dm.v1.CommandDm.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICommandDm} message CommandDm message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        CommandDm.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.id != null && Object.hasOwnProperty.call(message, \"id\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.id);\n                            if (message.oid != null && Object.hasOwnProperty.call(message, \"oid\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.oid);\n                            if (message.mid != null && Object.hasOwnProperty.call(message, \"mid\"))\n                                writer.uint32(/* id 3, wireType 2 =*/26).string(message.mid);\n                            if (message.command != null && Object.hasOwnProperty.call(message, \"command\"))\n                                writer.uint32(/* id 4, wireType 2 =*/34).string(message.command);\n                            if (message.content != null && Object.hasOwnProperty.call(message, \"content\"))\n                                writer.uint32(/* id 5, wireType 2 =*/42).string(message.content);\n                            if (message.progress != null && Object.hasOwnProperty.call(message, \"progress\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).int32(message.progress);\n                            if (message.ctime != null && Object.hasOwnProperty.call(message, \"ctime\"))\n                                writer.uint32(/* id 7, wireType 2 =*/58).string(message.ctime);\n                            if (message.mtime != null && Object.hasOwnProperty.call(message, \"mtime\"))\n                                writer.uint32(/* id 8, wireType 2 =*/66).string(message.mtime);\n                            if (message.extra != null && Object.hasOwnProperty.call(message, \"extra\"))\n                                writer.uint32(/* id 9, wireType 2 =*/74).string(message.extra);\n                            if (message.idStr != null && Object.hasOwnProperty.call(message, \"idStr\"))\n                                writer.uint32(/* id 10, wireType 2 =*/82).string(message.idStr);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified CommandDm message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.CommandDm.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ICommandDm} message CommandDm message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        CommandDm.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a CommandDm message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.CommandDm} CommandDm\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        CommandDm.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.CommandDm();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.id = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.oid = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.mid = reader.string();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.command = reader.string();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.content = reader.string();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.progress = reader.int32();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.ctime = reader.string();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.mtime = reader.string();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.extra = reader.string();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.idStr = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a CommandDm message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.CommandDm} CommandDm\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        CommandDm.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a CommandDm message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        CommandDm.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (!$util.isInteger(message.id) && !(message.id && $util.isInteger(message.id.low) && $util.isInteger(message.id.high)))\n                                    return \"id: integer|Long expected\";\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (!$util.isInteger(message.oid) && !(message.oid && $util.isInteger(message.oid.low) && $util.isInteger(message.oid.high)))\n                                    return \"oid: integer|Long expected\";\n                            if (message.mid != null && message.hasOwnProperty(\"mid\"))\n                                if (!$util.isString(message.mid))\n                                    return \"mid: string expected\";\n                            if (message.command != null && message.hasOwnProperty(\"command\"))\n                                if (!$util.isString(message.command))\n                                    return \"command: string expected\";\n                            if (message.content != null && message.hasOwnProperty(\"content\"))\n                                if (!$util.isString(message.content))\n                                    return \"content: string expected\";\n                            if (message.progress != null && message.hasOwnProperty(\"progress\"))\n                                if (!$util.isInteger(message.progress))\n                                    return \"progress: integer expected\";\n                            if (message.ctime != null && message.hasOwnProperty(\"ctime\"))\n                                if (!$util.isString(message.ctime))\n                                    return \"ctime: string expected\";\n                            if (message.mtime != null && message.hasOwnProperty(\"mtime\"))\n                                if (!$util.isString(message.mtime))\n                                    return \"mtime: string expected\";\n                            if (message.extra != null && message.hasOwnProperty(\"extra\"))\n                                if (!$util.isString(message.extra))\n                                    return \"extra: string expected\";\n                            if (message.idStr != null && message.hasOwnProperty(\"idStr\"))\n                                if (!$util.isString(message.idStr))\n                                    return \"idStr: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a CommandDm message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.CommandDm} CommandDm\n                         */\n                        CommandDm.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.CommandDm)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.CommandDm();\n                            if (object.id != null)\n                                if ($util.Long)\n                                    (message.id = $util.Long.fromValue(object.id)).unsigned = false;\n                                else if (typeof object.id === \"string\")\n                                    message.id = parseInt(object.id, 10);\n                                else if (typeof object.id === \"number\")\n                                    message.id = object.id;\n                                else if (typeof object.id === \"object\")\n                                    message.id = new $util.LongBits(object.id.low >>> 0, object.id.high >>> 0).toNumber();\n                            if (object.oid != null)\n                                if ($util.Long)\n                                    (message.oid = $util.Long.fromValue(object.oid)).unsigned = false;\n                                else if (typeof object.oid === \"string\")\n                                    message.oid = parseInt(object.oid, 10);\n                                else if (typeof object.oid === \"number\")\n                                    message.oid = object.oid;\n                                else if (typeof object.oid === \"object\")\n                                    message.oid = new $util.LongBits(object.oid.low >>> 0, object.oid.high >>> 0).toNumber();\n                            if (object.mid != null)\n                                message.mid = String(object.mid);\n                            if (object.command != null)\n                                message.command = String(object.command);\n                            if (object.content != null)\n                                message.content = String(object.content);\n                            if (object.progress != null)\n                                message.progress = object.progress | 0;\n                            if (object.ctime != null)\n                                message.ctime = String(object.ctime);\n                            if (object.mtime != null)\n                                message.mtime = String(object.mtime);\n                            if (object.extra != null)\n                                message.extra = String(object.extra);\n                            if (object.idStr != null)\n                                message.idStr = String(object.idStr);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a CommandDm message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.CommandDm} message CommandDm\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        CommandDm.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.id = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.id = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.oid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.oid = options.longs === String ? \"0\" : 0;\n                                object.mid = \"\";\n                                object.command = \"\";\n                                object.content = \"\";\n                                object.progress = 0;\n                                object.ctime = \"\";\n                                object.mtime = \"\";\n                                object.extra = \"\";\n                                object.idStr = \"\";\n                            }\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (typeof message.id === \"number\")\n                                    object.id = options.longs === String ? String(message.id) : message.id;\n                                else\n                                    object.id = options.longs === String ? $util.Long.prototype.toString.call(message.id) : options.longs === Number ? new $util.LongBits(message.id.low >>> 0, message.id.high >>> 0).toNumber() : message.id;\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (typeof message.oid === \"number\")\n                                    object.oid = options.longs === String ? String(message.oid) : message.oid;\n                                else\n                                    object.oid = options.longs === String ? $util.Long.prototype.toString.call(message.oid) : options.longs === Number ? new $util.LongBits(message.oid.low >>> 0, message.oid.high >>> 0).toNumber() : message.oid;\n                            if (message.mid != null && message.hasOwnProperty(\"mid\"))\n                                object.mid = message.mid;\n                            if (message.command != null && message.hasOwnProperty(\"command\"))\n                                object.command = message.command;\n                            if (message.content != null && message.hasOwnProperty(\"content\"))\n                                object.content = message.content;\n                            if (message.progress != null && message.hasOwnProperty(\"progress\"))\n                                object.progress = message.progress;\n                            if (message.ctime != null && message.hasOwnProperty(\"ctime\"))\n                                object.ctime = message.ctime;\n                            if (message.mtime != null && message.hasOwnProperty(\"mtime\"))\n                                object.mtime = message.mtime;\n                            if (message.extra != null && message.hasOwnProperty(\"extra\"))\n                                object.extra = message.extra;\n                            if (message.idStr != null && message.hasOwnProperty(\"idStr\"))\n                                object.idStr = message.idStr;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this CommandDm to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        CommandDm.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for CommandDm\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.CommandDm\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        CommandDm.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.CommandDm\";\n                        };\n\n                        return CommandDm;\n                    })();\n\n                    v1.DanmakuAIFlag = (function() {\n\n                        /**\n                         * Properties of a DanmakuAIFlag.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmakuAIFlag\n                         * @property {Array.<bilibili.community.service.dm.v1.IDanmakuFlag>|null} [dmFlags] DanmakuAIFlag dmFlags\n                         */\n\n                        /**\n                         * Constructs a new DanmakuAIFlag.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmakuAIFlag.\n                         * @implements IDanmakuAIFlag\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmakuAIFlag=} [properties] Properties to set\n                         */\n                        function DanmakuAIFlag(properties) {\n                            this.dmFlags = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmakuAIFlag dmFlags.\n                         * @member {Array.<bilibili.community.service.dm.v1.IDanmakuFlag>} dmFlags\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @instance\n                         */\n                        DanmakuAIFlag.prototype.dmFlags = $util.emptyArray;\n\n                        /**\n                         * Creates a new DanmakuAIFlag instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuAIFlag=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmakuAIFlag} DanmakuAIFlag instance\n                         */\n                        DanmakuAIFlag.create = function create(properties) {\n                            return new DanmakuAIFlag(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuAIFlag message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuAIFlag.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuAIFlag} message DanmakuAIFlag message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuAIFlag.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.dmFlags != null && message.dmFlags.length)\n                                for (var i = 0; i < message.dmFlags.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.DanmakuFlag.encode(message.dmFlags[i], writer.uint32(/* id 1, wireType 2 =*/10).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuAIFlag message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuAIFlag.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuAIFlag} message DanmakuAIFlag message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuAIFlag.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmakuAIFlag message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmakuAIFlag} DanmakuAIFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuAIFlag.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmakuAIFlag();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.dmFlags && message.dmFlags.length))\n                                            message.dmFlags = [];\n                                        message.dmFlags.push($root.bilibili.community.service.dm.v1.DanmakuFlag.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmakuAIFlag message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmakuAIFlag} DanmakuAIFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuAIFlag.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmakuAIFlag message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmakuAIFlag.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.dmFlags != null && message.hasOwnProperty(\"dmFlags\")) {\n                                if (!Array.isArray(message.dmFlags))\n                                    return \"dmFlags: array expected\";\n                                for (var i = 0; i < message.dmFlags.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.DanmakuFlag.verify(message.dmFlags[i]);\n                                    if (error)\n                                        return \"dmFlags.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmakuAIFlag message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmakuAIFlag} DanmakuAIFlag\n                         */\n                        DanmakuAIFlag.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmakuAIFlag)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmakuAIFlag();\n                            if (object.dmFlags) {\n                                if (!Array.isArray(object.dmFlags))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmakuAIFlag.dmFlags: array expected\");\n                                message.dmFlags = [];\n                                for (var i = 0; i < object.dmFlags.length; ++i) {\n                                    if (typeof object.dmFlags[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DanmakuAIFlag.dmFlags: object expected\");\n                                    message.dmFlags[i] = $root.bilibili.community.service.dm.v1.DanmakuFlag.fromObject(object.dmFlags[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmakuAIFlag message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmakuAIFlag} message DanmakuAIFlag\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmakuAIFlag.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.dmFlags = [];\n                            if (message.dmFlags && message.dmFlags.length) {\n                                object.dmFlags = [];\n                                for (var j = 0; j < message.dmFlags.length; ++j)\n                                    object.dmFlags[j] = $root.bilibili.community.service.dm.v1.DanmakuFlag.toObject(message.dmFlags[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmakuAIFlag to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmakuAIFlag.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmakuAIFlag\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmakuAIFlag\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmakuAIFlag.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmakuAIFlag\";\n                        };\n\n                        return DanmakuAIFlag;\n                    })();\n\n                    v1.DanmakuElem = (function() {\n\n                        /**\n                         * Properties of a DanmakuElem.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmakuElem\n                         * @property {number|Long|null} [id] DanmakuElem id\n                         * @property {number|null} [progress] DanmakuElem progress\n                         * @property {number|null} [mode] DanmakuElem mode\n                         * @property {number|null} [fontsize] DanmakuElem fontsize\n                         * @property {number|null} [color] DanmakuElem color\n                         * @property {string|null} [midHash] DanmakuElem midHash\n                         * @property {string|null} [content] DanmakuElem content\n                         * @property {number|Long|null} [ctime] DanmakuElem ctime\n                         * @property {number|null} [weight] DanmakuElem weight\n                         * @property {string|null} [action] DanmakuElem action\n                         * @property {number|null} [pool] DanmakuElem pool\n                         * @property {string|null} [idStr] DanmakuElem idStr\n                         * @property {number|null} [attr] DanmakuElem attr\n                         * @property {string|null} [animation] DanmakuElem animation\n                         * @property {bilibili.community.service.dm.v1.DmColorfulType|null} [colorful] DanmakuElem colorful\n                         */\n\n                        /**\n                         * Constructs a new DanmakuElem.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmakuElem.\n                         * @implements IDanmakuElem\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmakuElem=} [properties] Properties to set\n                         */\n                        function DanmakuElem(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmakuElem id.\n                         * @member {number|Long} id\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.id = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DanmakuElem progress.\n                         * @member {number} progress\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.progress = 0;\n\n                        /**\n                         * DanmakuElem mode.\n                         * @member {number} mode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.mode = 0;\n\n                        /**\n                         * DanmakuElem fontsize.\n                         * @member {number} fontsize\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.fontsize = 0;\n\n                        /**\n                         * DanmakuElem color.\n                         * @member {number} color\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.color = 0;\n\n                        /**\n                         * DanmakuElem midHash.\n                         * @member {string} midHash\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.midHash = \"\";\n\n                        /**\n                         * DanmakuElem content.\n                         * @member {string} content\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.content = \"\";\n\n                        /**\n                         * DanmakuElem ctime.\n                         * @member {number|Long} ctime\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.ctime = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DanmakuElem weight.\n                         * @member {number} weight\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.weight = 0;\n\n                        /**\n                         * DanmakuElem action.\n                         * @member {string} action\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.action = \"\";\n\n                        /**\n                         * DanmakuElem pool.\n                         * @member {number} pool\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.pool = 0;\n\n                        /**\n                         * DanmakuElem idStr.\n                         * @member {string} idStr\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.idStr = \"\";\n\n                        /**\n                         * DanmakuElem attr.\n                         * @member {number} attr\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.attr = 0;\n\n                        /**\n                         * DanmakuElem animation.\n                         * @member {string} animation\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.animation = \"\";\n\n                        /**\n                         * DanmakuElem colorful.\n                         * @member {bilibili.community.service.dm.v1.DmColorfulType} colorful\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         */\n                        DanmakuElem.prototype.colorful = 0;\n\n                        /**\n                         * Creates a new DanmakuElem instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuElem=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmakuElem} DanmakuElem instance\n                         */\n                        DanmakuElem.create = function create(properties) {\n                            return new DanmakuElem(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuElem message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuElem.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuElem} message DanmakuElem message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuElem.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.id != null && Object.hasOwnProperty.call(message, \"id\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.id);\n                            if (message.progress != null && Object.hasOwnProperty.call(message, \"progress\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.progress);\n                            if (message.mode != null && Object.hasOwnProperty.call(message, \"mode\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.mode);\n                            if (message.fontsize != null && Object.hasOwnProperty.call(message, \"fontsize\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int32(message.fontsize);\n                            if (message.color != null && Object.hasOwnProperty.call(message, \"color\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).uint32(message.color);\n                            if (message.midHash != null && Object.hasOwnProperty.call(message, \"midHash\"))\n                                writer.uint32(/* id 6, wireType 2 =*/50).string(message.midHash);\n                            if (message.content != null && Object.hasOwnProperty.call(message, \"content\"))\n                                writer.uint32(/* id 7, wireType 2 =*/58).string(message.content);\n                            if (message.ctime != null && Object.hasOwnProperty.call(message, \"ctime\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).int64(message.ctime);\n                            if (message.weight != null && Object.hasOwnProperty.call(message, \"weight\"))\n                                writer.uint32(/* id 9, wireType 0 =*/72).int32(message.weight);\n                            if (message.action != null && Object.hasOwnProperty.call(message, \"action\"))\n                                writer.uint32(/* id 10, wireType 2 =*/82).string(message.action);\n                            if (message.pool != null && Object.hasOwnProperty.call(message, \"pool\"))\n                                writer.uint32(/* id 11, wireType 0 =*/88).int32(message.pool);\n                            if (message.idStr != null && Object.hasOwnProperty.call(message, \"idStr\"))\n                                writer.uint32(/* id 12, wireType 2 =*/98).string(message.idStr);\n                            if (message.attr != null && Object.hasOwnProperty.call(message, \"attr\"))\n                                writer.uint32(/* id 13, wireType 0 =*/104).int32(message.attr);\n                            if (message.animation != null && Object.hasOwnProperty.call(message, \"animation\"))\n                                writer.uint32(/* id 22, wireType 2 =*/178).string(message.animation);\n                            if (message.colorful != null && Object.hasOwnProperty.call(message, \"colorful\"))\n                                writer.uint32(/* id 24, wireType 0 =*/192).int32(message.colorful);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuElem message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuElem.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuElem} message DanmakuElem message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuElem.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmakuElem message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmakuElem} DanmakuElem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuElem.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmakuElem();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.id = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.progress = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.mode = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.fontsize = reader.int32();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.color = reader.uint32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.midHash = reader.string();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.content = reader.string();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.ctime = reader.int64();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.weight = reader.int32();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.action = reader.string();\n                                        break;\n                                    }\n                                case 11: {\n                                        message.pool = reader.int32();\n                                        break;\n                                    }\n                                case 12: {\n                                        message.idStr = reader.string();\n                                        break;\n                                    }\n                                case 13: {\n                                        message.attr = reader.int32();\n                                        break;\n                                    }\n                                case 22: {\n                                        message.animation = reader.string();\n                                        break;\n                                    }\n                                case 24: {\n                                        message.colorful = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmakuElem message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmakuElem} DanmakuElem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuElem.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmakuElem message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmakuElem.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (!$util.isInteger(message.id) && !(message.id && $util.isInteger(message.id.low) && $util.isInteger(message.id.high)))\n                                    return \"id: integer|Long expected\";\n                            if (message.progress != null && message.hasOwnProperty(\"progress\"))\n                                if (!$util.isInteger(message.progress))\n                                    return \"progress: integer expected\";\n                            if (message.mode != null && message.hasOwnProperty(\"mode\"))\n                                if (!$util.isInteger(message.mode))\n                                    return \"mode: integer expected\";\n                            if (message.fontsize != null && message.hasOwnProperty(\"fontsize\"))\n                                if (!$util.isInteger(message.fontsize))\n                                    return \"fontsize: integer expected\";\n                            if (message.color != null && message.hasOwnProperty(\"color\"))\n                                if (!$util.isInteger(message.color))\n                                    return \"color: integer expected\";\n                            if (message.midHash != null && message.hasOwnProperty(\"midHash\"))\n                                if (!$util.isString(message.midHash))\n                                    return \"midHash: string expected\";\n                            if (message.content != null && message.hasOwnProperty(\"content\"))\n                                if (!$util.isString(message.content))\n                                    return \"content: string expected\";\n                            if (message.ctime != null && message.hasOwnProperty(\"ctime\"))\n                                if (!$util.isInteger(message.ctime) && !(message.ctime && $util.isInteger(message.ctime.low) && $util.isInteger(message.ctime.high)))\n                                    return \"ctime: integer|Long expected\";\n                            if (message.weight != null && message.hasOwnProperty(\"weight\"))\n                                if (!$util.isInteger(message.weight))\n                                    return \"weight: integer expected\";\n                            if (message.action != null && message.hasOwnProperty(\"action\"))\n                                if (!$util.isString(message.action))\n                                    return \"action: string expected\";\n                            if (message.pool != null && message.hasOwnProperty(\"pool\"))\n                                if (!$util.isInteger(message.pool))\n                                    return \"pool: integer expected\";\n                            if (message.idStr != null && message.hasOwnProperty(\"idStr\"))\n                                if (!$util.isString(message.idStr))\n                                    return \"idStr: string expected\";\n                            if (message.attr != null && message.hasOwnProperty(\"attr\"))\n                                if (!$util.isInteger(message.attr))\n                                    return \"attr: integer expected\";\n                            if (message.animation != null && message.hasOwnProperty(\"animation\"))\n                                if (!$util.isString(message.animation))\n                                    return \"animation: string expected\";\n                            if (message.colorful != null && message.hasOwnProperty(\"colorful\"))\n                                switch (message.colorful) {\n                                default:\n                                    return \"colorful: enum value expected\";\n                                case 0:\n                                case 60001:\n                                    break;\n                                }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmakuElem message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmakuElem} DanmakuElem\n                         */\n                        DanmakuElem.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmakuElem)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmakuElem();\n                            if (object.id != null)\n                                if ($util.Long)\n                                    (message.id = $util.Long.fromValue(object.id)).unsigned = false;\n                                else if (typeof object.id === \"string\")\n                                    message.id = parseInt(object.id, 10);\n                                else if (typeof object.id === \"number\")\n                                    message.id = object.id;\n                                else if (typeof object.id === \"object\")\n                                    message.id = new $util.LongBits(object.id.low >>> 0, object.id.high >>> 0).toNumber();\n                            if (object.progress != null)\n                                message.progress = object.progress | 0;\n                            if (object.mode != null)\n                                message.mode = object.mode | 0;\n                            if (object.fontsize != null)\n                                message.fontsize = object.fontsize | 0;\n                            if (object.color != null)\n                                message.color = object.color >>> 0;\n                            if (object.midHash != null)\n                                message.midHash = String(object.midHash);\n                            if (object.content != null)\n                                message.content = String(object.content);\n                            if (object.ctime != null)\n                                if ($util.Long)\n                                    (message.ctime = $util.Long.fromValue(object.ctime)).unsigned = false;\n                                else if (typeof object.ctime === \"string\")\n                                    message.ctime = parseInt(object.ctime, 10);\n                                else if (typeof object.ctime === \"number\")\n                                    message.ctime = object.ctime;\n                                else if (typeof object.ctime === \"object\")\n                                    message.ctime = new $util.LongBits(object.ctime.low >>> 0, object.ctime.high >>> 0).toNumber();\n                            if (object.weight != null)\n                                message.weight = object.weight | 0;\n                            if (object.action != null)\n                                message.action = String(object.action);\n                            if (object.pool != null)\n                                message.pool = object.pool | 0;\n                            if (object.idStr != null)\n                                message.idStr = String(object.idStr);\n                            if (object.attr != null)\n                                message.attr = object.attr | 0;\n                            if (object.animation != null)\n                                message.animation = String(object.animation);\n                            switch (object.colorful) {\n                            default:\n                                if (typeof object.colorful === \"number\") {\n                                    message.colorful = object.colorful;\n                                    break;\n                                }\n                                break;\n                            case \"NoneType\":\n                            case 0:\n                                message.colorful = 0;\n                                break;\n                            case \"VipGradualColor\":\n                            case 60001:\n                                message.colorful = 60001;\n                                break;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmakuElem message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmakuElem} message DanmakuElem\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmakuElem.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.id = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.id = options.longs === String ? \"0\" : 0;\n                                object.progress = 0;\n                                object.mode = 0;\n                                object.fontsize = 0;\n                                object.color = 0;\n                                object.midHash = \"\";\n                                object.content = \"\";\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.ctime = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.ctime = options.longs === String ? \"0\" : 0;\n                                object.weight = 0;\n                                object.action = \"\";\n                                object.pool = 0;\n                                object.idStr = \"\";\n                                object.attr = 0;\n                                object.animation = \"\";\n                                object.colorful = options.enums === String ? \"NoneType\" : 0;\n                            }\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (typeof message.id === \"number\")\n                                    object.id = options.longs === String ? String(message.id) : message.id;\n                                else\n                                    object.id = options.longs === String ? $util.Long.prototype.toString.call(message.id) : options.longs === Number ? new $util.LongBits(message.id.low >>> 0, message.id.high >>> 0).toNumber() : message.id;\n                            if (message.progress != null && message.hasOwnProperty(\"progress\"))\n                                object.progress = message.progress;\n                            if (message.mode != null && message.hasOwnProperty(\"mode\"))\n                                object.mode = message.mode;\n                            if (message.fontsize != null && message.hasOwnProperty(\"fontsize\"))\n                                object.fontsize = message.fontsize;\n                            if (message.color != null && message.hasOwnProperty(\"color\"))\n                                object.color = message.color;\n                            if (message.midHash != null && message.hasOwnProperty(\"midHash\"))\n                                object.midHash = message.midHash;\n                            if (message.content != null && message.hasOwnProperty(\"content\"))\n                                object.content = message.content;\n                            if (message.ctime != null && message.hasOwnProperty(\"ctime\"))\n                                if (typeof message.ctime === \"number\")\n                                    object.ctime = options.longs === String ? String(message.ctime) : message.ctime;\n                                else\n                                    object.ctime = options.longs === String ? $util.Long.prototype.toString.call(message.ctime) : options.longs === Number ? new $util.LongBits(message.ctime.low >>> 0, message.ctime.high >>> 0).toNumber() : message.ctime;\n                            if (message.weight != null && message.hasOwnProperty(\"weight\"))\n                                object.weight = message.weight;\n                            if (message.action != null && message.hasOwnProperty(\"action\"))\n                                object.action = message.action;\n                            if (message.pool != null && message.hasOwnProperty(\"pool\"))\n                                object.pool = message.pool;\n                            if (message.idStr != null && message.hasOwnProperty(\"idStr\"))\n                                object.idStr = message.idStr;\n                            if (message.attr != null && message.hasOwnProperty(\"attr\"))\n                                object.attr = message.attr;\n                            if (message.animation != null && message.hasOwnProperty(\"animation\"))\n                                object.animation = message.animation;\n                            if (message.colorful != null && message.hasOwnProperty(\"colorful\"))\n                                object.colorful = options.enums === String ? $root.bilibili.community.service.dm.v1.DmColorfulType[message.colorful] === undefined ? message.colorful : $root.bilibili.community.service.dm.v1.DmColorfulType[message.colorful] : message.colorful;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmakuElem to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmakuElem.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmakuElem\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmakuElem\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmakuElem.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmakuElem\";\n                        };\n\n                        return DanmakuElem;\n                    })();\n\n                    v1.DanmakuFlag = (function() {\n\n                        /**\n                         * Properties of a DanmakuFlag.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmakuFlag\n                         * @property {number|Long|null} [dmid] DanmakuFlag dmid\n                         * @property {number|null} [flag] DanmakuFlag flag\n                         */\n\n                        /**\n                         * Constructs a new DanmakuFlag.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmakuFlag.\n                         * @implements IDanmakuFlag\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlag=} [properties] Properties to set\n                         */\n                        function DanmakuFlag(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmakuFlag dmid.\n                         * @member {number|Long} dmid\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @instance\n                         */\n                        DanmakuFlag.prototype.dmid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DanmakuFlag flag.\n                         * @member {number} flag\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @instance\n                         */\n                        DanmakuFlag.prototype.flag = 0;\n\n                        /**\n                         * Creates a new DanmakuFlag instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlag=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlag} DanmakuFlag instance\n                         */\n                        DanmakuFlag.create = function create(properties) {\n                            return new DanmakuFlag(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuFlag message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlag.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlag} message DanmakuFlag message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuFlag.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.dmid != null && Object.hasOwnProperty.call(message, \"dmid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.dmid);\n                            if (message.flag != null && Object.hasOwnProperty.call(message, \"flag\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).uint32(message.flag);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuFlag message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlag.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlag} message DanmakuFlag message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuFlag.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmakuFlag message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlag} DanmakuFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuFlag.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmakuFlag();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.dmid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.flag = reader.uint32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmakuFlag message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlag} DanmakuFlag\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuFlag.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmakuFlag message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmakuFlag.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.dmid != null && message.hasOwnProperty(\"dmid\"))\n                                if (!$util.isInteger(message.dmid) && !(message.dmid && $util.isInteger(message.dmid.low) && $util.isInteger(message.dmid.high)))\n                                    return \"dmid: integer|Long expected\";\n                            if (message.flag != null && message.hasOwnProperty(\"flag\"))\n                                if (!$util.isInteger(message.flag))\n                                    return \"flag: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmakuFlag message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlag} DanmakuFlag\n                         */\n                        DanmakuFlag.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmakuFlag)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmakuFlag();\n                            if (object.dmid != null)\n                                if ($util.Long)\n                                    (message.dmid = $util.Long.fromValue(object.dmid)).unsigned = false;\n                                else if (typeof object.dmid === \"string\")\n                                    message.dmid = parseInt(object.dmid, 10);\n                                else if (typeof object.dmid === \"number\")\n                                    message.dmid = object.dmid;\n                                else if (typeof object.dmid === \"object\")\n                                    message.dmid = new $util.LongBits(object.dmid.low >>> 0, object.dmid.high >>> 0).toNumber();\n                            if (object.flag != null)\n                                message.flag = object.flag >>> 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmakuFlag message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmakuFlag} message DanmakuFlag\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmakuFlag.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.dmid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.dmid = options.longs === String ? \"0\" : 0;\n                                object.flag = 0;\n                            }\n                            if (message.dmid != null && message.hasOwnProperty(\"dmid\"))\n                                if (typeof message.dmid === \"number\")\n                                    object.dmid = options.longs === String ? String(message.dmid) : message.dmid;\n                                else\n                                    object.dmid = options.longs === String ? $util.Long.prototype.toString.call(message.dmid) : options.longs === Number ? new $util.LongBits(message.dmid.low >>> 0, message.dmid.high >>> 0).toNumber() : message.dmid;\n                            if (message.flag != null && message.hasOwnProperty(\"flag\"))\n                                object.flag = message.flag;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmakuFlag to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmakuFlag.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmakuFlag\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlag\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmakuFlag.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmakuFlag\";\n                        };\n\n                        return DanmakuFlag;\n                    })();\n\n                    v1.DanmakuFlagConfig = (function() {\n\n                        /**\n                         * Properties of a DanmakuFlagConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmakuFlagConfig\n                         * @property {number|null} [recFlag] DanmakuFlagConfig recFlag\n                         * @property {string|null} [recText] DanmakuFlagConfig recText\n                         * @property {number|null} [recSwitch] DanmakuFlagConfig recSwitch\n                         */\n\n                        /**\n                         * Constructs a new DanmakuFlagConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmakuFlagConfig.\n                         * @implements IDanmakuFlagConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlagConfig=} [properties] Properties to set\n                         */\n                        function DanmakuFlagConfig(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmakuFlagConfig recFlag.\n                         * @member {number} recFlag\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @instance\n                         */\n                        DanmakuFlagConfig.prototype.recFlag = 0;\n\n                        /**\n                         * DanmakuFlagConfig recText.\n                         * @member {string} recText\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @instance\n                         */\n                        DanmakuFlagConfig.prototype.recText = \"\";\n\n                        /**\n                         * DanmakuFlagConfig recSwitch.\n                         * @member {number} recSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @instance\n                         */\n                        DanmakuFlagConfig.prototype.recSwitch = 0;\n\n                        /**\n                         * Creates a new DanmakuFlagConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlagConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlagConfig} DanmakuFlagConfig instance\n                         */\n                        DanmakuFlagConfig.create = function create(properties) {\n                            return new DanmakuFlagConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuFlagConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlagConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlagConfig} message DanmakuFlagConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuFlagConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.recFlag != null && Object.hasOwnProperty.call(message, \"recFlag\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.recFlag);\n                            if (message.recText != null && Object.hasOwnProperty.call(message, \"recText\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.recText);\n                            if (message.recSwitch != null && Object.hasOwnProperty.call(message, \"recSwitch\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.recSwitch);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmakuFlagConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmakuFlagConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmakuFlagConfig} message DanmakuFlagConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmakuFlagConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmakuFlagConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlagConfig} DanmakuFlagConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuFlagConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmakuFlagConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.recFlag = reader.int32();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.recText = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.recSwitch = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmakuFlagConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlagConfig} DanmakuFlagConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmakuFlagConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmakuFlagConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmakuFlagConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.recFlag != null && message.hasOwnProperty(\"recFlag\"))\n                                if (!$util.isInteger(message.recFlag))\n                                    return \"recFlag: integer expected\";\n                            if (message.recText != null && message.hasOwnProperty(\"recText\"))\n                                if (!$util.isString(message.recText))\n                                    return \"recText: string expected\";\n                            if (message.recSwitch != null && message.hasOwnProperty(\"recSwitch\"))\n                                if (!$util.isInteger(message.recSwitch))\n                                    return \"recSwitch: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmakuFlagConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmakuFlagConfig} DanmakuFlagConfig\n                         */\n                        DanmakuFlagConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmakuFlagConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmakuFlagConfig();\n                            if (object.recFlag != null)\n                                message.recFlag = object.recFlag | 0;\n                            if (object.recText != null)\n                                message.recText = String(object.recText);\n                            if (object.recSwitch != null)\n                                message.recSwitch = object.recSwitch | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmakuFlagConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmakuFlagConfig} message DanmakuFlagConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmakuFlagConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.recFlag = 0;\n                                object.recText = \"\";\n                                object.recSwitch = 0;\n                            }\n                            if (message.recFlag != null && message.hasOwnProperty(\"recFlag\"))\n                                object.recFlag = message.recFlag;\n                            if (message.recText != null && message.hasOwnProperty(\"recText\"))\n                                object.recText = message.recText;\n                            if (message.recSwitch != null && message.hasOwnProperty(\"recSwitch\"))\n                                object.recSwitch = message.recSwitch;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmakuFlagConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmakuFlagConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmakuFlagConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmakuFlagConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmakuFlagConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmakuFlagConfig\";\n                        };\n\n                        return DanmakuFlagConfig;\n                    })();\n\n                    v1.DanmuDefaultPlayerConfig = (function() {\n\n                        /**\n                         * Properties of a DanmuDefaultPlayerConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmuDefaultPlayerConfig\n                         * @property {boolean|null} [playerDanmakuUseDefaultConfig] DanmuDefaultPlayerConfig playerDanmakuUseDefaultConfig\n                         * @property {boolean|null} [playerDanmakuAiRecommendedSwitch] DanmuDefaultPlayerConfig playerDanmakuAiRecommendedSwitch\n                         * @property {number|null} [playerDanmakuAiRecommendedLevel] DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevel\n                         * @property {boolean|null} [playerDanmakuBlocktop] DanmuDefaultPlayerConfig playerDanmakuBlocktop\n                         * @property {boolean|null} [playerDanmakuBlockscroll] DanmuDefaultPlayerConfig playerDanmakuBlockscroll\n                         * @property {boolean|null} [playerDanmakuBlockbottom] DanmuDefaultPlayerConfig playerDanmakuBlockbottom\n                         * @property {boolean|null} [playerDanmakuBlockcolorful] DanmuDefaultPlayerConfig playerDanmakuBlockcolorful\n                         * @property {boolean|null} [playerDanmakuBlockrepeat] DanmuDefaultPlayerConfig playerDanmakuBlockrepeat\n                         * @property {boolean|null} [playerDanmakuBlockspecial] DanmuDefaultPlayerConfig playerDanmakuBlockspecial\n                         * @property {number|null} [playerDanmakuOpacity] DanmuDefaultPlayerConfig playerDanmakuOpacity\n                         * @property {number|null} [playerDanmakuScalingfactor] DanmuDefaultPlayerConfig playerDanmakuScalingfactor\n                         * @property {number|null} [playerDanmakuDomain] DanmuDefaultPlayerConfig playerDanmakuDomain\n                         * @property {number|null} [playerDanmakuSpeed] DanmuDefaultPlayerConfig playerDanmakuSpeed\n                         * @property {boolean|null} [inlinePlayerDanmakuSwitch] DanmuDefaultPlayerConfig inlinePlayerDanmakuSwitch\n                         * @property {number|null} [playerDanmakuSeniorModeSwitch] DanmuDefaultPlayerConfig playerDanmakuSeniorModeSwitch\n                         * @property {number|null} [playerDanmakuAiRecommendedLevelV2] DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2\n                         * @property {Object.<string,number>|null} [playerDanmakuAiRecommendedLevelV2Map] DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2Map\n                         */\n\n                        /**\n                         * Constructs a new DanmuDefaultPlayerConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmuDefaultPlayerConfig.\n                         * @implements IDanmuDefaultPlayerConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig=} [properties] Properties to set\n                         */\n                        function DanmuDefaultPlayerConfig(properties) {\n                            this.playerDanmakuAiRecommendedLevelV2Map = {};\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuUseDefaultConfig.\n                         * @member {boolean} playerDanmakuUseDefaultConfig\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuUseDefaultConfig = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuAiRecommendedSwitch.\n                         * @member {boolean} playerDanmakuAiRecommendedSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuAiRecommendedSwitch = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevel.\n                         * @member {number} playerDanmakuAiRecommendedLevel\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuAiRecommendedLevel = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuBlocktop.\n                         * @member {boolean} playerDanmakuBlocktop\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuBlocktop = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuBlockscroll.\n                         * @member {boolean} playerDanmakuBlockscroll\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuBlockscroll = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuBlockbottom.\n                         * @member {boolean} playerDanmakuBlockbottom\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuBlockbottom = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuBlockcolorful.\n                         * @member {boolean} playerDanmakuBlockcolorful\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuBlockcolorful = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuBlockrepeat.\n                         * @member {boolean} playerDanmakuBlockrepeat\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuBlockrepeat = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuBlockspecial.\n                         * @member {boolean} playerDanmakuBlockspecial\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuBlockspecial = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuOpacity.\n                         * @member {number} playerDanmakuOpacity\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuOpacity = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuScalingfactor.\n                         * @member {number} playerDanmakuScalingfactor\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuScalingfactor = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuDomain.\n                         * @member {number} playerDanmakuDomain\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuDomain = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuSpeed.\n                         * @member {number} playerDanmakuSpeed\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuSpeed = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig inlinePlayerDanmakuSwitch.\n                         * @member {boolean} inlinePlayerDanmakuSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.inlinePlayerDanmakuSwitch = false;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuSeniorModeSwitch.\n                         * @member {number} playerDanmakuSeniorModeSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuSeniorModeSwitch = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2.\n                         * @member {number} playerDanmakuAiRecommendedLevelV2\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuAiRecommendedLevelV2 = 0;\n\n                        /**\n                         * DanmuDefaultPlayerConfig playerDanmakuAiRecommendedLevelV2Map.\n                         * @member {Object.<string,number>} playerDanmakuAiRecommendedLevelV2Map\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         */\n                        DanmuDefaultPlayerConfig.prototype.playerDanmakuAiRecommendedLevelV2Map = $util.emptyObject;\n\n                        /**\n                         * Creates a new DanmuDefaultPlayerConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig} DanmuDefaultPlayerConfig instance\n                         */\n                        DanmuDefaultPlayerConfig.create = function create(properties) {\n                            return new DanmuDefaultPlayerConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmuDefaultPlayerConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig} message DanmuDefaultPlayerConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuDefaultPlayerConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.playerDanmakuUseDefaultConfig != null && Object.hasOwnProperty.call(message, \"playerDanmakuUseDefaultConfig\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.playerDanmakuUseDefaultConfig);\n                            if (message.playerDanmakuAiRecommendedSwitch != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedSwitch\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.playerDanmakuAiRecommendedSwitch);\n                            if (message.playerDanmakuAiRecommendedLevel != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedLevel\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.playerDanmakuAiRecommendedLevel);\n                            if (message.playerDanmakuBlocktop != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlocktop\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).bool(message.playerDanmakuBlocktop);\n                            if (message.playerDanmakuBlockscroll != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockscroll\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).bool(message.playerDanmakuBlockscroll);\n                            if (message.playerDanmakuBlockbottom != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockbottom\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).bool(message.playerDanmakuBlockbottom);\n                            if (message.playerDanmakuBlockcolorful != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockcolorful\"))\n                                writer.uint32(/* id 9, wireType 0 =*/72).bool(message.playerDanmakuBlockcolorful);\n                            if (message.playerDanmakuBlockrepeat != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockrepeat\"))\n                                writer.uint32(/* id 10, wireType 0 =*/80).bool(message.playerDanmakuBlockrepeat);\n                            if (message.playerDanmakuBlockspecial != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockspecial\"))\n                                writer.uint32(/* id 11, wireType 0 =*/88).bool(message.playerDanmakuBlockspecial);\n                            if (message.playerDanmakuOpacity != null && Object.hasOwnProperty.call(message, \"playerDanmakuOpacity\"))\n                                writer.uint32(/* id 12, wireType 5 =*/101).float(message.playerDanmakuOpacity);\n                            if (message.playerDanmakuScalingfactor != null && Object.hasOwnProperty.call(message, \"playerDanmakuScalingfactor\"))\n                                writer.uint32(/* id 13, wireType 5 =*/109).float(message.playerDanmakuScalingfactor);\n                            if (message.playerDanmakuDomain != null && Object.hasOwnProperty.call(message, \"playerDanmakuDomain\"))\n                                writer.uint32(/* id 14, wireType 5 =*/117).float(message.playerDanmakuDomain);\n                            if (message.playerDanmakuSpeed != null && Object.hasOwnProperty.call(message, \"playerDanmakuSpeed\"))\n                                writer.uint32(/* id 15, wireType 0 =*/120).int32(message.playerDanmakuSpeed);\n                            if (message.inlinePlayerDanmakuSwitch != null && Object.hasOwnProperty.call(message, \"inlinePlayerDanmakuSwitch\"))\n                                writer.uint32(/* id 16, wireType 0 =*/128).bool(message.inlinePlayerDanmakuSwitch);\n                            if (message.playerDanmakuSeniorModeSwitch != null && Object.hasOwnProperty.call(message, \"playerDanmakuSeniorModeSwitch\"))\n                                writer.uint32(/* id 17, wireType 0 =*/136).int32(message.playerDanmakuSeniorModeSwitch);\n                            if (message.playerDanmakuAiRecommendedLevelV2 != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedLevelV2\"))\n                                writer.uint32(/* id 18, wireType 0 =*/144).int32(message.playerDanmakuAiRecommendedLevelV2);\n                            if (message.playerDanmakuAiRecommendedLevelV2Map != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedLevelV2Map\"))\n                                for (var keys = Object.keys(message.playerDanmakuAiRecommendedLevelV2Map), i = 0; i < keys.length; ++i)\n                                    writer.uint32(/* id 19, wireType 2 =*/154).fork().uint32(/* id 1, wireType 0 =*/8).int32(keys[i]).uint32(/* id 2, wireType 0 =*/16).int32(message.playerDanmakuAiRecommendedLevelV2Map[keys[i]]).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmuDefaultPlayerConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig} message DanmuDefaultPlayerConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuDefaultPlayerConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmuDefaultPlayerConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig} DanmuDefaultPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuDefaultPlayerConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig(), key, value;\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.playerDanmakuUseDefaultConfig = reader.bool();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.playerDanmakuAiRecommendedSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.playerDanmakuAiRecommendedLevel = reader.int32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.playerDanmakuBlocktop = reader.bool();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.playerDanmakuBlockscroll = reader.bool();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.playerDanmakuBlockbottom = reader.bool();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.playerDanmakuBlockcolorful = reader.bool();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.playerDanmakuBlockrepeat = reader.bool();\n                                        break;\n                                    }\n                                case 11: {\n                                        message.playerDanmakuBlockspecial = reader.bool();\n                                        break;\n                                    }\n                                case 12: {\n                                        message.playerDanmakuOpacity = reader.float();\n                                        break;\n                                    }\n                                case 13: {\n                                        message.playerDanmakuScalingfactor = reader.float();\n                                        break;\n                                    }\n                                case 14: {\n                                        message.playerDanmakuDomain = reader.float();\n                                        break;\n                                    }\n                                case 15: {\n                                        message.playerDanmakuSpeed = reader.int32();\n                                        break;\n                                    }\n                                case 16: {\n                                        message.inlinePlayerDanmakuSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 17: {\n                                        message.playerDanmakuSeniorModeSwitch = reader.int32();\n                                        break;\n                                    }\n                                case 18: {\n                                        message.playerDanmakuAiRecommendedLevelV2 = reader.int32();\n                                        break;\n                                    }\n                                case 19: {\n                                        if (message.playerDanmakuAiRecommendedLevelV2Map === $util.emptyObject)\n                                            message.playerDanmakuAiRecommendedLevelV2Map = {};\n                                        var end2 = reader.uint32() + reader.pos;\n                                        key = 0;\n                                        value = 0;\n                                        while (reader.pos < end2) {\n                                            var tag2 = reader.uint32();\n                                            switch (tag2 >>> 3) {\n                                            case 1:\n                                                key = reader.int32();\n                                                break;\n                                            case 2:\n                                                value = reader.int32();\n                                                break;\n                                            default:\n                                                reader.skipType(tag2 & 7);\n                                                break;\n                                            }\n                                        }\n                                        message.playerDanmakuAiRecommendedLevelV2Map[key] = value;\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmuDefaultPlayerConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig} DanmuDefaultPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuDefaultPlayerConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmuDefaultPlayerConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmuDefaultPlayerConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.playerDanmakuUseDefaultConfig != null && message.hasOwnProperty(\"playerDanmakuUseDefaultConfig\"))\n                                if (typeof message.playerDanmakuUseDefaultConfig !== \"boolean\")\n                                    return \"playerDanmakuUseDefaultConfig: boolean expected\";\n                            if (message.playerDanmakuAiRecommendedSwitch != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedSwitch\"))\n                                if (typeof message.playerDanmakuAiRecommendedSwitch !== \"boolean\")\n                                    return \"playerDanmakuAiRecommendedSwitch: boolean expected\";\n                            if (message.playerDanmakuAiRecommendedLevel != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevel\"))\n                                if (!$util.isInteger(message.playerDanmakuAiRecommendedLevel))\n                                    return \"playerDanmakuAiRecommendedLevel: integer expected\";\n                            if (message.playerDanmakuBlocktop != null && message.hasOwnProperty(\"playerDanmakuBlocktop\"))\n                                if (typeof message.playerDanmakuBlocktop !== \"boolean\")\n                                    return \"playerDanmakuBlocktop: boolean expected\";\n                            if (message.playerDanmakuBlockscroll != null && message.hasOwnProperty(\"playerDanmakuBlockscroll\"))\n                                if (typeof message.playerDanmakuBlockscroll !== \"boolean\")\n                                    return \"playerDanmakuBlockscroll: boolean expected\";\n                            if (message.playerDanmakuBlockbottom != null && message.hasOwnProperty(\"playerDanmakuBlockbottom\"))\n                                if (typeof message.playerDanmakuBlockbottom !== \"boolean\")\n                                    return \"playerDanmakuBlockbottom: boolean expected\";\n                            if (message.playerDanmakuBlockcolorful != null && message.hasOwnProperty(\"playerDanmakuBlockcolorful\"))\n                                if (typeof message.playerDanmakuBlockcolorful !== \"boolean\")\n                                    return \"playerDanmakuBlockcolorful: boolean expected\";\n                            if (message.playerDanmakuBlockrepeat != null && message.hasOwnProperty(\"playerDanmakuBlockrepeat\"))\n                                if (typeof message.playerDanmakuBlockrepeat !== \"boolean\")\n                                    return \"playerDanmakuBlockrepeat: boolean expected\";\n                            if (message.playerDanmakuBlockspecial != null && message.hasOwnProperty(\"playerDanmakuBlockspecial\"))\n                                if (typeof message.playerDanmakuBlockspecial !== \"boolean\")\n                                    return \"playerDanmakuBlockspecial: boolean expected\";\n                            if (message.playerDanmakuOpacity != null && message.hasOwnProperty(\"playerDanmakuOpacity\"))\n                                if (typeof message.playerDanmakuOpacity !== \"number\")\n                                    return \"playerDanmakuOpacity: number expected\";\n                            if (message.playerDanmakuScalingfactor != null && message.hasOwnProperty(\"playerDanmakuScalingfactor\"))\n                                if (typeof message.playerDanmakuScalingfactor !== \"number\")\n                                    return \"playerDanmakuScalingfactor: number expected\";\n                            if (message.playerDanmakuDomain != null && message.hasOwnProperty(\"playerDanmakuDomain\"))\n                                if (typeof message.playerDanmakuDomain !== \"number\")\n                                    return \"playerDanmakuDomain: number expected\";\n                            if (message.playerDanmakuSpeed != null && message.hasOwnProperty(\"playerDanmakuSpeed\"))\n                                if (!$util.isInteger(message.playerDanmakuSpeed))\n                                    return \"playerDanmakuSpeed: integer expected\";\n                            if (message.inlinePlayerDanmakuSwitch != null && message.hasOwnProperty(\"inlinePlayerDanmakuSwitch\"))\n                                if (typeof message.inlinePlayerDanmakuSwitch !== \"boolean\")\n                                    return \"inlinePlayerDanmakuSwitch: boolean expected\";\n                            if (message.playerDanmakuSeniorModeSwitch != null && message.hasOwnProperty(\"playerDanmakuSeniorModeSwitch\"))\n                                if (!$util.isInteger(message.playerDanmakuSeniorModeSwitch))\n                                    return \"playerDanmakuSeniorModeSwitch: integer expected\";\n                            if (message.playerDanmakuAiRecommendedLevelV2 != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevelV2\"))\n                                if (!$util.isInteger(message.playerDanmakuAiRecommendedLevelV2))\n                                    return \"playerDanmakuAiRecommendedLevelV2: integer expected\";\n                            if (message.playerDanmakuAiRecommendedLevelV2Map != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevelV2Map\")) {\n                                if (!$util.isObject(message.playerDanmakuAiRecommendedLevelV2Map))\n                                    return \"playerDanmakuAiRecommendedLevelV2Map: object expected\";\n                                var key = Object.keys(message.playerDanmakuAiRecommendedLevelV2Map);\n                                for (var i = 0; i < key.length; ++i) {\n                                    if (!$util.key32Re.test(key[i]))\n                                        return \"playerDanmakuAiRecommendedLevelV2Map: integer key{k:int32} expected\";\n                                    if (!$util.isInteger(message.playerDanmakuAiRecommendedLevelV2Map[key[i]]))\n                                        return \"playerDanmakuAiRecommendedLevelV2Map: integer{k:int32} expected\";\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmuDefaultPlayerConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig} DanmuDefaultPlayerConfig\n                         */\n                        DanmuDefaultPlayerConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig();\n                            if (object.playerDanmakuUseDefaultConfig != null)\n                                message.playerDanmakuUseDefaultConfig = Boolean(object.playerDanmakuUseDefaultConfig);\n                            if (object.playerDanmakuAiRecommendedSwitch != null)\n                                message.playerDanmakuAiRecommendedSwitch = Boolean(object.playerDanmakuAiRecommendedSwitch);\n                            if (object.playerDanmakuAiRecommendedLevel != null)\n                                message.playerDanmakuAiRecommendedLevel = object.playerDanmakuAiRecommendedLevel | 0;\n                            if (object.playerDanmakuBlocktop != null)\n                                message.playerDanmakuBlocktop = Boolean(object.playerDanmakuBlocktop);\n                            if (object.playerDanmakuBlockscroll != null)\n                                message.playerDanmakuBlockscroll = Boolean(object.playerDanmakuBlockscroll);\n                            if (object.playerDanmakuBlockbottom != null)\n                                message.playerDanmakuBlockbottom = Boolean(object.playerDanmakuBlockbottom);\n                            if (object.playerDanmakuBlockcolorful != null)\n                                message.playerDanmakuBlockcolorful = Boolean(object.playerDanmakuBlockcolorful);\n                            if (object.playerDanmakuBlockrepeat != null)\n                                message.playerDanmakuBlockrepeat = Boolean(object.playerDanmakuBlockrepeat);\n                            if (object.playerDanmakuBlockspecial != null)\n                                message.playerDanmakuBlockspecial = Boolean(object.playerDanmakuBlockspecial);\n                            if (object.playerDanmakuOpacity != null)\n                                message.playerDanmakuOpacity = Number(object.playerDanmakuOpacity);\n                            if (object.playerDanmakuScalingfactor != null)\n                                message.playerDanmakuScalingfactor = Number(object.playerDanmakuScalingfactor);\n                            if (object.playerDanmakuDomain != null)\n                                message.playerDanmakuDomain = Number(object.playerDanmakuDomain);\n                            if (object.playerDanmakuSpeed != null)\n                                message.playerDanmakuSpeed = object.playerDanmakuSpeed | 0;\n                            if (object.inlinePlayerDanmakuSwitch != null)\n                                message.inlinePlayerDanmakuSwitch = Boolean(object.inlinePlayerDanmakuSwitch);\n                            if (object.playerDanmakuSeniorModeSwitch != null)\n                                message.playerDanmakuSeniorModeSwitch = object.playerDanmakuSeniorModeSwitch | 0;\n                            if (object.playerDanmakuAiRecommendedLevelV2 != null)\n                                message.playerDanmakuAiRecommendedLevelV2 = object.playerDanmakuAiRecommendedLevelV2 | 0;\n                            if (object.playerDanmakuAiRecommendedLevelV2Map) {\n                                if (typeof object.playerDanmakuAiRecommendedLevelV2Map !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.playerDanmakuAiRecommendedLevelV2Map: object expected\");\n                                message.playerDanmakuAiRecommendedLevelV2Map = {};\n                                for (var keys = Object.keys(object.playerDanmakuAiRecommendedLevelV2Map), i = 0; i < keys.length; ++i)\n                                    message.playerDanmakuAiRecommendedLevelV2Map[keys[i]] = object.playerDanmakuAiRecommendedLevelV2Map[keys[i]] | 0;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmuDefaultPlayerConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig} message DanmuDefaultPlayerConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmuDefaultPlayerConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.objects || options.defaults)\n                                object.playerDanmakuAiRecommendedLevelV2Map = {};\n                            if (options.defaults) {\n                                object.playerDanmakuUseDefaultConfig = false;\n                                object.playerDanmakuAiRecommendedSwitch = false;\n                                object.playerDanmakuAiRecommendedLevel = 0;\n                                object.playerDanmakuBlocktop = false;\n                                object.playerDanmakuBlockscroll = false;\n                                object.playerDanmakuBlockbottom = false;\n                                object.playerDanmakuBlockcolorful = false;\n                                object.playerDanmakuBlockrepeat = false;\n                                object.playerDanmakuBlockspecial = false;\n                                object.playerDanmakuOpacity = 0;\n                                object.playerDanmakuScalingfactor = 0;\n                                object.playerDanmakuDomain = 0;\n                                object.playerDanmakuSpeed = 0;\n                                object.inlinePlayerDanmakuSwitch = false;\n                                object.playerDanmakuSeniorModeSwitch = 0;\n                                object.playerDanmakuAiRecommendedLevelV2 = 0;\n                            }\n                            if (message.playerDanmakuUseDefaultConfig != null && message.hasOwnProperty(\"playerDanmakuUseDefaultConfig\"))\n                                object.playerDanmakuUseDefaultConfig = message.playerDanmakuUseDefaultConfig;\n                            if (message.playerDanmakuAiRecommendedSwitch != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedSwitch\"))\n                                object.playerDanmakuAiRecommendedSwitch = message.playerDanmakuAiRecommendedSwitch;\n                            if (message.playerDanmakuAiRecommendedLevel != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevel\"))\n                                object.playerDanmakuAiRecommendedLevel = message.playerDanmakuAiRecommendedLevel;\n                            if (message.playerDanmakuBlocktop != null && message.hasOwnProperty(\"playerDanmakuBlocktop\"))\n                                object.playerDanmakuBlocktop = message.playerDanmakuBlocktop;\n                            if (message.playerDanmakuBlockscroll != null && message.hasOwnProperty(\"playerDanmakuBlockscroll\"))\n                                object.playerDanmakuBlockscroll = message.playerDanmakuBlockscroll;\n                            if (message.playerDanmakuBlockbottom != null && message.hasOwnProperty(\"playerDanmakuBlockbottom\"))\n                                object.playerDanmakuBlockbottom = message.playerDanmakuBlockbottom;\n                            if (message.playerDanmakuBlockcolorful != null && message.hasOwnProperty(\"playerDanmakuBlockcolorful\"))\n                                object.playerDanmakuBlockcolorful = message.playerDanmakuBlockcolorful;\n                            if (message.playerDanmakuBlockrepeat != null && message.hasOwnProperty(\"playerDanmakuBlockrepeat\"))\n                                object.playerDanmakuBlockrepeat = message.playerDanmakuBlockrepeat;\n                            if (message.playerDanmakuBlockspecial != null && message.hasOwnProperty(\"playerDanmakuBlockspecial\"))\n                                object.playerDanmakuBlockspecial = message.playerDanmakuBlockspecial;\n                            if (message.playerDanmakuOpacity != null && message.hasOwnProperty(\"playerDanmakuOpacity\"))\n                                object.playerDanmakuOpacity = options.json && !isFinite(message.playerDanmakuOpacity) ? String(message.playerDanmakuOpacity) : message.playerDanmakuOpacity;\n                            if (message.playerDanmakuScalingfactor != null && message.hasOwnProperty(\"playerDanmakuScalingfactor\"))\n                                object.playerDanmakuScalingfactor = options.json && !isFinite(message.playerDanmakuScalingfactor) ? String(message.playerDanmakuScalingfactor) : message.playerDanmakuScalingfactor;\n                            if (message.playerDanmakuDomain != null && message.hasOwnProperty(\"playerDanmakuDomain\"))\n                                object.playerDanmakuDomain = options.json && !isFinite(message.playerDanmakuDomain) ? String(message.playerDanmakuDomain) : message.playerDanmakuDomain;\n                            if (message.playerDanmakuSpeed != null && message.hasOwnProperty(\"playerDanmakuSpeed\"))\n                                object.playerDanmakuSpeed = message.playerDanmakuSpeed;\n                            if (message.inlinePlayerDanmakuSwitch != null && message.hasOwnProperty(\"inlinePlayerDanmakuSwitch\"))\n                                object.inlinePlayerDanmakuSwitch = message.inlinePlayerDanmakuSwitch;\n                            if (message.playerDanmakuSeniorModeSwitch != null && message.hasOwnProperty(\"playerDanmakuSeniorModeSwitch\"))\n                                object.playerDanmakuSeniorModeSwitch = message.playerDanmakuSeniorModeSwitch;\n                            if (message.playerDanmakuAiRecommendedLevelV2 != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevelV2\"))\n                                object.playerDanmakuAiRecommendedLevelV2 = message.playerDanmakuAiRecommendedLevelV2;\n                            var keys2;\n                            if (message.playerDanmakuAiRecommendedLevelV2Map && (keys2 = Object.keys(message.playerDanmakuAiRecommendedLevelV2Map)).length) {\n                                object.playerDanmakuAiRecommendedLevelV2Map = {};\n                                for (var j = 0; j < keys2.length; ++j)\n                                    object.playerDanmakuAiRecommendedLevelV2Map[keys2[j]] = message.playerDanmakuAiRecommendedLevelV2Map[keys2[j]];\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmuDefaultPlayerConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmuDefaultPlayerConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmuDefaultPlayerConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmuDefaultPlayerConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig\";\n                        };\n\n                        return DanmuDefaultPlayerConfig;\n                    })();\n\n                    v1.DanmuPlayerConfig = (function() {\n\n                        /**\n                         * Properties of a DanmuPlayerConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmuPlayerConfig\n                         * @property {boolean|null} [playerDanmakuSwitch] DanmuPlayerConfig playerDanmakuSwitch\n                         * @property {boolean|null} [playerDanmakuSwitchSave] DanmuPlayerConfig playerDanmakuSwitchSave\n                         * @property {boolean|null} [playerDanmakuUseDefaultConfig] DanmuPlayerConfig playerDanmakuUseDefaultConfig\n                         * @property {boolean|null} [playerDanmakuAiRecommendedSwitch] DanmuPlayerConfig playerDanmakuAiRecommendedSwitch\n                         * @property {number|null} [playerDanmakuAiRecommendedLevel] DanmuPlayerConfig playerDanmakuAiRecommendedLevel\n                         * @property {boolean|null} [playerDanmakuBlocktop] DanmuPlayerConfig playerDanmakuBlocktop\n                         * @property {boolean|null} [playerDanmakuBlockscroll] DanmuPlayerConfig playerDanmakuBlockscroll\n                         * @property {boolean|null} [playerDanmakuBlockbottom] DanmuPlayerConfig playerDanmakuBlockbottom\n                         * @property {boolean|null} [playerDanmakuBlockcolorful] DanmuPlayerConfig playerDanmakuBlockcolorful\n                         * @property {boolean|null} [playerDanmakuBlockrepeat] DanmuPlayerConfig playerDanmakuBlockrepeat\n                         * @property {boolean|null} [playerDanmakuBlockspecial] DanmuPlayerConfig playerDanmakuBlockspecial\n                         * @property {number|null} [playerDanmakuOpacity] DanmuPlayerConfig playerDanmakuOpacity\n                         * @property {number|null} [playerDanmakuScalingfactor] DanmuPlayerConfig playerDanmakuScalingfactor\n                         * @property {number|null} [playerDanmakuDomain] DanmuPlayerConfig playerDanmakuDomain\n                         * @property {number|null} [playerDanmakuSpeed] DanmuPlayerConfig playerDanmakuSpeed\n                         * @property {boolean|null} [playerDanmakuEnableblocklist] DanmuPlayerConfig playerDanmakuEnableblocklist\n                         * @property {boolean|null} [inlinePlayerDanmakuSwitch] DanmuPlayerConfig inlinePlayerDanmakuSwitch\n                         * @property {number|null} [inlinePlayerDanmakuConfig] DanmuPlayerConfig inlinePlayerDanmakuConfig\n                         * @property {number|null} [playerDanmakuIosSwitchSave] DanmuPlayerConfig playerDanmakuIosSwitchSave\n                         * @property {number|null} [playerDanmakuSeniorModeSwitch] DanmuPlayerConfig playerDanmakuSeniorModeSwitch\n                         * @property {number|null} [playerDanmakuAiRecommendedLevelV2] DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2\n                         * @property {Object.<string,number>|null} [playerDanmakuAiRecommendedLevelV2Map] DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2Map\n                         */\n\n                        /**\n                         * Constructs a new DanmuPlayerConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmuPlayerConfig.\n                         * @implements IDanmuPlayerConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfig=} [properties] Properties to set\n                         */\n                        function DanmuPlayerConfig(properties) {\n                            this.playerDanmakuAiRecommendedLevelV2Map = {};\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuSwitch.\n                         * @member {boolean} playerDanmakuSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuSwitch = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuSwitchSave.\n                         * @member {boolean} playerDanmakuSwitchSave\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuSwitchSave = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuUseDefaultConfig.\n                         * @member {boolean} playerDanmakuUseDefaultConfig\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuUseDefaultConfig = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuAiRecommendedSwitch.\n                         * @member {boolean} playerDanmakuAiRecommendedSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuAiRecommendedSwitch = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuAiRecommendedLevel.\n                         * @member {number} playerDanmakuAiRecommendedLevel\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuAiRecommendedLevel = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuBlocktop.\n                         * @member {boolean} playerDanmakuBlocktop\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuBlocktop = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuBlockscroll.\n                         * @member {boolean} playerDanmakuBlockscroll\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuBlockscroll = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuBlockbottom.\n                         * @member {boolean} playerDanmakuBlockbottom\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuBlockbottom = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuBlockcolorful.\n                         * @member {boolean} playerDanmakuBlockcolorful\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuBlockcolorful = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuBlockrepeat.\n                         * @member {boolean} playerDanmakuBlockrepeat\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuBlockrepeat = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuBlockspecial.\n                         * @member {boolean} playerDanmakuBlockspecial\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuBlockspecial = false;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuOpacity.\n                         * @member {number} playerDanmakuOpacity\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuOpacity = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuScalingfactor.\n                         * @member {number} playerDanmakuScalingfactor\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuScalingfactor = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuDomain.\n                         * @member {number} playerDanmakuDomain\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuDomain = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuSpeed.\n                         * @member {number} playerDanmakuSpeed\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuSpeed = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuEnableblocklist.\n                         * @member {boolean} playerDanmakuEnableblocklist\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuEnableblocklist = false;\n\n                        /**\n                         * DanmuPlayerConfig inlinePlayerDanmakuSwitch.\n                         * @member {boolean} inlinePlayerDanmakuSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.inlinePlayerDanmakuSwitch = false;\n\n                        /**\n                         * DanmuPlayerConfig inlinePlayerDanmakuConfig.\n                         * @member {number} inlinePlayerDanmakuConfig\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.inlinePlayerDanmakuConfig = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuIosSwitchSave.\n                         * @member {number} playerDanmakuIosSwitchSave\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuIosSwitchSave = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuSeniorModeSwitch.\n                         * @member {number} playerDanmakuSeniorModeSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuSeniorModeSwitch = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2.\n                         * @member {number} playerDanmakuAiRecommendedLevelV2\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuAiRecommendedLevelV2 = 0;\n\n                        /**\n                         * DanmuPlayerConfig playerDanmakuAiRecommendedLevelV2Map.\n                         * @member {Object.<string,number>} playerDanmakuAiRecommendedLevelV2Map\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         */\n                        DanmuPlayerConfig.prototype.playerDanmakuAiRecommendedLevelV2Map = $util.emptyObject;\n\n                        /**\n                         * Creates a new DanmuPlayerConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfig} DanmuPlayerConfig instance\n                         */\n                        DanmuPlayerConfig.create = function create(properties) {\n                            return new DanmuPlayerConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfig} message DanmuPlayerConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.playerDanmakuSwitch != null && Object.hasOwnProperty.call(message, \"playerDanmakuSwitch\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.playerDanmakuSwitch);\n                            if (message.playerDanmakuSwitchSave != null && Object.hasOwnProperty.call(message, \"playerDanmakuSwitchSave\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).bool(message.playerDanmakuSwitchSave);\n                            if (message.playerDanmakuUseDefaultConfig != null && Object.hasOwnProperty.call(message, \"playerDanmakuUseDefaultConfig\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).bool(message.playerDanmakuUseDefaultConfig);\n                            if (message.playerDanmakuAiRecommendedSwitch != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedSwitch\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.playerDanmakuAiRecommendedSwitch);\n                            if (message.playerDanmakuAiRecommendedLevel != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedLevel\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.playerDanmakuAiRecommendedLevel);\n                            if (message.playerDanmakuBlocktop != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlocktop\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).bool(message.playerDanmakuBlocktop);\n                            if (message.playerDanmakuBlockscroll != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockscroll\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).bool(message.playerDanmakuBlockscroll);\n                            if (message.playerDanmakuBlockbottom != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockbottom\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).bool(message.playerDanmakuBlockbottom);\n                            if (message.playerDanmakuBlockcolorful != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockcolorful\"))\n                                writer.uint32(/* id 9, wireType 0 =*/72).bool(message.playerDanmakuBlockcolorful);\n                            if (message.playerDanmakuBlockrepeat != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockrepeat\"))\n                                writer.uint32(/* id 10, wireType 0 =*/80).bool(message.playerDanmakuBlockrepeat);\n                            if (message.playerDanmakuBlockspecial != null && Object.hasOwnProperty.call(message, \"playerDanmakuBlockspecial\"))\n                                writer.uint32(/* id 11, wireType 0 =*/88).bool(message.playerDanmakuBlockspecial);\n                            if (message.playerDanmakuOpacity != null && Object.hasOwnProperty.call(message, \"playerDanmakuOpacity\"))\n                                writer.uint32(/* id 12, wireType 5 =*/101).float(message.playerDanmakuOpacity);\n                            if (message.playerDanmakuScalingfactor != null && Object.hasOwnProperty.call(message, \"playerDanmakuScalingfactor\"))\n                                writer.uint32(/* id 13, wireType 5 =*/109).float(message.playerDanmakuScalingfactor);\n                            if (message.playerDanmakuDomain != null && Object.hasOwnProperty.call(message, \"playerDanmakuDomain\"))\n                                writer.uint32(/* id 14, wireType 5 =*/117).float(message.playerDanmakuDomain);\n                            if (message.playerDanmakuSpeed != null && Object.hasOwnProperty.call(message, \"playerDanmakuSpeed\"))\n                                writer.uint32(/* id 15, wireType 0 =*/120).int32(message.playerDanmakuSpeed);\n                            if (message.playerDanmakuEnableblocklist != null && Object.hasOwnProperty.call(message, \"playerDanmakuEnableblocklist\"))\n                                writer.uint32(/* id 16, wireType 0 =*/128).bool(message.playerDanmakuEnableblocklist);\n                            if (message.inlinePlayerDanmakuSwitch != null && Object.hasOwnProperty.call(message, \"inlinePlayerDanmakuSwitch\"))\n                                writer.uint32(/* id 17, wireType 0 =*/136).bool(message.inlinePlayerDanmakuSwitch);\n                            if (message.inlinePlayerDanmakuConfig != null && Object.hasOwnProperty.call(message, \"inlinePlayerDanmakuConfig\"))\n                                writer.uint32(/* id 18, wireType 0 =*/144).int32(message.inlinePlayerDanmakuConfig);\n                            if (message.playerDanmakuIosSwitchSave != null && Object.hasOwnProperty.call(message, \"playerDanmakuIosSwitchSave\"))\n                                writer.uint32(/* id 19, wireType 0 =*/152).int32(message.playerDanmakuIosSwitchSave);\n                            if (message.playerDanmakuSeniorModeSwitch != null && Object.hasOwnProperty.call(message, \"playerDanmakuSeniorModeSwitch\"))\n                                writer.uint32(/* id 20, wireType 0 =*/160).int32(message.playerDanmakuSeniorModeSwitch);\n                            if (message.playerDanmakuAiRecommendedLevelV2 != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedLevelV2\"))\n                                writer.uint32(/* id 21, wireType 0 =*/168).int32(message.playerDanmakuAiRecommendedLevelV2);\n                            if (message.playerDanmakuAiRecommendedLevelV2Map != null && Object.hasOwnProperty.call(message, \"playerDanmakuAiRecommendedLevelV2Map\"))\n                                for (var keys = Object.keys(message.playerDanmakuAiRecommendedLevelV2Map), i = 0; i < keys.length; ++i)\n                                    writer.uint32(/* id 22, wireType 2 =*/178).fork().uint32(/* id 1, wireType 0 =*/8).int32(keys[i]).uint32(/* id 2, wireType 0 =*/16).int32(message.playerDanmakuAiRecommendedLevelV2Map[keys[i]]).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfig} message DanmuPlayerConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfig} DanmuPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmuPlayerConfig(), key, value;\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.playerDanmakuSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.playerDanmakuSwitchSave = reader.bool();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.playerDanmakuUseDefaultConfig = reader.bool();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.playerDanmakuAiRecommendedSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.playerDanmakuAiRecommendedLevel = reader.int32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.playerDanmakuBlocktop = reader.bool();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.playerDanmakuBlockscroll = reader.bool();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.playerDanmakuBlockbottom = reader.bool();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.playerDanmakuBlockcolorful = reader.bool();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.playerDanmakuBlockrepeat = reader.bool();\n                                        break;\n                                    }\n                                case 11: {\n                                        message.playerDanmakuBlockspecial = reader.bool();\n                                        break;\n                                    }\n                                case 12: {\n                                        message.playerDanmakuOpacity = reader.float();\n                                        break;\n                                    }\n                                case 13: {\n                                        message.playerDanmakuScalingfactor = reader.float();\n                                        break;\n                                    }\n                                case 14: {\n                                        message.playerDanmakuDomain = reader.float();\n                                        break;\n                                    }\n                                case 15: {\n                                        message.playerDanmakuSpeed = reader.int32();\n                                        break;\n                                    }\n                                case 16: {\n                                        message.playerDanmakuEnableblocklist = reader.bool();\n                                        break;\n                                    }\n                                case 17: {\n                                        message.inlinePlayerDanmakuSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 18: {\n                                        message.inlinePlayerDanmakuConfig = reader.int32();\n                                        break;\n                                    }\n                                case 19: {\n                                        message.playerDanmakuIosSwitchSave = reader.int32();\n                                        break;\n                                    }\n                                case 20: {\n                                        message.playerDanmakuSeniorModeSwitch = reader.int32();\n                                        break;\n                                    }\n                                case 21: {\n                                        message.playerDanmakuAiRecommendedLevelV2 = reader.int32();\n                                        break;\n                                    }\n                                case 22: {\n                                        if (message.playerDanmakuAiRecommendedLevelV2Map === $util.emptyObject)\n                                            message.playerDanmakuAiRecommendedLevelV2Map = {};\n                                        var end2 = reader.uint32() + reader.pos;\n                                        key = 0;\n                                        value = 0;\n                                        while (reader.pos < end2) {\n                                            var tag2 = reader.uint32();\n                                            switch (tag2 >>> 3) {\n                                            case 1:\n                                                key = reader.int32();\n                                                break;\n                                            case 2:\n                                                value = reader.int32();\n                                                break;\n                                            default:\n                                                reader.skipType(tag2 & 7);\n                                                break;\n                                            }\n                                        }\n                                        message.playerDanmakuAiRecommendedLevelV2Map[key] = value;\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfig} DanmuPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmuPlayerConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmuPlayerConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.playerDanmakuSwitch != null && message.hasOwnProperty(\"playerDanmakuSwitch\"))\n                                if (typeof message.playerDanmakuSwitch !== \"boolean\")\n                                    return \"playerDanmakuSwitch: boolean expected\";\n                            if (message.playerDanmakuSwitchSave != null && message.hasOwnProperty(\"playerDanmakuSwitchSave\"))\n                                if (typeof message.playerDanmakuSwitchSave !== \"boolean\")\n                                    return \"playerDanmakuSwitchSave: boolean expected\";\n                            if (message.playerDanmakuUseDefaultConfig != null && message.hasOwnProperty(\"playerDanmakuUseDefaultConfig\"))\n                                if (typeof message.playerDanmakuUseDefaultConfig !== \"boolean\")\n                                    return \"playerDanmakuUseDefaultConfig: boolean expected\";\n                            if (message.playerDanmakuAiRecommendedSwitch != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedSwitch\"))\n                                if (typeof message.playerDanmakuAiRecommendedSwitch !== \"boolean\")\n                                    return \"playerDanmakuAiRecommendedSwitch: boolean expected\";\n                            if (message.playerDanmakuAiRecommendedLevel != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevel\"))\n                                if (!$util.isInteger(message.playerDanmakuAiRecommendedLevel))\n                                    return \"playerDanmakuAiRecommendedLevel: integer expected\";\n                            if (message.playerDanmakuBlocktop != null && message.hasOwnProperty(\"playerDanmakuBlocktop\"))\n                                if (typeof message.playerDanmakuBlocktop !== \"boolean\")\n                                    return \"playerDanmakuBlocktop: boolean expected\";\n                            if (message.playerDanmakuBlockscroll != null && message.hasOwnProperty(\"playerDanmakuBlockscroll\"))\n                                if (typeof message.playerDanmakuBlockscroll !== \"boolean\")\n                                    return \"playerDanmakuBlockscroll: boolean expected\";\n                            if (message.playerDanmakuBlockbottom != null && message.hasOwnProperty(\"playerDanmakuBlockbottom\"))\n                                if (typeof message.playerDanmakuBlockbottom !== \"boolean\")\n                                    return \"playerDanmakuBlockbottom: boolean expected\";\n                            if (message.playerDanmakuBlockcolorful != null && message.hasOwnProperty(\"playerDanmakuBlockcolorful\"))\n                                if (typeof message.playerDanmakuBlockcolorful !== \"boolean\")\n                                    return \"playerDanmakuBlockcolorful: boolean expected\";\n                            if (message.playerDanmakuBlockrepeat != null && message.hasOwnProperty(\"playerDanmakuBlockrepeat\"))\n                                if (typeof message.playerDanmakuBlockrepeat !== \"boolean\")\n                                    return \"playerDanmakuBlockrepeat: boolean expected\";\n                            if (message.playerDanmakuBlockspecial != null && message.hasOwnProperty(\"playerDanmakuBlockspecial\"))\n                                if (typeof message.playerDanmakuBlockspecial !== \"boolean\")\n                                    return \"playerDanmakuBlockspecial: boolean expected\";\n                            if (message.playerDanmakuOpacity != null && message.hasOwnProperty(\"playerDanmakuOpacity\"))\n                                if (typeof message.playerDanmakuOpacity !== \"number\")\n                                    return \"playerDanmakuOpacity: number expected\";\n                            if (message.playerDanmakuScalingfactor != null && message.hasOwnProperty(\"playerDanmakuScalingfactor\"))\n                                if (typeof message.playerDanmakuScalingfactor !== \"number\")\n                                    return \"playerDanmakuScalingfactor: number expected\";\n                            if (message.playerDanmakuDomain != null && message.hasOwnProperty(\"playerDanmakuDomain\"))\n                                if (typeof message.playerDanmakuDomain !== \"number\")\n                                    return \"playerDanmakuDomain: number expected\";\n                            if (message.playerDanmakuSpeed != null && message.hasOwnProperty(\"playerDanmakuSpeed\"))\n                                if (!$util.isInteger(message.playerDanmakuSpeed))\n                                    return \"playerDanmakuSpeed: integer expected\";\n                            if (message.playerDanmakuEnableblocklist != null && message.hasOwnProperty(\"playerDanmakuEnableblocklist\"))\n                                if (typeof message.playerDanmakuEnableblocklist !== \"boolean\")\n                                    return \"playerDanmakuEnableblocklist: boolean expected\";\n                            if (message.inlinePlayerDanmakuSwitch != null && message.hasOwnProperty(\"inlinePlayerDanmakuSwitch\"))\n                                if (typeof message.inlinePlayerDanmakuSwitch !== \"boolean\")\n                                    return \"inlinePlayerDanmakuSwitch: boolean expected\";\n                            if (message.inlinePlayerDanmakuConfig != null && message.hasOwnProperty(\"inlinePlayerDanmakuConfig\"))\n                                if (!$util.isInteger(message.inlinePlayerDanmakuConfig))\n                                    return \"inlinePlayerDanmakuConfig: integer expected\";\n                            if (message.playerDanmakuIosSwitchSave != null && message.hasOwnProperty(\"playerDanmakuIosSwitchSave\"))\n                                if (!$util.isInteger(message.playerDanmakuIosSwitchSave))\n                                    return \"playerDanmakuIosSwitchSave: integer expected\";\n                            if (message.playerDanmakuSeniorModeSwitch != null && message.hasOwnProperty(\"playerDanmakuSeniorModeSwitch\"))\n                                if (!$util.isInteger(message.playerDanmakuSeniorModeSwitch))\n                                    return \"playerDanmakuSeniorModeSwitch: integer expected\";\n                            if (message.playerDanmakuAiRecommendedLevelV2 != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevelV2\"))\n                                if (!$util.isInteger(message.playerDanmakuAiRecommendedLevelV2))\n                                    return \"playerDanmakuAiRecommendedLevelV2: integer expected\";\n                            if (message.playerDanmakuAiRecommendedLevelV2Map != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevelV2Map\")) {\n                                if (!$util.isObject(message.playerDanmakuAiRecommendedLevelV2Map))\n                                    return \"playerDanmakuAiRecommendedLevelV2Map: object expected\";\n                                var key = Object.keys(message.playerDanmakuAiRecommendedLevelV2Map);\n                                for (var i = 0; i < key.length; ++i) {\n                                    if (!$util.key32Re.test(key[i]))\n                                        return \"playerDanmakuAiRecommendedLevelV2Map: integer key{k:int32} expected\";\n                                    if (!$util.isInteger(message.playerDanmakuAiRecommendedLevelV2Map[key[i]]))\n                                        return \"playerDanmakuAiRecommendedLevelV2Map: integer{k:int32} expected\";\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmuPlayerConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfig} DanmuPlayerConfig\n                         */\n                        DanmuPlayerConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmuPlayerConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmuPlayerConfig();\n                            if (object.playerDanmakuSwitch != null)\n                                message.playerDanmakuSwitch = Boolean(object.playerDanmakuSwitch);\n                            if (object.playerDanmakuSwitchSave != null)\n                                message.playerDanmakuSwitchSave = Boolean(object.playerDanmakuSwitchSave);\n                            if (object.playerDanmakuUseDefaultConfig != null)\n                                message.playerDanmakuUseDefaultConfig = Boolean(object.playerDanmakuUseDefaultConfig);\n                            if (object.playerDanmakuAiRecommendedSwitch != null)\n                                message.playerDanmakuAiRecommendedSwitch = Boolean(object.playerDanmakuAiRecommendedSwitch);\n                            if (object.playerDanmakuAiRecommendedLevel != null)\n                                message.playerDanmakuAiRecommendedLevel = object.playerDanmakuAiRecommendedLevel | 0;\n                            if (object.playerDanmakuBlocktop != null)\n                                message.playerDanmakuBlocktop = Boolean(object.playerDanmakuBlocktop);\n                            if (object.playerDanmakuBlockscroll != null)\n                                message.playerDanmakuBlockscroll = Boolean(object.playerDanmakuBlockscroll);\n                            if (object.playerDanmakuBlockbottom != null)\n                                message.playerDanmakuBlockbottom = Boolean(object.playerDanmakuBlockbottom);\n                            if (object.playerDanmakuBlockcolorful != null)\n                                message.playerDanmakuBlockcolorful = Boolean(object.playerDanmakuBlockcolorful);\n                            if (object.playerDanmakuBlockrepeat != null)\n                                message.playerDanmakuBlockrepeat = Boolean(object.playerDanmakuBlockrepeat);\n                            if (object.playerDanmakuBlockspecial != null)\n                                message.playerDanmakuBlockspecial = Boolean(object.playerDanmakuBlockspecial);\n                            if (object.playerDanmakuOpacity != null)\n                                message.playerDanmakuOpacity = Number(object.playerDanmakuOpacity);\n                            if (object.playerDanmakuScalingfactor != null)\n                                message.playerDanmakuScalingfactor = Number(object.playerDanmakuScalingfactor);\n                            if (object.playerDanmakuDomain != null)\n                                message.playerDanmakuDomain = Number(object.playerDanmakuDomain);\n                            if (object.playerDanmakuSpeed != null)\n                                message.playerDanmakuSpeed = object.playerDanmakuSpeed | 0;\n                            if (object.playerDanmakuEnableblocklist != null)\n                                message.playerDanmakuEnableblocklist = Boolean(object.playerDanmakuEnableblocklist);\n                            if (object.inlinePlayerDanmakuSwitch != null)\n                                message.inlinePlayerDanmakuSwitch = Boolean(object.inlinePlayerDanmakuSwitch);\n                            if (object.inlinePlayerDanmakuConfig != null)\n                                message.inlinePlayerDanmakuConfig = object.inlinePlayerDanmakuConfig | 0;\n                            if (object.playerDanmakuIosSwitchSave != null)\n                                message.playerDanmakuIosSwitchSave = object.playerDanmakuIosSwitchSave | 0;\n                            if (object.playerDanmakuSeniorModeSwitch != null)\n                                message.playerDanmakuSeniorModeSwitch = object.playerDanmakuSeniorModeSwitch | 0;\n                            if (object.playerDanmakuAiRecommendedLevelV2 != null)\n                                message.playerDanmakuAiRecommendedLevelV2 = object.playerDanmakuAiRecommendedLevelV2 | 0;\n                            if (object.playerDanmakuAiRecommendedLevelV2Map) {\n                                if (typeof object.playerDanmakuAiRecommendedLevelV2Map !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuPlayerConfig.playerDanmakuAiRecommendedLevelV2Map: object expected\");\n                                message.playerDanmakuAiRecommendedLevelV2Map = {};\n                                for (var keys = Object.keys(object.playerDanmakuAiRecommendedLevelV2Map), i = 0; i < keys.length; ++i)\n                                    message.playerDanmakuAiRecommendedLevelV2Map[keys[i]] = object.playerDanmakuAiRecommendedLevelV2Map[keys[i]] | 0;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmuPlayerConfig} message DanmuPlayerConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmuPlayerConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.objects || options.defaults)\n                                object.playerDanmakuAiRecommendedLevelV2Map = {};\n                            if (options.defaults) {\n                                object.playerDanmakuSwitch = false;\n                                object.playerDanmakuSwitchSave = false;\n                                object.playerDanmakuUseDefaultConfig = false;\n                                object.playerDanmakuAiRecommendedSwitch = false;\n                                object.playerDanmakuAiRecommendedLevel = 0;\n                                object.playerDanmakuBlocktop = false;\n                                object.playerDanmakuBlockscroll = false;\n                                object.playerDanmakuBlockbottom = false;\n                                object.playerDanmakuBlockcolorful = false;\n                                object.playerDanmakuBlockrepeat = false;\n                                object.playerDanmakuBlockspecial = false;\n                                object.playerDanmakuOpacity = 0;\n                                object.playerDanmakuScalingfactor = 0;\n                                object.playerDanmakuDomain = 0;\n                                object.playerDanmakuSpeed = 0;\n                                object.playerDanmakuEnableblocklist = false;\n                                object.inlinePlayerDanmakuSwitch = false;\n                                object.inlinePlayerDanmakuConfig = 0;\n                                object.playerDanmakuIosSwitchSave = 0;\n                                object.playerDanmakuSeniorModeSwitch = 0;\n                                object.playerDanmakuAiRecommendedLevelV2 = 0;\n                            }\n                            if (message.playerDanmakuSwitch != null && message.hasOwnProperty(\"playerDanmakuSwitch\"))\n                                object.playerDanmakuSwitch = message.playerDanmakuSwitch;\n                            if (message.playerDanmakuSwitchSave != null && message.hasOwnProperty(\"playerDanmakuSwitchSave\"))\n                                object.playerDanmakuSwitchSave = message.playerDanmakuSwitchSave;\n                            if (message.playerDanmakuUseDefaultConfig != null && message.hasOwnProperty(\"playerDanmakuUseDefaultConfig\"))\n                                object.playerDanmakuUseDefaultConfig = message.playerDanmakuUseDefaultConfig;\n                            if (message.playerDanmakuAiRecommendedSwitch != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedSwitch\"))\n                                object.playerDanmakuAiRecommendedSwitch = message.playerDanmakuAiRecommendedSwitch;\n                            if (message.playerDanmakuAiRecommendedLevel != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevel\"))\n                                object.playerDanmakuAiRecommendedLevel = message.playerDanmakuAiRecommendedLevel;\n                            if (message.playerDanmakuBlocktop != null && message.hasOwnProperty(\"playerDanmakuBlocktop\"))\n                                object.playerDanmakuBlocktop = message.playerDanmakuBlocktop;\n                            if (message.playerDanmakuBlockscroll != null && message.hasOwnProperty(\"playerDanmakuBlockscroll\"))\n                                object.playerDanmakuBlockscroll = message.playerDanmakuBlockscroll;\n                            if (message.playerDanmakuBlockbottom != null && message.hasOwnProperty(\"playerDanmakuBlockbottom\"))\n                                object.playerDanmakuBlockbottom = message.playerDanmakuBlockbottom;\n                            if (message.playerDanmakuBlockcolorful != null && message.hasOwnProperty(\"playerDanmakuBlockcolorful\"))\n                                object.playerDanmakuBlockcolorful = message.playerDanmakuBlockcolorful;\n                            if (message.playerDanmakuBlockrepeat != null && message.hasOwnProperty(\"playerDanmakuBlockrepeat\"))\n                                object.playerDanmakuBlockrepeat = message.playerDanmakuBlockrepeat;\n                            if (message.playerDanmakuBlockspecial != null && message.hasOwnProperty(\"playerDanmakuBlockspecial\"))\n                                object.playerDanmakuBlockspecial = message.playerDanmakuBlockspecial;\n                            if (message.playerDanmakuOpacity != null && message.hasOwnProperty(\"playerDanmakuOpacity\"))\n                                object.playerDanmakuOpacity = options.json && !isFinite(message.playerDanmakuOpacity) ? String(message.playerDanmakuOpacity) : message.playerDanmakuOpacity;\n                            if (message.playerDanmakuScalingfactor != null && message.hasOwnProperty(\"playerDanmakuScalingfactor\"))\n                                object.playerDanmakuScalingfactor = options.json && !isFinite(message.playerDanmakuScalingfactor) ? String(message.playerDanmakuScalingfactor) : message.playerDanmakuScalingfactor;\n                            if (message.playerDanmakuDomain != null && message.hasOwnProperty(\"playerDanmakuDomain\"))\n                                object.playerDanmakuDomain = options.json && !isFinite(message.playerDanmakuDomain) ? String(message.playerDanmakuDomain) : message.playerDanmakuDomain;\n                            if (message.playerDanmakuSpeed != null && message.hasOwnProperty(\"playerDanmakuSpeed\"))\n                                object.playerDanmakuSpeed = message.playerDanmakuSpeed;\n                            if (message.playerDanmakuEnableblocklist != null && message.hasOwnProperty(\"playerDanmakuEnableblocklist\"))\n                                object.playerDanmakuEnableblocklist = message.playerDanmakuEnableblocklist;\n                            if (message.inlinePlayerDanmakuSwitch != null && message.hasOwnProperty(\"inlinePlayerDanmakuSwitch\"))\n                                object.inlinePlayerDanmakuSwitch = message.inlinePlayerDanmakuSwitch;\n                            if (message.inlinePlayerDanmakuConfig != null && message.hasOwnProperty(\"inlinePlayerDanmakuConfig\"))\n                                object.inlinePlayerDanmakuConfig = message.inlinePlayerDanmakuConfig;\n                            if (message.playerDanmakuIosSwitchSave != null && message.hasOwnProperty(\"playerDanmakuIosSwitchSave\"))\n                                object.playerDanmakuIosSwitchSave = message.playerDanmakuIosSwitchSave;\n                            if (message.playerDanmakuSeniorModeSwitch != null && message.hasOwnProperty(\"playerDanmakuSeniorModeSwitch\"))\n                                object.playerDanmakuSeniorModeSwitch = message.playerDanmakuSeniorModeSwitch;\n                            if (message.playerDanmakuAiRecommendedLevelV2 != null && message.hasOwnProperty(\"playerDanmakuAiRecommendedLevelV2\"))\n                                object.playerDanmakuAiRecommendedLevelV2 = message.playerDanmakuAiRecommendedLevelV2;\n                            var keys2;\n                            if (message.playerDanmakuAiRecommendedLevelV2Map && (keys2 = Object.keys(message.playerDanmakuAiRecommendedLevelV2Map)).length) {\n                                object.playerDanmakuAiRecommendedLevelV2Map = {};\n                                for (var j = 0; j < keys2.length; ++j)\n                                    object.playerDanmakuAiRecommendedLevelV2Map[keys2[j]] = message.playerDanmakuAiRecommendedLevelV2Map[keys2[j]];\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmuPlayerConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmuPlayerConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmuPlayerConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmuPlayerConfig\";\n                        };\n\n                        return DanmuPlayerConfig;\n                    })();\n\n                    v1.DanmuPlayerConfigPanel = (function() {\n\n                        /**\n                         * Properties of a DanmuPlayerConfigPanel.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmuPlayerConfigPanel\n                         * @property {string|null} [selectionText] DanmuPlayerConfigPanel selectionText\n                         */\n\n                        /**\n                         * Constructs a new DanmuPlayerConfigPanel.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmuPlayerConfigPanel.\n                         * @implements IDanmuPlayerConfigPanel\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel=} [properties] Properties to set\n                         */\n                        function DanmuPlayerConfigPanel(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmuPlayerConfigPanel selectionText.\n                         * @member {string} selectionText\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @instance\n                         */\n                        DanmuPlayerConfigPanel.prototype.selectionText = \"\";\n\n                        /**\n                         * Creates a new DanmuPlayerConfigPanel instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfigPanel} DanmuPlayerConfigPanel instance\n                         */\n                        DanmuPlayerConfigPanel.create = function create(properties) {\n                            return new DanmuPlayerConfigPanel(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfigPanel message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel} message DanmuPlayerConfigPanel message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerConfigPanel.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.selectionText != null && Object.hasOwnProperty.call(message, \"selectionText\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.selectionText);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerConfigPanel message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel} message DanmuPlayerConfigPanel message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerConfigPanel.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerConfigPanel message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfigPanel} DanmuPlayerConfigPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerConfigPanel.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.selectionText = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerConfigPanel message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfigPanel} DanmuPlayerConfigPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerConfigPanel.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmuPlayerConfigPanel message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmuPlayerConfigPanel.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.selectionText != null && message.hasOwnProperty(\"selectionText\"))\n                                if (!$util.isString(message.selectionText))\n                                    return \"selectionText: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmuPlayerConfigPanel message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerConfigPanel} DanmuPlayerConfigPanel\n                         */\n                        DanmuPlayerConfigPanel.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel();\n                            if (object.selectionText != null)\n                                message.selectionText = String(object.selectionText);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerConfigPanel message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmuPlayerConfigPanel} message DanmuPlayerConfigPanel\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmuPlayerConfigPanel.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.selectionText = \"\";\n                            if (message.selectionText != null && message.hasOwnProperty(\"selectionText\"))\n                                object.selectionText = message.selectionText;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmuPlayerConfigPanel to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmuPlayerConfigPanel.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerConfigPanel\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmuPlayerConfigPanel.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmuPlayerConfigPanel\";\n                        };\n\n                        return DanmuPlayerConfigPanel;\n                    })();\n\n                    v1.DanmuPlayerDynamicConfig = (function() {\n\n                        /**\n                         * Properties of a DanmuPlayerDynamicConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmuPlayerDynamicConfig\n                         * @property {number|null} [progress] DanmuPlayerDynamicConfig progress\n                         * @property {number|null} [playerDanmakuDomain] DanmuPlayerDynamicConfig playerDanmakuDomain\n                         */\n\n                        /**\n                         * Constructs a new DanmuPlayerDynamicConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmuPlayerDynamicConfig.\n                         * @implements IDanmuPlayerDynamicConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig=} [properties] Properties to set\n                         */\n                        function DanmuPlayerDynamicConfig(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmuPlayerDynamicConfig progress.\n                         * @member {number} progress\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @instance\n                         */\n                        DanmuPlayerDynamicConfig.prototype.progress = 0;\n\n                        /**\n                         * DanmuPlayerDynamicConfig playerDanmakuDomain.\n                         * @member {number} playerDanmakuDomain\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @instance\n                         */\n                        DanmuPlayerDynamicConfig.prototype.playerDanmakuDomain = 0;\n\n                        /**\n                         * Creates a new DanmuPlayerDynamicConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig} DanmuPlayerDynamicConfig instance\n                         */\n                        DanmuPlayerDynamicConfig.create = function create(properties) {\n                            return new DanmuPlayerDynamicConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerDynamicConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig} message DanmuPlayerDynamicConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerDynamicConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.progress != null && Object.hasOwnProperty.call(message, \"progress\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.progress);\n                            if (message.playerDanmakuDomain != null && Object.hasOwnProperty.call(message, \"playerDanmakuDomain\"))\n                                writer.uint32(/* id 14, wireType 5 =*/117).float(message.playerDanmakuDomain);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerDynamicConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig} message DanmuPlayerDynamicConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerDynamicConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerDynamicConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig} DanmuPlayerDynamicConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerDynamicConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.progress = reader.int32();\n                                        break;\n                                    }\n                                case 14: {\n                                        message.playerDanmakuDomain = reader.float();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerDynamicConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig} DanmuPlayerDynamicConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerDynamicConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmuPlayerDynamicConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmuPlayerDynamicConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.progress != null && message.hasOwnProperty(\"progress\"))\n                                if (!$util.isInteger(message.progress))\n                                    return \"progress: integer expected\";\n                            if (message.playerDanmakuDomain != null && message.hasOwnProperty(\"playerDanmakuDomain\"))\n                                if (typeof message.playerDanmakuDomain !== \"number\")\n                                    return \"playerDanmakuDomain: number expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmuPlayerDynamicConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig} DanmuPlayerDynamicConfig\n                         */\n                        DanmuPlayerDynamicConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig();\n                            if (object.progress != null)\n                                message.progress = object.progress | 0;\n                            if (object.playerDanmakuDomain != null)\n                                message.playerDanmakuDomain = Number(object.playerDanmakuDomain);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerDynamicConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig} message DanmuPlayerDynamicConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmuPlayerDynamicConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.progress = 0;\n                                object.playerDanmakuDomain = 0;\n                            }\n                            if (message.progress != null && message.hasOwnProperty(\"progress\"))\n                                object.progress = message.progress;\n                            if (message.playerDanmakuDomain != null && message.hasOwnProperty(\"playerDanmakuDomain\"))\n                                object.playerDanmakuDomain = options.json && !isFinite(message.playerDanmakuDomain) ? String(message.playerDanmakuDomain) : message.playerDanmakuDomain;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmuPlayerDynamicConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmuPlayerDynamicConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerDynamicConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmuPlayerDynamicConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig\";\n                        };\n\n                        return DanmuPlayerDynamicConfig;\n                    })();\n\n                    v1.DanmuPlayerViewConfig = (function() {\n\n                        /**\n                         * Properties of a DanmuPlayerViewConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmuPlayerViewConfig\n                         * @property {bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig|null} [danmukuDefaultPlayerConfig] DanmuPlayerViewConfig danmukuDefaultPlayerConfig\n                         * @property {bilibili.community.service.dm.v1.IDanmuPlayerConfig|null} [danmukuPlayerConfig] DanmuPlayerViewConfig danmukuPlayerConfig\n                         * @property {Array.<bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig>|null} [danmukuPlayerDynamicConfig] DanmuPlayerViewConfig danmukuPlayerDynamicConfig\n                         * @property {bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel|null} [danmukuPlayerConfigPanel] DanmuPlayerViewConfig danmukuPlayerConfigPanel\n                         */\n\n                        /**\n                         * Constructs a new DanmuPlayerViewConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmuPlayerViewConfig.\n                         * @implements IDanmuPlayerViewConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerViewConfig=} [properties] Properties to set\n                         */\n                        function DanmuPlayerViewConfig(properties) {\n                            this.danmukuPlayerDynamicConfig = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmuPlayerViewConfig danmukuDefaultPlayerConfig.\n                         * @member {bilibili.community.service.dm.v1.IDanmuDefaultPlayerConfig|null|undefined} danmukuDefaultPlayerConfig\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @instance\n                         */\n                        DanmuPlayerViewConfig.prototype.danmukuDefaultPlayerConfig = null;\n\n                        /**\n                         * DanmuPlayerViewConfig danmukuPlayerConfig.\n                         * @member {bilibili.community.service.dm.v1.IDanmuPlayerConfig|null|undefined} danmukuPlayerConfig\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @instance\n                         */\n                        DanmuPlayerViewConfig.prototype.danmukuPlayerConfig = null;\n\n                        /**\n                         * DanmuPlayerViewConfig danmukuPlayerDynamicConfig.\n                         * @member {Array.<bilibili.community.service.dm.v1.IDanmuPlayerDynamicConfig>} danmukuPlayerDynamicConfig\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @instance\n                         */\n                        DanmuPlayerViewConfig.prototype.danmukuPlayerDynamicConfig = $util.emptyArray;\n\n                        /**\n                         * DanmuPlayerViewConfig danmukuPlayerConfigPanel.\n                         * @member {bilibili.community.service.dm.v1.IDanmuPlayerConfigPanel|null|undefined} danmukuPlayerConfigPanel\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @instance\n                         */\n                        DanmuPlayerViewConfig.prototype.danmukuPlayerConfigPanel = null;\n\n                        /**\n                         * Creates a new DanmuPlayerViewConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerViewConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerViewConfig} DanmuPlayerViewConfig instance\n                         */\n                        DanmuPlayerViewConfig.create = function create(properties) {\n                            return new DanmuPlayerViewConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerViewConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerViewConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerViewConfig} message DanmuPlayerViewConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerViewConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.danmukuDefaultPlayerConfig != null && Object.hasOwnProperty.call(message, \"danmukuDefaultPlayerConfig\"))\n                                $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.encode(message.danmukuDefaultPlayerConfig, writer.uint32(/* id 1, wireType 2 =*/10).fork()).ldelim();\n                            if (message.danmukuPlayerConfig != null && Object.hasOwnProperty.call(message, \"danmukuPlayerConfig\"))\n                                $root.bilibili.community.service.dm.v1.DanmuPlayerConfig.encode(message.danmukuPlayerConfig, writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim();\n                            if (message.danmukuPlayerDynamicConfig != null && message.danmukuPlayerDynamicConfig.length)\n                                for (var i = 0; i < message.danmukuPlayerDynamicConfig.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.encode(message.danmukuPlayerDynamicConfig[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            if (message.danmukuPlayerConfigPanel != null && Object.hasOwnProperty.call(message, \"danmukuPlayerConfigPanel\"))\n                                $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.encode(message.danmukuPlayerConfigPanel, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmuPlayerViewConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuPlayerViewConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuPlayerViewConfig} message DanmuPlayerViewConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuPlayerViewConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerViewConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerViewConfig} DanmuPlayerViewConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerViewConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.danmukuDefaultPlayerConfig = $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 2: {\n                                        message.danmukuPlayerConfig = $root.bilibili.community.service.dm.v1.DanmuPlayerConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 3: {\n                                        if (!(message.danmukuPlayerDynamicConfig && message.danmukuPlayerDynamicConfig.length))\n                                            message.danmukuPlayerDynamicConfig = [];\n                                        message.danmukuPlayerDynamicConfig.push($root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 4: {\n                                        message.danmukuPlayerConfigPanel = $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmuPlayerViewConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerViewConfig} DanmuPlayerViewConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuPlayerViewConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmuPlayerViewConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmuPlayerViewConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.danmukuDefaultPlayerConfig != null && message.hasOwnProperty(\"danmukuDefaultPlayerConfig\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.verify(message.danmukuDefaultPlayerConfig);\n                                if (error)\n                                    return \"danmukuDefaultPlayerConfig.\" + error;\n                            }\n                            if (message.danmukuPlayerConfig != null && message.hasOwnProperty(\"danmukuPlayerConfig\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmuPlayerConfig.verify(message.danmukuPlayerConfig);\n                                if (error)\n                                    return \"danmukuPlayerConfig.\" + error;\n                            }\n                            if (message.danmukuPlayerDynamicConfig != null && message.hasOwnProperty(\"danmukuPlayerDynamicConfig\")) {\n                                if (!Array.isArray(message.danmukuPlayerDynamicConfig))\n                                    return \"danmukuPlayerDynamicConfig: array expected\";\n                                for (var i = 0; i < message.danmukuPlayerDynamicConfig.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.verify(message.danmukuPlayerDynamicConfig[i]);\n                                    if (error)\n                                        return \"danmukuPlayerDynamicConfig.\" + error;\n                                }\n                            }\n                            if (message.danmukuPlayerConfigPanel != null && message.hasOwnProperty(\"danmukuPlayerConfigPanel\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.verify(message.danmukuPlayerConfigPanel);\n                                if (error)\n                                    return \"danmukuPlayerConfigPanel.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmuPlayerViewConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmuPlayerViewConfig} DanmuPlayerViewConfig\n                         */\n                        DanmuPlayerViewConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig();\n                            if (object.danmukuDefaultPlayerConfig != null) {\n                                if (typeof object.danmukuDefaultPlayerConfig !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuPlayerViewConfig.danmukuDefaultPlayerConfig: object expected\");\n                                message.danmukuDefaultPlayerConfig = $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.fromObject(object.danmukuDefaultPlayerConfig);\n                            }\n                            if (object.danmukuPlayerConfig != null) {\n                                if (typeof object.danmukuPlayerConfig !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuPlayerViewConfig.danmukuPlayerConfig: object expected\");\n                                message.danmukuPlayerConfig = $root.bilibili.community.service.dm.v1.DanmuPlayerConfig.fromObject(object.danmukuPlayerConfig);\n                            }\n                            if (object.danmukuPlayerDynamicConfig) {\n                                if (!Array.isArray(object.danmukuPlayerDynamicConfig))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuPlayerViewConfig.danmukuPlayerDynamicConfig: array expected\");\n                                message.danmukuPlayerDynamicConfig = [];\n                                for (var i = 0; i < object.danmukuPlayerDynamicConfig.length; ++i) {\n                                    if (typeof object.danmukuPlayerDynamicConfig[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DanmuPlayerViewConfig.danmukuPlayerDynamicConfig: object expected\");\n                                    message.danmukuPlayerDynamicConfig[i] = $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.fromObject(object.danmukuPlayerDynamicConfig[i]);\n                                }\n                            }\n                            if (object.danmukuPlayerConfigPanel != null) {\n                                if (typeof object.danmukuPlayerConfigPanel !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuPlayerViewConfig.danmukuPlayerConfigPanel: object expected\");\n                                message.danmukuPlayerConfigPanel = $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.fromObject(object.danmukuPlayerConfigPanel);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmuPlayerViewConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmuPlayerViewConfig} message DanmuPlayerViewConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmuPlayerViewConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.danmukuPlayerDynamicConfig = [];\n                            if (options.defaults) {\n                                object.danmukuDefaultPlayerConfig = null;\n                                object.danmukuPlayerConfig = null;\n                                object.danmukuPlayerConfigPanel = null;\n                            }\n                            if (message.danmukuDefaultPlayerConfig != null && message.hasOwnProperty(\"danmukuDefaultPlayerConfig\"))\n                                object.danmukuDefaultPlayerConfig = $root.bilibili.community.service.dm.v1.DanmuDefaultPlayerConfig.toObject(message.danmukuDefaultPlayerConfig, options);\n                            if (message.danmukuPlayerConfig != null && message.hasOwnProperty(\"danmukuPlayerConfig\"))\n                                object.danmukuPlayerConfig = $root.bilibili.community.service.dm.v1.DanmuPlayerConfig.toObject(message.danmukuPlayerConfig, options);\n                            if (message.danmukuPlayerDynamicConfig && message.danmukuPlayerDynamicConfig.length) {\n                                object.danmukuPlayerDynamicConfig = [];\n                                for (var j = 0; j < message.danmukuPlayerDynamicConfig.length; ++j)\n                                    object.danmukuPlayerDynamicConfig[j] = $root.bilibili.community.service.dm.v1.DanmuPlayerDynamicConfig.toObject(message.danmukuPlayerDynamicConfig[j], options);\n                            }\n                            if (message.danmukuPlayerConfigPanel != null && message.hasOwnProperty(\"danmukuPlayerConfigPanel\"))\n                                object.danmukuPlayerConfigPanel = $root.bilibili.community.service.dm.v1.DanmuPlayerConfigPanel.toObject(message.danmukuPlayerConfigPanel, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmuPlayerViewConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmuPlayerViewConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmuPlayerViewConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmuPlayerViewConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmuPlayerViewConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmuPlayerViewConfig\";\n                        };\n\n                        return DanmuPlayerViewConfig;\n                    })();\n\n                    v1.DanmuWebPlayerConfig = (function() {\n\n                        /**\n                         * Properties of a DanmuWebPlayerConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDanmuWebPlayerConfig\n                         * @property {boolean|null} [dmSwitch] DanmuWebPlayerConfig dmSwitch\n                         * @property {boolean|null} [aiSwitch] DanmuWebPlayerConfig aiSwitch\n                         * @property {number|null} [aiLevel] DanmuWebPlayerConfig aiLevel\n                         * @property {boolean|null} [blocktop] DanmuWebPlayerConfig blocktop\n                         * @property {boolean|null} [blockscroll] DanmuWebPlayerConfig blockscroll\n                         * @property {boolean|null} [blockbottom] DanmuWebPlayerConfig blockbottom\n                         * @property {boolean|null} [blockcolor] DanmuWebPlayerConfig blockcolor\n                         * @property {boolean|null} [blockspecial] DanmuWebPlayerConfig blockspecial\n                         * @property {boolean|null} [preventshade] DanmuWebPlayerConfig preventshade\n                         * @property {boolean|null} [dmask] DanmuWebPlayerConfig dmask\n                         * @property {number|null} [opacity] DanmuWebPlayerConfig opacity\n                         * @property {number|null} [dmarea] DanmuWebPlayerConfig dmarea\n                         * @property {number|null} [speedplus] DanmuWebPlayerConfig speedplus\n                         * @property {number|null} [fontsize] DanmuWebPlayerConfig fontsize\n                         * @property {boolean|null} [screensync] DanmuWebPlayerConfig screensync\n                         * @property {boolean|null} [speedsync] DanmuWebPlayerConfig speedsync\n                         * @property {string|null} [fontfamily] DanmuWebPlayerConfig fontfamily\n                         * @property {boolean|null} [bold] DanmuWebPlayerConfig bold\n                         * @property {number|null} [fontborder] DanmuWebPlayerConfig fontborder\n                         * @property {string|null} [drawType] DanmuWebPlayerConfig drawType\n                         * @property {number|null} [seniorModeSwitch] DanmuWebPlayerConfig seniorModeSwitch\n                         * @property {number|null} [aiLevelV2] DanmuWebPlayerConfig aiLevelV2\n                         * @property {Object.<string,number>|null} [aiLevelV2Map] DanmuWebPlayerConfig aiLevelV2Map\n                         */\n\n                        /**\n                         * Constructs a new DanmuWebPlayerConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DanmuWebPlayerConfig.\n                         * @implements IDanmuWebPlayerConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDanmuWebPlayerConfig=} [properties] Properties to set\n                         */\n                        function DanmuWebPlayerConfig(properties) {\n                            this.aiLevelV2Map = {};\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DanmuWebPlayerConfig dmSwitch.\n                         * @member {boolean} dmSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.dmSwitch = false;\n\n                        /**\n                         * DanmuWebPlayerConfig aiSwitch.\n                         * @member {boolean} aiSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.aiSwitch = false;\n\n                        /**\n                         * DanmuWebPlayerConfig aiLevel.\n                         * @member {number} aiLevel\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.aiLevel = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig blocktop.\n                         * @member {boolean} blocktop\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.blocktop = false;\n\n                        /**\n                         * DanmuWebPlayerConfig blockscroll.\n                         * @member {boolean} blockscroll\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.blockscroll = false;\n\n                        /**\n                         * DanmuWebPlayerConfig blockbottom.\n                         * @member {boolean} blockbottom\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.blockbottom = false;\n\n                        /**\n                         * DanmuWebPlayerConfig blockcolor.\n                         * @member {boolean} blockcolor\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.blockcolor = false;\n\n                        /**\n                         * DanmuWebPlayerConfig blockspecial.\n                         * @member {boolean} blockspecial\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.blockspecial = false;\n\n                        /**\n                         * DanmuWebPlayerConfig preventshade.\n                         * @member {boolean} preventshade\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.preventshade = false;\n\n                        /**\n                         * DanmuWebPlayerConfig dmask.\n                         * @member {boolean} dmask\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.dmask = false;\n\n                        /**\n                         * DanmuWebPlayerConfig opacity.\n                         * @member {number} opacity\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.opacity = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig dmarea.\n                         * @member {number} dmarea\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.dmarea = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig speedplus.\n                         * @member {number} speedplus\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.speedplus = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig fontsize.\n                         * @member {number} fontsize\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.fontsize = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig screensync.\n                         * @member {boolean} screensync\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.screensync = false;\n\n                        /**\n                         * DanmuWebPlayerConfig speedsync.\n                         * @member {boolean} speedsync\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.speedsync = false;\n\n                        /**\n                         * DanmuWebPlayerConfig fontfamily.\n                         * @member {string} fontfamily\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.fontfamily = \"\";\n\n                        /**\n                         * DanmuWebPlayerConfig bold.\n                         * @member {boolean} bold\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.bold = false;\n\n                        /**\n                         * DanmuWebPlayerConfig fontborder.\n                         * @member {number} fontborder\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.fontborder = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig drawType.\n                         * @member {string} drawType\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.drawType = \"\";\n\n                        /**\n                         * DanmuWebPlayerConfig seniorModeSwitch.\n                         * @member {number} seniorModeSwitch\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.seniorModeSwitch = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig aiLevelV2.\n                         * @member {number} aiLevelV2\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.aiLevelV2 = 0;\n\n                        /**\n                         * DanmuWebPlayerConfig aiLevelV2Map.\n                         * @member {Object.<string,number>} aiLevelV2Map\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         */\n                        DanmuWebPlayerConfig.prototype.aiLevelV2Map = $util.emptyObject;\n\n                        /**\n                         * Creates a new DanmuWebPlayerConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuWebPlayerConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DanmuWebPlayerConfig} DanmuWebPlayerConfig instance\n                         */\n                        DanmuWebPlayerConfig.create = function create(properties) {\n                            return new DanmuWebPlayerConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DanmuWebPlayerConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuWebPlayerConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuWebPlayerConfig} message DanmuWebPlayerConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuWebPlayerConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.dmSwitch != null && Object.hasOwnProperty.call(message, \"dmSwitch\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.dmSwitch);\n                            if (message.aiSwitch != null && Object.hasOwnProperty.call(message, \"aiSwitch\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).bool(message.aiSwitch);\n                            if (message.aiLevel != null && Object.hasOwnProperty.call(message, \"aiLevel\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.aiLevel);\n                            if (message.blocktop != null && Object.hasOwnProperty.call(message, \"blocktop\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.blocktop);\n                            if (message.blockscroll != null && Object.hasOwnProperty.call(message, \"blockscroll\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).bool(message.blockscroll);\n                            if (message.blockbottom != null && Object.hasOwnProperty.call(message, \"blockbottom\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).bool(message.blockbottom);\n                            if (message.blockcolor != null && Object.hasOwnProperty.call(message, \"blockcolor\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).bool(message.blockcolor);\n                            if (message.blockspecial != null && Object.hasOwnProperty.call(message, \"blockspecial\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).bool(message.blockspecial);\n                            if (message.preventshade != null && Object.hasOwnProperty.call(message, \"preventshade\"))\n                                writer.uint32(/* id 9, wireType 0 =*/72).bool(message.preventshade);\n                            if (message.dmask != null && Object.hasOwnProperty.call(message, \"dmask\"))\n                                writer.uint32(/* id 10, wireType 0 =*/80).bool(message.dmask);\n                            if (message.opacity != null && Object.hasOwnProperty.call(message, \"opacity\"))\n                                writer.uint32(/* id 11, wireType 5 =*/93).float(message.opacity);\n                            if (message.dmarea != null && Object.hasOwnProperty.call(message, \"dmarea\"))\n                                writer.uint32(/* id 12, wireType 0 =*/96).int32(message.dmarea);\n                            if (message.speedplus != null && Object.hasOwnProperty.call(message, \"speedplus\"))\n                                writer.uint32(/* id 13, wireType 5 =*/109).float(message.speedplus);\n                            if (message.fontsize != null && Object.hasOwnProperty.call(message, \"fontsize\"))\n                                writer.uint32(/* id 14, wireType 5 =*/117).float(message.fontsize);\n                            if (message.screensync != null && Object.hasOwnProperty.call(message, \"screensync\"))\n                                writer.uint32(/* id 15, wireType 0 =*/120).bool(message.screensync);\n                            if (message.speedsync != null && Object.hasOwnProperty.call(message, \"speedsync\"))\n                                writer.uint32(/* id 16, wireType 0 =*/128).bool(message.speedsync);\n                            if (message.fontfamily != null && Object.hasOwnProperty.call(message, \"fontfamily\"))\n                                writer.uint32(/* id 17, wireType 2 =*/138).string(message.fontfamily);\n                            if (message.bold != null && Object.hasOwnProperty.call(message, \"bold\"))\n                                writer.uint32(/* id 18, wireType 0 =*/144).bool(message.bold);\n                            if (message.fontborder != null && Object.hasOwnProperty.call(message, \"fontborder\"))\n                                writer.uint32(/* id 19, wireType 0 =*/152).int32(message.fontborder);\n                            if (message.drawType != null && Object.hasOwnProperty.call(message, \"drawType\"))\n                                writer.uint32(/* id 20, wireType 2 =*/162).string(message.drawType);\n                            if (message.seniorModeSwitch != null && Object.hasOwnProperty.call(message, \"seniorModeSwitch\"))\n                                writer.uint32(/* id 21, wireType 0 =*/168).int32(message.seniorModeSwitch);\n                            if (message.aiLevelV2 != null && Object.hasOwnProperty.call(message, \"aiLevelV2\"))\n                                writer.uint32(/* id 22, wireType 0 =*/176).int32(message.aiLevelV2);\n                            if (message.aiLevelV2Map != null && Object.hasOwnProperty.call(message, \"aiLevelV2Map\"))\n                                for (var keys = Object.keys(message.aiLevelV2Map), i = 0; i < keys.length; ++i)\n                                    writer.uint32(/* id 23, wireType 2 =*/186).fork().uint32(/* id 1, wireType 0 =*/8).int32(keys[i]).uint32(/* id 2, wireType 0 =*/16).int32(message.aiLevelV2Map[keys[i]]).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DanmuWebPlayerConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DanmuWebPlayerConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDanmuWebPlayerConfig} message DanmuWebPlayerConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DanmuWebPlayerConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DanmuWebPlayerConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DanmuWebPlayerConfig} DanmuWebPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuWebPlayerConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig(), key, value;\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.dmSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.aiSwitch = reader.bool();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.aiLevel = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.blocktop = reader.bool();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.blockscroll = reader.bool();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.blockbottom = reader.bool();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.blockcolor = reader.bool();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.blockspecial = reader.bool();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.preventshade = reader.bool();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.dmask = reader.bool();\n                                        break;\n                                    }\n                                case 11: {\n                                        message.opacity = reader.float();\n                                        break;\n                                    }\n                                case 12: {\n                                        message.dmarea = reader.int32();\n                                        break;\n                                    }\n                                case 13: {\n                                        message.speedplus = reader.float();\n                                        break;\n                                    }\n                                case 14: {\n                                        message.fontsize = reader.float();\n                                        break;\n                                    }\n                                case 15: {\n                                        message.screensync = reader.bool();\n                                        break;\n                                    }\n                                case 16: {\n                                        message.speedsync = reader.bool();\n                                        break;\n                                    }\n                                case 17: {\n                                        message.fontfamily = reader.string();\n                                        break;\n                                    }\n                                case 18: {\n                                        message.bold = reader.bool();\n                                        break;\n                                    }\n                                case 19: {\n                                        message.fontborder = reader.int32();\n                                        break;\n                                    }\n                                case 20: {\n                                        message.drawType = reader.string();\n                                        break;\n                                    }\n                                case 21: {\n                                        message.seniorModeSwitch = reader.int32();\n                                        break;\n                                    }\n                                case 22: {\n                                        message.aiLevelV2 = reader.int32();\n                                        break;\n                                    }\n                                case 23: {\n                                        if (message.aiLevelV2Map === $util.emptyObject)\n                                            message.aiLevelV2Map = {};\n                                        var end2 = reader.uint32() + reader.pos;\n                                        key = 0;\n                                        value = 0;\n                                        while (reader.pos < end2) {\n                                            var tag2 = reader.uint32();\n                                            switch (tag2 >>> 3) {\n                                            case 1:\n                                                key = reader.int32();\n                                                break;\n                                            case 2:\n                                                value = reader.int32();\n                                                break;\n                                            default:\n                                                reader.skipType(tag2 & 7);\n                                                break;\n                                            }\n                                        }\n                                        message.aiLevelV2Map[key] = value;\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DanmuWebPlayerConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DanmuWebPlayerConfig} DanmuWebPlayerConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DanmuWebPlayerConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DanmuWebPlayerConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DanmuWebPlayerConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.dmSwitch != null && message.hasOwnProperty(\"dmSwitch\"))\n                                if (typeof message.dmSwitch !== \"boolean\")\n                                    return \"dmSwitch: boolean expected\";\n                            if (message.aiSwitch != null && message.hasOwnProperty(\"aiSwitch\"))\n                                if (typeof message.aiSwitch !== \"boolean\")\n                                    return \"aiSwitch: boolean expected\";\n                            if (message.aiLevel != null && message.hasOwnProperty(\"aiLevel\"))\n                                if (!$util.isInteger(message.aiLevel))\n                                    return \"aiLevel: integer expected\";\n                            if (message.blocktop != null && message.hasOwnProperty(\"blocktop\"))\n                                if (typeof message.blocktop !== \"boolean\")\n                                    return \"blocktop: boolean expected\";\n                            if (message.blockscroll != null && message.hasOwnProperty(\"blockscroll\"))\n                                if (typeof message.blockscroll !== \"boolean\")\n                                    return \"blockscroll: boolean expected\";\n                            if (message.blockbottom != null && message.hasOwnProperty(\"blockbottom\"))\n                                if (typeof message.blockbottom !== \"boolean\")\n                                    return \"blockbottom: boolean expected\";\n                            if (message.blockcolor != null && message.hasOwnProperty(\"blockcolor\"))\n                                if (typeof message.blockcolor !== \"boolean\")\n                                    return \"blockcolor: boolean expected\";\n                            if (message.blockspecial != null && message.hasOwnProperty(\"blockspecial\"))\n                                if (typeof message.blockspecial !== \"boolean\")\n                                    return \"blockspecial: boolean expected\";\n                            if (message.preventshade != null && message.hasOwnProperty(\"preventshade\"))\n                                if (typeof message.preventshade !== \"boolean\")\n                                    return \"preventshade: boolean expected\";\n                            if (message.dmask != null && message.hasOwnProperty(\"dmask\"))\n                                if (typeof message.dmask !== \"boolean\")\n                                    return \"dmask: boolean expected\";\n                            if (message.opacity != null && message.hasOwnProperty(\"opacity\"))\n                                if (typeof message.opacity !== \"number\")\n                                    return \"opacity: number expected\";\n                            if (message.dmarea != null && message.hasOwnProperty(\"dmarea\"))\n                                if (!$util.isInteger(message.dmarea))\n                                    return \"dmarea: integer expected\";\n                            if (message.speedplus != null && message.hasOwnProperty(\"speedplus\"))\n                                if (typeof message.speedplus !== \"number\")\n                                    return \"speedplus: number expected\";\n                            if (message.fontsize != null && message.hasOwnProperty(\"fontsize\"))\n                                if (typeof message.fontsize !== \"number\")\n                                    return \"fontsize: number expected\";\n                            if (message.screensync != null && message.hasOwnProperty(\"screensync\"))\n                                if (typeof message.screensync !== \"boolean\")\n                                    return \"screensync: boolean expected\";\n                            if (message.speedsync != null && message.hasOwnProperty(\"speedsync\"))\n                                if (typeof message.speedsync !== \"boolean\")\n                                    return \"speedsync: boolean expected\";\n                            if (message.fontfamily != null && message.hasOwnProperty(\"fontfamily\"))\n                                if (!$util.isString(message.fontfamily))\n                                    return \"fontfamily: string expected\";\n                            if (message.bold != null && message.hasOwnProperty(\"bold\"))\n                                if (typeof message.bold !== \"boolean\")\n                                    return \"bold: boolean expected\";\n                            if (message.fontborder != null && message.hasOwnProperty(\"fontborder\"))\n                                if (!$util.isInteger(message.fontborder))\n                                    return \"fontborder: integer expected\";\n                            if (message.drawType != null && message.hasOwnProperty(\"drawType\"))\n                                if (!$util.isString(message.drawType))\n                                    return \"drawType: string expected\";\n                            if (message.seniorModeSwitch != null && message.hasOwnProperty(\"seniorModeSwitch\"))\n                                if (!$util.isInteger(message.seniorModeSwitch))\n                                    return \"seniorModeSwitch: integer expected\";\n                            if (message.aiLevelV2 != null && message.hasOwnProperty(\"aiLevelV2\"))\n                                if (!$util.isInteger(message.aiLevelV2))\n                                    return \"aiLevelV2: integer expected\";\n                            if (message.aiLevelV2Map != null && message.hasOwnProperty(\"aiLevelV2Map\")) {\n                                if (!$util.isObject(message.aiLevelV2Map))\n                                    return \"aiLevelV2Map: object expected\";\n                                var key = Object.keys(message.aiLevelV2Map);\n                                for (var i = 0; i < key.length; ++i) {\n                                    if (!$util.key32Re.test(key[i]))\n                                        return \"aiLevelV2Map: integer key{k:int32} expected\";\n                                    if (!$util.isInteger(message.aiLevelV2Map[key[i]]))\n                                        return \"aiLevelV2Map: integer{k:int32} expected\";\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DanmuWebPlayerConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DanmuWebPlayerConfig} DanmuWebPlayerConfig\n                         */\n                        DanmuWebPlayerConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig();\n                            if (object.dmSwitch != null)\n                                message.dmSwitch = Boolean(object.dmSwitch);\n                            if (object.aiSwitch != null)\n                                message.aiSwitch = Boolean(object.aiSwitch);\n                            if (object.aiLevel != null)\n                                message.aiLevel = object.aiLevel | 0;\n                            if (object.blocktop != null)\n                                message.blocktop = Boolean(object.blocktop);\n                            if (object.blockscroll != null)\n                                message.blockscroll = Boolean(object.blockscroll);\n                            if (object.blockbottom != null)\n                                message.blockbottom = Boolean(object.blockbottom);\n                            if (object.blockcolor != null)\n                                message.blockcolor = Boolean(object.blockcolor);\n                            if (object.blockspecial != null)\n                                message.blockspecial = Boolean(object.blockspecial);\n                            if (object.preventshade != null)\n                                message.preventshade = Boolean(object.preventshade);\n                            if (object.dmask != null)\n                                message.dmask = Boolean(object.dmask);\n                            if (object.opacity != null)\n                                message.opacity = Number(object.opacity);\n                            if (object.dmarea != null)\n                                message.dmarea = object.dmarea | 0;\n                            if (object.speedplus != null)\n                                message.speedplus = Number(object.speedplus);\n                            if (object.fontsize != null)\n                                message.fontsize = Number(object.fontsize);\n                            if (object.screensync != null)\n                                message.screensync = Boolean(object.screensync);\n                            if (object.speedsync != null)\n                                message.speedsync = Boolean(object.speedsync);\n                            if (object.fontfamily != null)\n                                message.fontfamily = String(object.fontfamily);\n                            if (object.bold != null)\n                                message.bold = Boolean(object.bold);\n                            if (object.fontborder != null)\n                                message.fontborder = object.fontborder | 0;\n                            if (object.drawType != null)\n                                message.drawType = String(object.drawType);\n                            if (object.seniorModeSwitch != null)\n                                message.seniorModeSwitch = object.seniorModeSwitch | 0;\n                            if (object.aiLevelV2 != null)\n                                message.aiLevelV2 = object.aiLevelV2 | 0;\n                            if (object.aiLevelV2Map) {\n                                if (typeof object.aiLevelV2Map !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DanmuWebPlayerConfig.aiLevelV2Map: object expected\");\n                                message.aiLevelV2Map = {};\n                                for (var keys = Object.keys(object.aiLevelV2Map), i = 0; i < keys.length; ++i)\n                                    message.aiLevelV2Map[keys[i]] = object.aiLevelV2Map[keys[i]] | 0;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DanmuWebPlayerConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DanmuWebPlayerConfig} message DanmuWebPlayerConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DanmuWebPlayerConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.objects || options.defaults)\n                                object.aiLevelV2Map = {};\n                            if (options.defaults) {\n                                object.dmSwitch = false;\n                                object.aiSwitch = false;\n                                object.aiLevel = 0;\n                                object.blocktop = false;\n                                object.blockscroll = false;\n                                object.blockbottom = false;\n                                object.blockcolor = false;\n                                object.blockspecial = false;\n                                object.preventshade = false;\n                                object.dmask = false;\n                                object.opacity = 0;\n                                object.dmarea = 0;\n                                object.speedplus = 0;\n                                object.fontsize = 0;\n                                object.screensync = false;\n                                object.speedsync = false;\n                                object.fontfamily = \"\";\n                                object.bold = false;\n                                object.fontborder = 0;\n                                object.drawType = \"\";\n                                object.seniorModeSwitch = 0;\n                                object.aiLevelV2 = 0;\n                            }\n                            if (message.dmSwitch != null && message.hasOwnProperty(\"dmSwitch\"))\n                                object.dmSwitch = message.dmSwitch;\n                            if (message.aiSwitch != null && message.hasOwnProperty(\"aiSwitch\"))\n                                object.aiSwitch = message.aiSwitch;\n                            if (message.aiLevel != null && message.hasOwnProperty(\"aiLevel\"))\n                                object.aiLevel = message.aiLevel;\n                            if (message.blocktop != null && message.hasOwnProperty(\"blocktop\"))\n                                object.blocktop = message.blocktop;\n                            if (message.blockscroll != null && message.hasOwnProperty(\"blockscroll\"))\n                                object.blockscroll = message.blockscroll;\n                            if (message.blockbottom != null && message.hasOwnProperty(\"blockbottom\"))\n                                object.blockbottom = message.blockbottom;\n                            if (message.blockcolor != null && message.hasOwnProperty(\"blockcolor\"))\n                                object.blockcolor = message.blockcolor;\n                            if (message.blockspecial != null && message.hasOwnProperty(\"blockspecial\"))\n                                object.blockspecial = message.blockspecial;\n                            if (message.preventshade != null && message.hasOwnProperty(\"preventshade\"))\n                                object.preventshade = message.preventshade;\n                            if (message.dmask != null && message.hasOwnProperty(\"dmask\"))\n                                object.dmask = message.dmask;\n                            if (message.opacity != null && message.hasOwnProperty(\"opacity\"))\n                                object.opacity = options.json && !isFinite(message.opacity) ? String(message.opacity) : message.opacity;\n                            if (message.dmarea != null && message.hasOwnProperty(\"dmarea\"))\n                                object.dmarea = message.dmarea;\n                            if (message.speedplus != null && message.hasOwnProperty(\"speedplus\"))\n                                object.speedplus = options.json && !isFinite(message.speedplus) ? String(message.speedplus) : message.speedplus;\n                            if (message.fontsize != null && message.hasOwnProperty(\"fontsize\"))\n                                object.fontsize = options.json && !isFinite(message.fontsize) ? String(message.fontsize) : message.fontsize;\n                            if (message.screensync != null && message.hasOwnProperty(\"screensync\"))\n                                object.screensync = message.screensync;\n                            if (message.speedsync != null && message.hasOwnProperty(\"speedsync\"))\n                                object.speedsync = message.speedsync;\n                            if (message.fontfamily != null && message.hasOwnProperty(\"fontfamily\"))\n                                object.fontfamily = message.fontfamily;\n                            if (message.bold != null && message.hasOwnProperty(\"bold\"))\n                                object.bold = message.bold;\n                            if (message.fontborder != null && message.hasOwnProperty(\"fontborder\"))\n                                object.fontborder = message.fontborder;\n                            if (message.drawType != null && message.hasOwnProperty(\"drawType\"))\n                                object.drawType = message.drawType;\n                            if (message.seniorModeSwitch != null && message.hasOwnProperty(\"seniorModeSwitch\"))\n                                object.seniorModeSwitch = message.seniorModeSwitch;\n                            if (message.aiLevelV2 != null && message.hasOwnProperty(\"aiLevelV2\"))\n                                object.aiLevelV2 = message.aiLevelV2;\n                            var keys2;\n                            if (message.aiLevelV2Map && (keys2 = Object.keys(message.aiLevelV2Map)).length) {\n                                object.aiLevelV2Map = {};\n                                for (var j = 0; j < keys2.length; ++j)\n                                    object.aiLevelV2Map[keys2[j]] = message.aiLevelV2Map[keys2[j]];\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DanmuWebPlayerConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DanmuWebPlayerConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DanmuWebPlayerConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DanmuWebPlayerConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DanmuWebPlayerConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DanmuWebPlayerConfig\";\n                        };\n\n                        return DanmuWebPlayerConfig;\n                    })();\n\n                    /**\n                     * DMAttrBit enum.\n                     * @name bilibili.community.service.dm.v1.DMAttrBit\n                     * @enum {number}\n                     * @property {number} DMAttrBitProtect=0 DMAttrBitProtect value\n                     * @property {number} DMAttrBitFromLive=1 DMAttrBitFromLive value\n                     * @property {number} DMAttrHighLike=2 DMAttrHighLike value\n                     */\n                    v1.DMAttrBit = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"DMAttrBitProtect\"] = 0;\n                        values[valuesById[1] = \"DMAttrBitFromLive\"] = 1;\n                        values[valuesById[2] = \"DMAttrHighLike\"] = 2;\n                        return values;\n                    })();\n\n                    v1.DmColorful = (function() {\n\n                        /**\n                         * Properties of a DmColorful.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmColorful\n                         * @property {bilibili.community.service.dm.v1.DmColorfulType|null} [type] DmColorful type\n                         * @property {string|null} [src] DmColorful src\n                         */\n\n                        /**\n                         * Constructs a new DmColorful.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmColorful.\n                         * @implements IDmColorful\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmColorful=} [properties] Properties to set\n                         */\n                        function DmColorful(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmColorful type.\n                         * @member {bilibili.community.service.dm.v1.DmColorfulType} type\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @instance\n                         */\n                        DmColorful.prototype.type = 0;\n\n                        /**\n                         * DmColorful src.\n                         * @member {string} src\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @instance\n                         */\n                        DmColorful.prototype.src = \"\";\n\n                        /**\n                         * Creates a new DmColorful instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmColorful=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmColorful} DmColorful instance\n                         */\n                        DmColorful.create = function create(properties) {\n                            return new DmColorful(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmColorful message. Does not implicitly {@link bilibili.community.service.dm.v1.DmColorful.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmColorful} message DmColorful message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmColorful.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.type);\n                            if (message.src != null && Object.hasOwnProperty.call(message, \"src\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.src);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmColorful message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmColorful.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmColorful} message DmColorful message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmColorful.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmColorful message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmColorful} DmColorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmColorful.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmColorful();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.src = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmColorful message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmColorful} DmColorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmColorful.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmColorful message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmColorful.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                switch (message.type) {\n                                default:\n                                    return \"type: enum value expected\";\n                                case 0:\n                                case 60001:\n                                    break;\n                                }\n                            if (message.src != null && message.hasOwnProperty(\"src\"))\n                                if (!$util.isString(message.src))\n                                    return \"src: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmColorful message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmColorful} DmColorful\n                         */\n                        DmColorful.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmColorful)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmColorful();\n                            switch (object.type) {\n                            default:\n                                if (typeof object.type === \"number\") {\n                                    message.type = object.type;\n                                    break;\n                                }\n                                break;\n                            case \"NoneType\":\n                            case 0:\n                                message.type = 0;\n                                break;\n                            case \"VipGradualColor\":\n                            case 60001:\n                                message.type = 60001;\n                                break;\n                            }\n                            if (object.src != null)\n                                message.src = String(object.src);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmColorful message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmColorful} message DmColorful\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmColorful.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.type = options.enums === String ? \"NoneType\" : 0;\n                                object.src = \"\";\n                            }\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = options.enums === String ? $root.bilibili.community.service.dm.v1.DmColorfulType[message.type] === undefined ? message.type : $root.bilibili.community.service.dm.v1.DmColorfulType[message.type] : message.type;\n                            if (message.src != null && message.hasOwnProperty(\"src\"))\n                                object.src = message.src;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmColorful to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmColorful.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmColorful\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmColorful\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmColorful.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmColorful\";\n                        };\n\n                        return DmColorful;\n                    })();\n\n                    /**\n                     * DmColorfulType enum.\n                     * @name bilibili.community.service.dm.v1.DmColorfulType\n                     * @enum {number}\n                     * @property {number} NoneType=0 NoneType value\n                     * @property {number} VipGradualColor=60001 VipGradualColor value\n                     */\n                    v1.DmColorfulType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"NoneType\"] = 0;\n                        values[valuesById[60001] = \"VipGradualColor\"] = 60001;\n                        return values;\n                    })();\n\n                    v1.DmExpoReportReq = (function() {\n\n                        /**\n                         * Properties of a DmExpoReportReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmExpoReportReq\n                         * @property {string|null} [sessionId] DmExpoReportReq sessionId\n                         * @property {number|Long|null} [oid] DmExpoReportReq oid\n                         * @property {string|null} [spmid] DmExpoReportReq spmid\n                         */\n\n                        /**\n                         * Constructs a new DmExpoReportReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmExpoReportReq.\n                         * @implements IDmExpoReportReq\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportReq=} [properties] Properties to set\n                         */\n                        function DmExpoReportReq(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmExpoReportReq sessionId.\n                         * @member {string} sessionId\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @instance\n                         */\n                        DmExpoReportReq.prototype.sessionId = \"\";\n\n                        /**\n                         * DmExpoReportReq oid.\n                         * @member {number|Long} oid\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @instance\n                         */\n                        DmExpoReportReq.prototype.oid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmExpoReportReq spmid.\n                         * @member {string} spmid\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @instance\n                         */\n                        DmExpoReportReq.prototype.spmid = \"\";\n\n                        /**\n                         * Creates a new DmExpoReportReq instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportReq=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportReq} DmExpoReportReq instance\n                         */\n                        DmExpoReportReq.create = function create(properties) {\n                            return new DmExpoReportReq(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmExpoReportReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportReq.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportReq} message DmExpoReportReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmExpoReportReq.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.sessionId != null && Object.hasOwnProperty.call(message, \"sessionId\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.sessionId);\n                            if (message.oid != null && Object.hasOwnProperty.call(message, \"oid\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.oid);\n                            if (message.spmid != null && Object.hasOwnProperty.call(message, \"spmid\"))\n                                writer.uint32(/* id 4, wireType 2 =*/34).string(message.spmid);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmExpoReportReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportReq.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportReq} message DmExpoReportReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmExpoReportReq.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmExpoReportReq message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportReq} DmExpoReportReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmExpoReportReq.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmExpoReportReq();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.sessionId = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.oid = reader.int64();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.spmid = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmExpoReportReq message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportReq} DmExpoReportReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmExpoReportReq.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmExpoReportReq message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmExpoReportReq.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.sessionId != null && message.hasOwnProperty(\"sessionId\"))\n                                if (!$util.isString(message.sessionId))\n                                    return \"sessionId: string expected\";\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (!$util.isInteger(message.oid) && !(message.oid && $util.isInteger(message.oid.low) && $util.isInteger(message.oid.high)))\n                                    return \"oid: integer|Long expected\";\n                            if (message.spmid != null && message.hasOwnProperty(\"spmid\"))\n                                if (!$util.isString(message.spmid))\n                                    return \"spmid: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmExpoReportReq message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportReq} DmExpoReportReq\n                         */\n                        DmExpoReportReq.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmExpoReportReq)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmExpoReportReq();\n                            if (object.sessionId != null)\n                                message.sessionId = String(object.sessionId);\n                            if (object.oid != null)\n                                if ($util.Long)\n                                    (message.oid = $util.Long.fromValue(object.oid)).unsigned = false;\n                                else if (typeof object.oid === \"string\")\n                                    message.oid = parseInt(object.oid, 10);\n                                else if (typeof object.oid === \"number\")\n                                    message.oid = object.oid;\n                                else if (typeof object.oid === \"object\")\n                                    message.oid = new $util.LongBits(object.oid.low >>> 0, object.oid.high >>> 0).toNumber();\n                            if (object.spmid != null)\n                                message.spmid = String(object.spmid);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmExpoReportReq message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmExpoReportReq} message DmExpoReportReq\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmExpoReportReq.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.sessionId = \"\";\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.oid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.oid = options.longs === String ? \"0\" : 0;\n                                object.spmid = \"\";\n                            }\n                            if (message.sessionId != null && message.hasOwnProperty(\"sessionId\"))\n                                object.sessionId = message.sessionId;\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (typeof message.oid === \"number\")\n                                    object.oid = options.longs === String ? String(message.oid) : message.oid;\n                                else\n                                    object.oid = options.longs === String ? $util.Long.prototype.toString.call(message.oid) : options.longs === Number ? new $util.LongBits(message.oid.low >>> 0, message.oid.high >>> 0).toNumber() : message.oid;\n                            if (message.spmid != null && message.hasOwnProperty(\"spmid\"))\n                                object.spmid = message.spmid;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmExpoReportReq to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmExpoReportReq.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmExpoReportReq\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportReq\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmExpoReportReq.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmExpoReportReq\";\n                        };\n\n                        return DmExpoReportReq;\n                    })();\n\n                    v1.DmExpoReportRes = (function() {\n\n                        /**\n                         * Properties of a DmExpoReportRes.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmExpoReportRes\n                         */\n\n                        /**\n                         * Constructs a new DmExpoReportRes.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmExpoReportRes.\n                         * @implements IDmExpoReportRes\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportRes=} [properties] Properties to set\n                         */\n                        function DmExpoReportRes(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Creates a new DmExpoReportRes instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportRes=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportRes} DmExpoReportRes instance\n                         */\n                        DmExpoReportRes.create = function create(properties) {\n                            return new DmExpoReportRes(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmExpoReportRes message. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportRes.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportRes} message DmExpoReportRes message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmExpoReportRes.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmExpoReportRes message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmExpoReportRes.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmExpoReportRes} message DmExpoReportRes message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmExpoReportRes.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmExpoReportRes message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportRes} DmExpoReportRes\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmExpoReportRes.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmExpoReportRes();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmExpoReportRes message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportRes} DmExpoReportRes\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmExpoReportRes.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmExpoReportRes message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmExpoReportRes.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmExpoReportRes message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmExpoReportRes} DmExpoReportRes\n                         */\n                        DmExpoReportRes.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmExpoReportRes)\n                                return object;\n                            return new $root.bilibili.community.service.dm.v1.DmExpoReportRes();\n                        };\n\n                        /**\n                         * Creates a plain object from a DmExpoReportRes message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmExpoReportRes} message DmExpoReportRes\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmExpoReportRes.toObject = function toObject() {\n                            return {};\n                        };\n\n                        /**\n                         * Converts this DmExpoReportRes to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmExpoReportRes.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmExpoReportRes\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmExpoReportRes\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmExpoReportRes.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmExpoReportRes\";\n                        };\n\n                        return DmExpoReportRes;\n                    })();\n\n                    v1.DmPlayerConfigReq = (function() {\n\n                        /**\n                         * Properties of a DmPlayerConfigReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmPlayerConfigReq\n                         * @property {number|Long|null} [ts] DmPlayerConfigReq ts\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuSwitch|null} [\"switch\"] DmPlayerConfigReq switch\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave|null} [switchSave] DmPlayerConfigReq switchSave\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig|null} [useDefaultConfig] DmPlayerConfigReq useDefaultConfig\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch|null} [aiRecommendedSwitch] DmPlayerConfigReq aiRecommendedSwitch\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel|null} [aiRecommendedLevel] DmPlayerConfigReq aiRecommendedLevel\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop|null} [blocktop] DmPlayerConfigReq blocktop\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll|null} [blockscroll] DmPlayerConfigReq blockscroll\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom|null} [blockbottom] DmPlayerConfigReq blockbottom\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful|null} [blockcolorful] DmPlayerConfigReq blockcolorful\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat|null} [blockrepeat] DmPlayerConfigReq blockrepeat\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial|null} [blockspecial] DmPlayerConfigReq blockspecial\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuOpacity|null} [opacity] DmPlayerConfigReq opacity\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor|null} [scalingfactor] DmPlayerConfigReq scalingfactor\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuDomain|null} [domain] DmPlayerConfigReq domain\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuSpeed|null} [speed] DmPlayerConfigReq speed\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist|null} [enableblocklist] DmPlayerConfigReq enableblocklist\n                         * @property {bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch|null} [inlinePlayerDanmakuSwitch] DmPlayerConfigReq inlinePlayerDanmakuSwitch\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch|null} [seniorModeSwitch] DmPlayerConfigReq seniorModeSwitch\n                         * @property {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2|null} [aiRecommendedLevelV2] DmPlayerConfigReq aiRecommendedLevelV2\n                         */\n\n                        /**\n                         * Constructs a new DmPlayerConfigReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmPlayerConfigReq.\n                         * @implements IDmPlayerConfigReq\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmPlayerConfigReq=} [properties] Properties to set\n                         */\n                        function DmPlayerConfigReq(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmPlayerConfigReq ts.\n                         * @member {number|Long} ts\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.ts = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmPlayerConfigReq switch.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuSwitch|null|undefined} switch\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype[\"switch\"] = null;\n\n                        /**\n                         * DmPlayerConfigReq switchSave.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave|null|undefined} switchSave\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.switchSave = null;\n\n                        /**\n                         * DmPlayerConfigReq useDefaultConfig.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig|null|undefined} useDefaultConfig\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.useDefaultConfig = null;\n\n                        /**\n                         * DmPlayerConfigReq aiRecommendedSwitch.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch|null|undefined} aiRecommendedSwitch\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.aiRecommendedSwitch = null;\n\n                        /**\n                         * DmPlayerConfigReq aiRecommendedLevel.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel|null|undefined} aiRecommendedLevel\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.aiRecommendedLevel = null;\n\n                        /**\n                         * DmPlayerConfigReq blocktop.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop|null|undefined} blocktop\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.blocktop = null;\n\n                        /**\n                         * DmPlayerConfigReq blockscroll.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll|null|undefined} blockscroll\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.blockscroll = null;\n\n                        /**\n                         * DmPlayerConfigReq blockbottom.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom|null|undefined} blockbottom\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.blockbottom = null;\n\n                        /**\n                         * DmPlayerConfigReq blockcolorful.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful|null|undefined} blockcolorful\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.blockcolorful = null;\n\n                        /**\n                         * DmPlayerConfigReq blockrepeat.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat|null|undefined} blockrepeat\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.blockrepeat = null;\n\n                        /**\n                         * DmPlayerConfigReq blockspecial.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial|null|undefined} blockspecial\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.blockspecial = null;\n\n                        /**\n                         * DmPlayerConfigReq opacity.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuOpacity|null|undefined} opacity\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.opacity = null;\n\n                        /**\n                         * DmPlayerConfigReq scalingfactor.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor|null|undefined} scalingfactor\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.scalingfactor = null;\n\n                        /**\n                         * DmPlayerConfigReq domain.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuDomain|null|undefined} domain\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.domain = null;\n\n                        /**\n                         * DmPlayerConfigReq speed.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuSpeed|null|undefined} speed\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.speed = null;\n\n                        /**\n                         * DmPlayerConfigReq enableblocklist.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist|null|undefined} enableblocklist\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.enableblocklist = null;\n\n                        /**\n                         * DmPlayerConfigReq inlinePlayerDanmakuSwitch.\n                         * @member {bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch|null|undefined} inlinePlayerDanmakuSwitch\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.inlinePlayerDanmakuSwitch = null;\n\n                        /**\n                         * DmPlayerConfigReq seniorModeSwitch.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch|null|undefined} seniorModeSwitch\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.seniorModeSwitch = null;\n\n                        /**\n                         * DmPlayerConfigReq aiRecommendedLevelV2.\n                         * @member {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2|null|undefined} aiRecommendedLevelV2\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         */\n                        DmPlayerConfigReq.prototype.aiRecommendedLevelV2 = null;\n\n                        /**\n                         * Creates a new DmPlayerConfigReq instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmPlayerConfigReq=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmPlayerConfigReq} DmPlayerConfigReq instance\n                         */\n                        DmPlayerConfigReq.create = function create(properties) {\n                            return new DmPlayerConfigReq(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmPlayerConfigReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmPlayerConfigReq.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmPlayerConfigReq} message DmPlayerConfigReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmPlayerConfigReq.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.ts != null && Object.hasOwnProperty.call(message, \"ts\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.ts);\n                            if (message[\"switch\"] != null && Object.hasOwnProperty.call(message, \"switch\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch.encode(message[\"switch\"], writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim();\n                            if (message.switchSave != null && Object.hasOwnProperty.call(message, \"switchSave\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.encode(message.switchSave, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            if (message.useDefaultConfig != null && Object.hasOwnProperty.call(message, \"useDefaultConfig\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.encode(message.useDefaultConfig, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim();\n                            if (message.aiRecommendedSwitch != null && Object.hasOwnProperty.call(message, \"aiRecommendedSwitch\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.encode(message.aiRecommendedSwitch, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim();\n                            if (message.aiRecommendedLevel != null && Object.hasOwnProperty.call(message, \"aiRecommendedLevel\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.encode(message.aiRecommendedLevel, writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim();\n                            if (message.blocktop != null && Object.hasOwnProperty.call(message, \"blocktop\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.encode(message.blocktop, writer.uint32(/* id 7, wireType 2 =*/58).fork()).ldelim();\n                            if (message.blockscroll != null && Object.hasOwnProperty.call(message, \"blockscroll\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.encode(message.blockscroll, writer.uint32(/* id 8, wireType 2 =*/66).fork()).ldelim();\n                            if (message.blockbottom != null && Object.hasOwnProperty.call(message, \"blockbottom\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.encode(message.blockbottom, writer.uint32(/* id 9, wireType 2 =*/74).fork()).ldelim();\n                            if (message.blockcolorful != null && Object.hasOwnProperty.call(message, \"blockcolorful\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.encode(message.blockcolorful, writer.uint32(/* id 10, wireType 2 =*/82).fork()).ldelim();\n                            if (message.blockrepeat != null && Object.hasOwnProperty.call(message, \"blockrepeat\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.encode(message.blockrepeat, writer.uint32(/* id 11, wireType 2 =*/90).fork()).ldelim();\n                            if (message.blockspecial != null && Object.hasOwnProperty.call(message, \"blockspecial\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.encode(message.blockspecial, writer.uint32(/* id 12, wireType 2 =*/98).fork()).ldelim();\n                            if (message.opacity != null && Object.hasOwnProperty.call(message, \"opacity\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity.encode(message.opacity, writer.uint32(/* id 13, wireType 2 =*/106).fork()).ldelim();\n                            if (message.scalingfactor != null && Object.hasOwnProperty.call(message, \"scalingfactor\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.encode(message.scalingfactor, writer.uint32(/* id 14, wireType 2 =*/114).fork()).ldelim();\n                            if (message.domain != null && Object.hasOwnProperty.call(message, \"domain\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain.encode(message.domain, writer.uint32(/* id 15, wireType 2 =*/122).fork()).ldelim();\n                            if (message.speed != null && Object.hasOwnProperty.call(message, \"speed\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed.encode(message.speed, writer.uint32(/* id 16, wireType 2 =*/130).fork()).ldelim();\n                            if (message.enableblocklist != null && Object.hasOwnProperty.call(message, \"enableblocklist\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.encode(message.enableblocklist, writer.uint32(/* id 17, wireType 2 =*/138).fork()).ldelim();\n                            if (message.inlinePlayerDanmakuSwitch != null && Object.hasOwnProperty.call(message, \"inlinePlayerDanmakuSwitch\"))\n                                $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.encode(message.inlinePlayerDanmakuSwitch, writer.uint32(/* id 18, wireType 2 =*/146).fork()).ldelim();\n                            if (message.seniorModeSwitch != null && Object.hasOwnProperty.call(message, \"seniorModeSwitch\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.encode(message.seniorModeSwitch, writer.uint32(/* id 19, wireType 2 =*/154).fork()).ldelim();\n                            if (message.aiRecommendedLevelV2 != null && Object.hasOwnProperty.call(message, \"aiRecommendedLevelV2\"))\n                                $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.encode(message.aiRecommendedLevelV2, writer.uint32(/* id 20, wireType 2 =*/162).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmPlayerConfigReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmPlayerConfigReq.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmPlayerConfigReq} message DmPlayerConfigReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmPlayerConfigReq.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmPlayerConfigReq message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmPlayerConfigReq} DmPlayerConfigReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmPlayerConfigReq.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmPlayerConfigReq();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.ts = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message[\"switch\"] = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 3: {\n                                        message.switchSave = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 4: {\n                                        message.useDefaultConfig = $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 5: {\n                                        message.aiRecommendedSwitch = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 6: {\n                                        message.aiRecommendedLevel = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 7: {\n                                        message.blocktop = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 8: {\n                                        message.blockscroll = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 9: {\n                                        message.blockbottom = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 10: {\n                                        message.blockcolorful = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 11: {\n                                        message.blockrepeat = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 12: {\n                                        message.blockspecial = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 13: {\n                                        message.opacity = $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 14: {\n                                        message.scalingfactor = $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 15: {\n                                        message.domain = $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 16: {\n                                        message.speed = $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 17: {\n                                        message.enableblocklist = $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 18: {\n                                        message.inlinePlayerDanmakuSwitch = $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 19: {\n                                        message.seniorModeSwitch = $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 20: {\n                                        message.aiRecommendedLevelV2 = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmPlayerConfigReq message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmPlayerConfigReq} DmPlayerConfigReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmPlayerConfigReq.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmPlayerConfigReq message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmPlayerConfigReq.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.ts != null && message.hasOwnProperty(\"ts\"))\n                                if (!$util.isInteger(message.ts) && !(message.ts && $util.isInteger(message.ts.low) && $util.isInteger(message.ts.high)))\n                                    return \"ts: integer|Long expected\";\n                            if (message[\"switch\"] != null && message.hasOwnProperty(\"switch\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch.verify(message[\"switch\"]);\n                                if (error)\n                                    return \"switch.\" + error;\n                            }\n                            if (message.switchSave != null && message.hasOwnProperty(\"switchSave\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.verify(message.switchSave);\n                                if (error)\n                                    return \"switchSave.\" + error;\n                            }\n                            if (message.useDefaultConfig != null && message.hasOwnProperty(\"useDefaultConfig\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.verify(message.useDefaultConfig);\n                                if (error)\n                                    return \"useDefaultConfig.\" + error;\n                            }\n                            if (message.aiRecommendedSwitch != null && message.hasOwnProperty(\"aiRecommendedSwitch\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.verify(message.aiRecommendedSwitch);\n                                if (error)\n                                    return \"aiRecommendedSwitch.\" + error;\n                            }\n                            if (message.aiRecommendedLevel != null && message.hasOwnProperty(\"aiRecommendedLevel\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.verify(message.aiRecommendedLevel);\n                                if (error)\n                                    return \"aiRecommendedLevel.\" + error;\n                            }\n                            if (message.blocktop != null && message.hasOwnProperty(\"blocktop\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.verify(message.blocktop);\n                                if (error)\n                                    return \"blocktop.\" + error;\n                            }\n                            if (message.blockscroll != null && message.hasOwnProperty(\"blockscroll\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.verify(message.blockscroll);\n                                if (error)\n                                    return \"blockscroll.\" + error;\n                            }\n                            if (message.blockbottom != null && message.hasOwnProperty(\"blockbottom\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.verify(message.blockbottom);\n                                if (error)\n                                    return \"blockbottom.\" + error;\n                            }\n                            if (message.blockcolorful != null && message.hasOwnProperty(\"blockcolorful\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.verify(message.blockcolorful);\n                                if (error)\n                                    return \"blockcolorful.\" + error;\n                            }\n                            if (message.blockrepeat != null && message.hasOwnProperty(\"blockrepeat\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.verify(message.blockrepeat);\n                                if (error)\n                                    return \"blockrepeat.\" + error;\n                            }\n                            if (message.blockspecial != null && message.hasOwnProperty(\"blockspecial\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.verify(message.blockspecial);\n                                if (error)\n                                    return \"blockspecial.\" + error;\n                            }\n                            if (message.opacity != null && message.hasOwnProperty(\"opacity\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity.verify(message.opacity);\n                                if (error)\n                                    return \"opacity.\" + error;\n                            }\n                            if (message.scalingfactor != null && message.hasOwnProperty(\"scalingfactor\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.verify(message.scalingfactor);\n                                if (error)\n                                    return \"scalingfactor.\" + error;\n                            }\n                            if (message.domain != null && message.hasOwnProperty(\"domain\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain.verify(message.domain);\n                                if (error)\n                                    return \"domain.\" + error;\n                            }\n                            if (message.speed != null && message.hasOwnProperty(\"speed\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed.verify(message.speed);\n                                if (error)\n                                    return \"speed.\" + error;\n                            }\n                            if (message.enableblocklist != null && message.hasOwnProperty(\"enableblocklist\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.verify(message.enableblocklist);\n                                if (error)\n                                    return \"enableblocklist.\" + error;\n                            }\n                            if (message.inlinePlayerDanmakuSwitch != null && message.hasOwnProperty(\"inlinePlayerDanmakuSwitch\")) {\n                                var error = $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.verify(message.inlinePlayerDanmakuSwitch);\n                                if (error)\n                                    return \"inlinePlayerDanmakuSwitch.\" + error;\n                            }\n                            if (message.seniorModeSwitch != null && message.hasOwnProperty(\"seniorModeSwitch\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.verify(message.seniorModeSwitch);\n                                if (error)\n                                    return \"seniorModeSwitch.\" + error;\n                            }\n                            if (message.aiRecommendedLevelV2 != null && message.hasOwnProperty(\"aiRecommendedLevelV2\")) {\n                                var error = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.verify(message.aiRecommendedLevelV2);\n                                if (error)\n                                    return \"aiRecommendedLevelV2.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmPlayerConfigReq message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmPlayerConfigReq} DmPlayerConfigReq\n                         */\n                        DmPlayerConfigReq.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmPlayerConfigReq)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmPlayerConfigReq();\n                            if (object.ts != null)\n                                if ($util.Long)\n                                    (message.ts = $util.Long.fromValue(object.ts)).unsigned = false;\n                                else if (typeof object.ts === \"string\")\n                                    message.ts = parseInt(object.ts, 10);\n                                else if (typeof object.ts === \"number\")\n                                    message.ts = object.ts;\n                                else if (typeof object.ts === \"object\")\n                                    message.ts = new $util.LongBits(object.ts.low >>> 0, object.ts.high >>> 0).toNumber();\n                            if (object[\"switch\"] != null) {\n                                if (typeof object[\"switch\"] !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.switch: object expected\");\n                                message[\"switch\"] = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch.fromObject(object[\"switch\"]);\n                            }\n                            if (object.switchSave != null) {\n                                if (typeof object.switchSave !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.switchSave: object expected\");\n                                message.switchSave = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.fromObject(object.switchSave);\n                            }\n                            if (object.useDefaultConfig != null) {\n                                if (typeof object.useDefaultConfig !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.useDefaultConfig: object expected\");\n                                message.useDefaultConfig = $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.fromObject(object.useDefaultConfig);\n                            }\n                            if (object.aiRecommendedSwitch != null) {\n                                if (typeof object.aiRecommendedSwitch !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.aiRecommendedSwitch: object expected\");\n                                message.aiRecommendedSwitch = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.fromObject(object.aiRecommendedSwitch);\n                            }\n                            if (object.aiRecommendedLevel != null) {\n                                if (typeof object.aiRecommendedLevel !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.aiRecommendedLevel: object expected\");\n                                message.aiRecommendedLevel = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.fromObject(object.aiRecommendedLevel);\n                            }\n                            if (object.blocktop != null) {\n                                if (typeof object.blocktop !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.blocktop: object expected\");\n                                message.blocktop = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.fromObject(object.blocktop);\n                            }\n                            if (object.blockscroll != null) {\n                                if (typeof object.blockscroll !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.blockscroll: object expected\");\n                                message.blockscroll = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.fromObject(object.blockscroll);\n                            }\n                            if (object.blockbottom != null) {\n                                if (typeof object.blockbottom !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.blockbottom: object expected\");\n                                message.blockbottom = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.fromObject(object.blockbottom);\n                            }\n                            if (object.blockcolorful != null) {\n                                if (typeof object.blockcolorful !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.blockcolorful: object expected\");\n                                message.blockcolorful = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.fromObject(object.blockcolorful);\n                            }\n                            if (object.blockrepeat != null) {\n                                if (typeof object.blockrepeat !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.blockrepeat: object expected\");\n                                message.blockrepeat = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.fromObject(object.blockrepeat);\n                            }\n                            if (object.blockspecial != null) {\n                                if (typeof object.blockspecial !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.blockspecial: object expected\");\n                                message.blockspecial = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.fromObject(object.blockspecial);\n                            }\n                            if (object.opacity != null) {\n                                if (typeof object.opacity !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.opacity: object expected\");\n                                message.opacity = $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity.fromObject(object.opacity);\n                            }\n                            if (object.scalingfactor != null) {\n                                if (typeof object.scalingfactor !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.scalingfactor: object expected\");\n                                message.scalingfactor = $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.fromObject(object.scalingfactor);\n                            }\n                            if (object.domain != null) {\n                                if (typeof object.domain !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.domain: object expected\");\n                                message.domain = $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain.fromObject(object.domain);\n                            }\n                            if (object.speed != null) {\n                                if (typeof object.speed !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.speed: object expected\");\n                                message.speed = $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed.fromObject(object.speed);\n                            }\n                            if (object.enableblocklist != null) {\n                                if (typeof object.enableblocklist !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.enableblocklist: object expected\");\n                                message.enableblocklist = $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.fromObject(object.enableblocklist);\n                            }\n                            if (object.inlinePlayerDanmakuSwitch != null) {\n                                if (typeof object.inlinePlayerDanmakuSwitch !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.inlinePlayerDanmakuSwitch: object expected\");\n                                message.inlinePlayerDanmakuSwitch = $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.fromObject(object.inlinePlayerDanmakuSwitch);\n                            }\n                            if (object.seniorModeSwitch != null) {\n                                if (typeof object.seniorModeSwitch !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.seniorModeSwitch: object expected\");\n                                message.seniorModeSwitch = $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.fromObject(object.seniorModeSwitch);\n                            }\n                            if (object.aiRecommendedLevelV2 != null) {\n                                if (typeof object.aiRecommendedLevelV2 !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmPlayerConfigReq.aiRecommendedLevelV2: object expected\");\n                                message.aiRecommendedLevelV2 = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.fromObject(object.aiRecommendedLevelV2);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmPlayerConfigReq message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmPlayerConfigReq} message DmPlayerConfigReq\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmPlayerConfigReq.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.ts = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.ts = options.longs === String ? \"0\" : 0;\n                                object[\"switch\"] = null;\n                                object.switchSave = null;\n                                object.useDefaultConfig = null;\n                                object.aiRecommendedSwitch = null;\n                                object.aiRecommendedLevel = null;\n                                object.blocktop = null;\n                                object.blockscroll = null;\n                                object.blockbottom = null;\n                                object.blockcolorful = null;\n                                object.blockrepeat = null;\n                                object.blockspecial = null;\n                                object.opacity = null;\n                                object.scalingfactor = null;\n                                object.domain = null;\n                                object.speed = null;\n                                object.enableblocklist = null;\n                                object.inlinePlayerDanmakuSwitch = null;\n                                object.seniorModeSwitch = null;\n                                object.aiRecommendedLevelV2 = null;\n                            }\n                            if (message.ts != null && message.hasOwnProperty(\"ts\"))\n                                if (typeof message.ts === \"number\")\n                                    object.ts = options.longs === String ? String(message.ts) : message.ts;\n                                else\n                                    object.ts = options.longs === String ? $util.Long.prototype.toString.call(message.ts) : options.longs === Number ? new $util.LongBits(message.ts.low >>> 0, message.ts.high >>> 0).toNumber() : message.ts;\n                            if (message[\"switch\"] != null && message.hasOwnProperty(\"switch\"))\n                                object[\"switch\"] = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch.toObject(message[\"switch\"], options);\n                            if (message.switchSave != null && message.hasOwnProperty(\"switchSave\"))\n                                object.switchSave = $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.toObject(message.switchSave, options);\n                            if (message.useDefaultConfig != null && message.hasOwnProperty(\"useDefaultConfig\"))\n                                object.useDefaultConfig = $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.toObject(message.useDefaultConfig, options);\n                            if (message.aiRecommendedSwitch != null && message.hasOwnProperty(\"aiRecommendedSwitch\"))\n                                object.aiRecommendedSwitch = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.toObject(message.aiRecommendedSwitch, options);\n                            if (message.aiRecommendedLevel != null && message.hasOwnProperty(\"aiRecommendedLevel\"))\n                                object.aiRecommendedLevel = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.toObject(message.aiRecommendedLevel, options);\n                            if (message.blocktop != null && message.hasOwnProperty(\"blocktop\"))\n                                object.blocktop = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.toObject(message.blocktop, options);\n                            if (message.blockscroll != null && message.hasOwnProperty(\"blockscroll\"))\n                                object.blockscroll = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.toObject(message.blockscroll, options);\n                            if (message.blockbottom != null && message.hasOwnProperty(\"blockbottom\"))\n                                object.blockbottom = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.toObject(message.blockbottom, options);\n                            if (message.blockcolorful != null && message.hasOwnProperty(\"blockcolorful\"))\n                                object.blockcolorful = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.toObject(message.blockcolorful, options);\n                            if (message.blockrepeat != null && message.hasOwnProperty(\"blockrepeat\"))\n                                object.blockrepeat = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.toObject(message.blockrepeat, options);\n                            if (message.blockspecial != null && message.hasOwnProperty(\"blockspecial\"))\n                                object.blockspecial = $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.toObject(message.blockspecial, options);\n                            if (message.opacity != null && message.hasOwnProperty(\"opacity\"))\n                                object.opacity = $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity.toObject(message.opacity, options);\n                            if (message.scalingfactor != null && message.hasOwnProperty(\"scalingfactor\"))\n                                object.scalingfactor = $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.toObject(message.scalingfactor, options);\n                            if (message.domain != null && message.hasOwnProperty(\"domain\"))\n                                object.domain = $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain.toObject(message.domain, options);\n                            if (message.speed != null && message.hasOwnProperty(\"speed\"))\n                                object.speed = $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed.toObject(message.speed, options);\n                            if (message.enableblocklist != null && message.hasOwnProperty(\"enableblocklist\"))\n                                object.enableblocklist = $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.toObject(message.enableblocklist, options);\n                            if (message.inlinePlayerDanmakuSwitch != null && message.hasOwnProperty(\"inlinePlayerDanmakuSwitch\"))\n                                object.inlinePlayerDanmakuSwitch = $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.toObject(message.inlinePlayerDanmakuSwitch, options);\n                            if (message.seniorModeSwitch != null && message.hasOwnProperty(\"seniorModeSwitch\"))\n                                object.seniorModeSwitch = $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.toObject(message.seniorModeSwitch, options);\n                            if (message.aiRecommendedLevelV2 != null && message.hasOwnProperty(\"aiRecommendedLevelV2\"))\n                                object.aiRecommendedLevelV2 = $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.toObject(message.aiRecommendedLevelV2, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmPlayerConfigReq to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmPlayerConfigReq.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmPlayerConfigReq\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmPlayerConfigReq\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmPlayerConfigReq.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmPlayerConfigReq\";\n                        };\n\n                        return DmPlayerConfigReq;\n                    })();\n\n                    v1.DmSegConfig = (function() {\n\n                        /**\n                         * Properties of a DmSegConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegConfig\n                         * @property {number|Long|null} [pageSize] DmSegConfig pageSize\n                         * @property {number|Long|null} [total] DmSegConfig total\n                         */\n\n                        /**\n                         * Constructs a new DmSegConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegConfig.\n                         * @implements IDmSegConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegConfig=} [properties] Properties to set\n                         */\n                        function DmSegConfig(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegConfig pageSize.\n                         * @member {number|Long} pageSize\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @instance\n                         */\n                        DmSegConfig.prototype.pageSize = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegConfig total.\n                         * @member {number|Long} total\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @instance\n                         */\n                        DmSegConfig.prototype.total = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * Creates a new DmSegConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegConfig} DmSegConfig instance\n                         */\n                        DmSegConfig.create = function create(properties) {\n                            return new DmSegConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegConfig} message DmSegConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.pageSize != null && Object.hasOwnProperty.call(message, \"pageSize\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.pageSize);\n                            if (message.total != null && Object.hasOwnProperty.call(message, \"total\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.total);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegConfig} message DmSegConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegConfig} DmSegConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.pageSize = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.total = reader.int64();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegConfig} DmSegConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.pageSize != null && message.hasOwnProperty(\"pageSize\"))\n                                if (!$util.isInteger(message.pageSize) && !(message.pageSize && $util.isInteger(message.pageSize.low) && $util.isInteger(message.pageSize.high)))\n                                    return \"pageSize: integer|Long expected\";\n                            if (message.total != null && message.hasOwnProperty(\"total\"))\n                                if (!$util.isInteger(message.total) && !(message.total && $util.isInteger(message.total.low) && $util.isInteger(message.total.high)))\n                                    return \"total: integer|Long expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegConfig} DmSegConfig\n                         */\n                        DmSegConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegConfig();\n                            if (object.pageSize != null)\n                                if ($util.Long)\n                                    (message.pageSize = $util.Long.fromValue(object.pageSize)).unsigned = false;\n                                else if (typeof object.pageSize === \"string\")\n                                    message.pageSize = parseInt(object.pageSize, 10);\n                                else if (typeof object.pageSize === \"number\")\n                                    message.pageSize = object.pageSize;\n                                else if (typeof object.pageSize === \"object\")\n                                    message.pageSize = new $util.LongBits(object.pageSize.low >>> 0, object.pageSize.high >>> 0).toNumber();\n                            if (object.total != null)\n                                if ($util.Long)\n                                    (message.total = $util.Long.fromValue(object.total)).unsigned = false;\n                                else if (typeof object.total === \"string\")\n                                    message.total = parseInt(object.total, 10);\n                                else if (typeof object.total === \"number\")\n                                    message.total = object.total;\n                                else if (typeof object.total === \"object\")\n                                    message.total = new $util.LongBits(object.total.low >>> 0, object.total.high >>> 0).toNumber();\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegConfig} message DmSegConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.pageSize = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.pageSize = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.total = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.total = options.longs === String ? \"0\" : 0;\n                            }\n                            if (message.pageSize != null && message.hasOwnProperty(\"pageSize\"))\n                                if (typeof message.pageSize === \"number\")\n                                    object.pageSize = options.longs === String ? String(message.pageSize) : message.pageSize;\n                                else\n                                    object.pageSize = options.longs === String ? $util.Long.prototype.toString.call(message.pageSize) : options.longs === Number ? new $util.LongBits(message.pageSize.low >>> 0, message.pageSize.high >>> 0).toNumber() : message.pageSize;\n                            if (message.total != null && message.hasOwnProperty(\"total\"))\n                                if (typeof message.total === \"number\")\n                                    object.total = options.longs === String ? String(message.total) : message.total;\n                                else\n                                    object.total = options.longs === String ? $util.Long.prototype.toString.call(message.total) : options.longs === Number ? new $util.LongBits(message.total.low >>> 0, message.total.high >>> 0).toNumber() : message.total;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegConfig\";\n                        };\n\n                        return DmSegConfig;\n                    })();\n\n                    v1.DmSegMobileReply = (function() {\n\n                        /**\n                         * Properties of a DmSegMobileReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegMobileReply\n                         * @property {Array.<bilibili.community.service.dm.v1.IDanmakuElem>|null} [elems] DmSegMobileReply elems\n                         * @property {number|null} [state] DmSegMobileReply state\n                         * @property {bilibili.community.service.dm.v1.IDanmakuAIFlag|null} [aiFlag] DmSegMobileReply aiFlag\n                         * @property {Array.<bilibili.community.service.dm.v1.IDmColorful>|null} [colorfulSrc] DmSegMobileReply colorfulSrc\n                         */\n\n                        /**\n                         * Constructs a new DmSegMobileReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegMobileReply.\n                         * @implements IDmSegMobileReply\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReply=} [properties] Properties to set\n                         */\n                        function DmSegMobileReply(properties) {\n                            this.elems = [];\n                            this.colorfulSrc = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegMobileReply elems.\n                         * @member {Array.<bilibili.community.service.dm.v1.IDanmakuElem>} elems\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @instance\n                         */\n                        DmSegMobileReply.prototype.elems = $util.emptyArray;\n\n                        /**\n                         * DmSegMobileReply state.\n                         * @member {number} state\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @instance\n                         */\n                        DmSegMobileReply.prototype.state = 0;\n\n                        /**\n                         * DmSegMobileReply aiFlag.\n                         * @member {bilibili.community.service.dm.v1.IDanmakuAIFlag|null|undefined} aiFlag\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @instance\n                         */\n                        DmSegMobileReply.prototype.aiFlag = null;\n\n                        /**\n                         * DmSegMobileReply colorfulSrc.\n                         * @member {Array.<bilibili.community.service.dm.v1.IDmColorful>} colorfulSrc\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @instance\n                         */\n                        DmSegMobileReply.prototype.colorfulSrc = $util.emptyArray;\n\n                        /**\n                         * Creates a new DmSegMobileReply instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReply=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReply} DmSegMobileReply instance\n                         */\n                        DmSegMobileReply.create = function create(properties) {\n                            return new DmSegMobileReply(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegMobileReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReply.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReply} message DmSegMobileReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegMobileReply.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.elems != null && message.elems.length)\n                                for (var i = 0; i < message.elems.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.DanmakuElem.encode(message.elems[i], writer.uint32(/* id 1, wireType 2 =*/10).fork()).ldelim();\n                            if (message.state != null && Object.hasOwnProperty.call(message, \"state\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.state);\n                            if (message.aiFlag != null && Object.hasOwnProperty.call(message, \"aiFlag\"))\n                                $root.bilibili.community.service.dm.v1.DanmakuAIFlag.encode(message.aiFlag, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            if (message.colorfulSrc != null && message.colorfulSrc.length)\n                                for (var i = 0; i < message.colorfulSrc.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.DmColorful.encode(message.colorfulSrc[i], writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegMobileReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReply.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReply} message DmSegMobileReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegMobileReply.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegMobileReply message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReply} DmSegMobileReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegMobileReply.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegMobileReply();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.elems && message.elems.length))\n                                            message.elems = [];\n                                        message.elems.push($root.bilibili.community.service.dm.v1.DanmakuElem.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 2: {\n                                        message.state = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.aiFlag = $root.bilibili.community.service.dm.v1.DanmakuAIFlag.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 5: {\n                                        if (!(message.colorfulSrc && message.colorfulSrc.length))\n                                            message.colorfulSrc = [];\n                                        message.colorfulSrc.push($root.bilibili.community.service.dm.v1.DmColorful.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegMobileReply message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReply} DmSegMobileReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegMobileReply.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegMobileReply message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegMobileReply.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.elems != null && message.hasOwnProperty(\"elems\")) {\n                                if (!Array.isArray(message.elems))\n                                    return \"elems: array expected\";\n                                for (var i = 0; i < message.elems.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.DanmakuElem.verify(message.elems[i]);\n                                    if (error)\n                                        return \"elems.\" + error;\n                                }\n                            }\n                            if (message.state != null && message.hasOwnProperty(\"state\"))\n                                if (!$util.isInteger(message.state))\n                                    return \"state: integer expected\";\n                            if (message.aiFlag != null && message.hasOwnProperty(\"aiFlag\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmakuAIFlag.verify(message.aiFlag);\n                                if (error)\n                                    return \"aiFlag.\" + error;\n                            }\n                            if (message.colorfulSrc != null && message.hasOwnProperty(\"colorfulSrc\")) {\n                                if (!Array.isArray(message.colorfulSrc))\n                                    return \"colorfulSrc: array expected\";\n                                for (var i = 0; i < message.colorfulSrc.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.DmColorful.verify(message.colorfulSrc[i]);\n                                    if (error)\n                                        return \"colorfulSrc.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegMobileReply message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReply} DmSegMobileReply\n                         */\n                        DmSegMobileReply.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegMobileReply)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegMobileReply();\n                            if (object.elems) {\n                                if (!Array.isArray(object.elems))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmSegMobileReply.elems: array expected\");\n                                message.elems = [];\n                                for (var i = 0; i < object.elems.length; ++i) {\n                                    if (typeof object.elems[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmSegMobileReply.elems: object expected\");\n                                    message.elems[i] = $root.bilibili.community.service.dm.v1.DanmakuElem.fromObject(object.elems[i]);\n                                }\n                            }\n                            if (object.state != null)\n                                message.state = object.state | 0;\n                            if (object.aiFlag != null) {\n                                if (typeof object.aiFlag !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmSegMobileReply.aiFlag: object expected\");\n                                message.aiFlag = $root.bilibili.community.service.dm.v1.DanmakuAIFlag.fromObject(object.aiFlag);\n                            }\n                            if (object.colorfulSrc) {\n                                if (!Array.isArray(object.colorfulSrc))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmSegMobileReply.colorfulSrc: array expected\");\n                                message.colorfulSrc = [];\n                                for (var i = 0; i < object.colorfulSrc.length; ++i) {\n                                    if (typeof object.colorfulSrc[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmSegMobileReply.colorfulSrc: object expected\");\n                                    message.colorfulSrc[i] = $root.bilibili.community.service.dm.v1.DmColorful.fromObject(object.colorfulSrc[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegMobileReply message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegMobileReply} message DmSegMobileReply\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegMobileReply.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.elems = [];\n                                object.colorfulSrc = [];\n                            }\n                            if (options.defaults) {\n                                object.state = 0;\n                                object.aiFlag = null;\n                            }\n                            if (message.elems && message.elems.length) {\n                                object.elems = [];\n                                for (var j = 0; j < message.elems.length; ++j)\n                                    object.elems[j] = $root.bilibili.community.service.dm.v1.DanmakuElem.toObject(message.elems[j], options);\n                            }\n                            if (message.state != null && message.hasOwnProperty(\"state\"))\n                                object.state = message.state;\n                            if (message.aiFlag != null && message.hasOwnProperty(\"aiFlag\"))\n                                object.aiFlag = $root.bilibili.community.service.dm.v1.DanmakuAIFlag.toObject(message.aiFlag, options);\n                            if (message.colorfulSrc && message.colorfulSrc.length) {\n                                object.colorfulSrc = [];\n                                for (var j = 0; j < message.colorfulSrc.length; ++j)\n                                    object.colorfulSrc[j] = $root.bilibili.community.service.dm.v1.DmColorful.toObject(message.colorfulSrc[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegMobileReply to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegMobileReply.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegMobileReply\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReply\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegMobileReply.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegMobileReply\";\n                        };\n\n                        return DmSegMobileReply;\n                    })();\n\n                    v1.DmSegMobileReq = (function() {\n\n                        /**\n                         * Properties of a DmSegMobileReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegMobileReq\n                         * @property {number|Long|null} [pid] DmSegMobileReq pid\n                         * @property {number|Long|null} [oid] DmSegMobileReq oid\n                         * @property {number|null} [type] DmSegMobileReq type\n                         * @property {number|Long|null} [segmentIndex] DmSegMobileReq segmentIndex\n                         * @property {number|null} [teenagersMode] DmSegMobileReq teenagersMode\n                         * @property {number|Long|null} [ps] DmSegMobileReq ps\n                         * @property {number|Long|null} [pe] DmSegMobileReq pe\n                         * @property {number|null} [pullMode] DmSegMobileReq pullMode\n                         * @property {number|null} [fromScene] DmSegMobileReq fromScene\n                         */\n\n                        /**\n                         * Constructs a new DmSegMobileReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegMobileReq.\n                         * @implements IDmSegMobileReq\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReq=} [properties] Properties to set\n                         */\n                        function DmSegMobileReq(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegMobileReq pid.\n                         * @member {number|Long} pid\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.pid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegMobileReq oid.\n                         * @member {number|Long} oid\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.oid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegMobileReq type.\n                         * @member {number} type\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.type = 0;\n\n                        /**\n                         * DmSegMobileReq segmentIndex.\n                         * @member {number|Long} segmentIndex\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.segmentIndex = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegMobileReq teenagersMode.\n                         * @member {number} teenagersMode\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.teenagersMode = 0;\n\n                        /**\n                         * DmSegMobileReq ps.\n                         * @member {number|Long} ps\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.ps = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegMobileReq pe.\n                         * @member {number|Long} pe\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.pe = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegMobileReq pullMode.\n                         * @member {number} pullMode\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.pullMode = 0;\n\n                        /**\n                         * DmSegMobileReq fromScene.\n                         * @member {number} fromScene\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         */\n                        DmSegMobileReq.prototype.fromScene = 0;\n\n                        /**\n                         * Creates a new DmSegMobileReq instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReq=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReq} DmSegMobileReq instance\n                         */\n                        DmSegMobileReq.create = function create(properties) {\n                            return new DmSegMobileReq(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegMobileReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReq.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReq} message DmSegMobileReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegMobileReq.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.pid != null && Object.hasOwnProperty.call(message, \"pid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.pid);\n                            if (message.oid != null && Object.hasOwnProperty.call(message, \"oid\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.oid);\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.type);\n                            if (message.segmentIndex != null && Object.hasOwnProperty.call(message, \"segmentIndex\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int64(message.segmentIndex);\n                            if (message.teenagersMode != null && Object.hasOwnProperty.call(message, \"teenagersMode\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.teenagersMode);\n                            if (message.ps != null && Object.hasOwnProperty.call(message, \"ps\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).int64(message.ps);\n                            if (message.pe != null && Object.hasOwnProperty.call(message, \"pe\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).int64(message.pe);\n                            if (message.pullMode != null && Object.hasOwnProperty.call(message, \"pullMode\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).int32(message.pullMode);\n                            if (message.fromScene != null && Object.hasOwnProperty.call(message, \"fromScene\"))\n                                writer.uint32(/* id 9, wireType 0 =*/72).int32(message.fromScene);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegMobileReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegMobileReq.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegMobileReq} message DmSegMobileReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegMobileReq.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegMobileReq message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReq} DmSegMobileReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegMobileReq.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegMobileReq();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.pid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.oid = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.segmentIndex = reader.int64();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.teenagersMode = reader.int32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.ps = reader.int64();\n                                        break;\n                                    }\n                                case 7: {\n                                        message.pe = reader.int64();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.pullMode = reader.int32();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.fromScene = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegMobileReq message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReq} DmSegMobileReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegMobileReq.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegMobileReq message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegMobileReq.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (!$util.isInteger(message.pid) && !(message.pid && $util.isInteger(message.pid.low) && $util.isInteger(message.pid.high)))\n                                    return \"pid: integer|Long expected\";\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (!$util.isInteger(message.oid) && !(message.oid && $util.isInteger(message.oid.low) && $util.isInteger(message.oid.high)))\n                                    return \"oid: integer|Long expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                if (!$util.isInteger(message.type))\n                                    return \"type: integer expected\";\n                            if (message.segmentIndex != null && message.hasOwnProperty(\"segmentIndex\"))\n                                if (!$util.isInteger(message.segmentIndex) && !(message.segmentIndex && $util.isInteger(message.segmentIndex.low) && $util.isInteger(message.segmentIndex.high)))\n                                    return \"segmentIndex: integer|Long expected\";\n                            if (message.teenagersMode != null && message.hasOwnProperty(\"teenagersMode\"))\n                                if (!$util.isInteger(message.teenagersMode))\n                                    return \"teenagersMode: integer expected\";\n                            if (message.ps != null && message.hasOwnProperty(\"ps\"))\n                                if (!$util.isInteger(message.ps) && !(message.ps && $util.isInteger(message.ps.low) && $util.isInteger(message.ps.high)))\n                                    return \"ps: integer|Long expected\";\n                            if (message.pe != null && message.hasOwnProperty(\"pe\"))\n                                if (!$util.isInteger(message.pe) && !(message.pe && $util.isInteger(message.pe.low) && $util.isInteger(message.pe.high)))\n                                    return \"pe: integer|Long expected\";\n                            if (message.pullMode != null && message.hasOwnProperty(\"pullMode\"))\n                                if (!$util.isInteger(message.pullMode))\n                                    return \"pullMode: integer expected\";\n                            if (message.fromScene != null && message.hasOwnProperty(\"fromScene\"))\n                                if (!$util.isInteger(message.fromScene))\n                                    return \"fromScene: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegMobileReq message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegMobileReq} DmSegMobileReq\n                         */\n                        DmSegMobileReq.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegMobileReq)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegMobileReq();\n                            if (object.pid != null)\n                                if ($util.Long)\n                                    (message.pid = $util.Long.fromValue(object.pid)).unsigned = false;\n                                else if (typeof object.pid === \"string\")\n                                    message.pid = parseInt(object.pid, 10);\n                                else if (typeof object.pid === \"number\")\n                                    message.pid = object.pid;\n                                else if (typeof object.pid === \"object\")\n                                    message.pid = new $util.LongBits(object.pid.low >>> 0, object.pid.high >>> 0).toNumber();\n                            if (object.oid != null)\n                                if ($util.Long)\n                                    (message.oid = $util.Long.fromValue(object.oid)).unsigned = false;\n                                else if (typeof object.oid === \"string\")\n                                    message.oid = parseInt(object.oid, 10);\n                                else if (typeof object.oid === \"number\")\n                                    message.oid = object.oid;\n                                else if (typeof object.oid === \"object\")\n                                    message.oid = new $util.LongBits(object.oid.low >>> 0, object.oid.high >>> 0).toNumber();\n                            if (object.type != null)\n                                message.type = object.type | 0;\n                            if (object.segmentIndex != null)\n                                if ($util.Long)\n                                    (message.segmentIndex = $util.Long.fromValue(object.segmentIndex)).unsigned = false;\n                                else if (typeof object.segmentIndex === \"string\")\n                                    message.segmentIndex = parseInt(object.segmentIndex, 10);\n                                else if (typeof object.segmentIndex === \"number\")\n                                    message.segmentIndex = object.segmentIndex;\n                                else if (typeof object.segmentIndex === \"object\")\n                                    message.segmentIndex = new $util.LongBits(object.segmentIndex.low >>> 0, object.segmentIndex.high >>> 0).toNumber();\n                            if (object.teenagersMode != null)\n                                message.teenagersMode = object.teenagersMode | 0;\n                            if (object.ps != null)\n                                if ($util.Long)\n                                    (message.ps = $util.Long.fromValue(object.ps)).unsigned = false;\n                                else if (typeof object.ps === \"string\")\n                                    message.ps = parseInt(object.ps, 10);\n                                else if (typeof object.ps === \"number\")\n                                    message.ps = object.ps;\n                                else if (typeof object.ps === \"object\")\n                                    message.ps = new $util.LongBits(object.ps.low >>> 0, object.ps.high >>> 0).toNumber();\n                            if (object.pe != null)\n                                if ($util.Long)\n                                    (message.pe = $util.Long.fromValue(object.pe)).unsigned = false;\n                                else if (typeof object.pe === \"string\")\n                                    message.pe = parseInt(object.pe, 10);\n                                else if (typeof object.pe === \"number\")\n                                    message.pe = object.pe;\n                                else if (typeof object.pe === \"object\")\n                                    message.pe = new $util.LongBits(object.pe.low >>> 0, object.pe.high >>> 0).toNumber();\n                            if (object.pullMode != null)\n                                message.pullMode = object.pullMode | 0;\n                            if (object.fromScene != null)\n                                message.fromScene = object.fromScene | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegMobileReq message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegMobileReq} message DmSegMobileReq\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegMobileReq.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.pid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.pid = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.oid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.oid = options.longs === String ? \"0\" : 0;\n                                object.type = 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.segmentIndex = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.segmentIndex = options.longs === String ? \"0\" : 0;\n                                object.teenagersMode = 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.ps = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.ps = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.pe = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.pe = options.longs === String ? \"0\" : 0;\n                                object.pullMode = 0;\n                                object.fromScene = 0;\n                            }\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (typeof message.pid === \"number\")\n                                    object.pid = options.longs === String ? String(message.pid) : message.pid;\n                                else\n                                    object.pid = options.longs === String ? $util.Long.prototype.toString.call(message.pid) : options.longs === Number ? new $util.LongBits(message.pid.low >>> 0, message.pid.high >>> 0).toNumber() : message.pid;\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (typeof message.oid === \"number\")\n                                    object.oid = options.longs === String ? String(message.oid) : message.oid;\n                                else\n                                    object.oid = options.longs === String ? $util.Long.prototype.toString.call(message.oid) : options.longs === Number ? new $util.LongBits(message.oid.low >>> 0, message.oid.high >>> 0).toNumber() : message.oid;\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = message.type;\n                            if (message.segmentIndex != null && message.hasOwnProperty(\"segmentIndex\"))\n                                if (typeof message.segmentIndex === \"number\")\n                                    object.segmentIndex = options.longs === String ? String(message.segmentIndex) : message.segmentIndex;\n                                else\n                                    object.segmentIndex = options.longs === String ? $util.Long.prototype.toString.call(message.segmentIndex) : options.longs === Number ? new $util.LongBits(message.segmentIndex.low >>> 0, message.segmentIndex.high >>> 0).toNumber() : message.segmentIndex;\n                            if (message.teenagersMode != null && message.hasOwnProperty(\"teenagersMode\"))\n                                object.teenagersMode = message.teenagersMode;\n                            if (message.ps != null && message.hasOwnProperty(\"ps\"))\n                                if (typeof message.ps === \"number\")\n                                    object.ps = options.longs === String ? String(message.ps) : message.ps;\n                                else\n                                    object.ps = options.longs === String ? $util.Long.prototype.toString.call(message.ps) : options.longs === Number ? new $util.LongBits(message.ps.low >>> 0, message.ps.high >>> 0).toNumber() : message.ps;\n                            if (message.pe != null && message.hasOwnProperty(\"pe\"))\n                                if (typeof message.pe === \"number\")\n                                    object.pe = options.longs === String ? String(message.pe) : message.pe;\n                                else\n                                    object.pe = options.longs === String ? $util.Long.prototype.toString.call(message.pe) : options.longs === Number ? new $util.LongBits(message.pe.low >>> 0, message.pe.high >>> 0).toNumber() : message.pe;\n                            if (message.pullMode != null && message.hasOwnProperty(\"pullMode\"))\n                                object.pullMode = message.pullMode;\n                            if (message.fromScene != null && message.hasOwnProperty(\"fromScene\"))\n                                object.fromScene = message.fromScene;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegMobileReq to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegMobileReq.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegMobileReq\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegMobileReq\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegMobileReq.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegMobileReq\";\n                        };\n\n                        return DmSegMobileReq;\n                    })();\n\n                    v1.DmSegOttReply = (function() {\n\n                        /**\n                         * Properties of a DmSegOttReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegOttReply\n                         * @property {boolean|null} [closed] DmSegOttReply closed\n                         * @property {Array.<bilibili.community.service.dm.v1.IDanmakuElem>|null} [elems] DmSegOttReply elems\n                         */\n\n                        /**\n                         * Constructs a new DmSegOttReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegOttReply.\n                         * @implements IDmSegOttReply\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReply=} [properties] Properties to set\n                         */\n                        function DmSegOttReply(properties) {\n                            this.elems = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegOttReply closed.\n                         * @member {boolean} closed\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @instance\n                         */\n                        DmSegOttReply.prototype.closed = false;\n\n                        /**\n                         * DmSegOttReply elems.\n                         * @member {Array.<bilibili.community.service.dm.v1.IDanmakuElem>} elems\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @instance\n                         */\n                        DmSegOttReply.prototype.elems = $util.emptyArray;\n\n                        /**\n                         * Creates a new DmSegOttReply instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReply=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReply} DmSegOttReply instance\n                         */\n                        DmSegOttReply.create = function create(properties) {\n                            return new DmSegOttReply(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegOttReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReply.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReply} message DmSegOttReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegOttReply.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.closed != null && Object.hasOwnProperty.call(message, \"closed\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.closed);\n                            if (message.elems != null && message.elems.length)\n                                for (var i = 0; i < message.elems.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.DanmakuElem.encode(message.elems[i], writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegOttReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReply.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReply} message DmSegOttReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegOttReply.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegOttReply message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReply} DmSegOttReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegOttReply.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegOttReply();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.closed = reader.bool();\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.elems && message.elems.length))\n                                            message.elems = [];\n                                        message.elems.push($root.bilibili.community.service.dm.v1.DanmakuElem.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegOttReply message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReply} DmSegOttReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegOttReply.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegOttReply message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegOttReply.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.closed != null && message.hasOwnProperty(\"closed\"))\n                                if (typeof message.closed !== \"boolean\")\n                                    return \"closed: boolean expected\";\n                            if (message.elems != null && message.hasOwnProperty(\"elems\")) {\n                                if (!Array.isArray(message.elems))\n                                    return \"elems: array expected\";\n                                for (var i = 0; i < message.elems.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.DanmakuElem.verify(message.elems[i]);\n                                    if (error)\n                                        return \"elems.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegOttReply message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReply} DmSegOttReply\n                         */\n                        DmSegOttReply.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegOttReply)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegOttReply();\n                            if (object.closed != null)\n                                message.closed = Boolean(object.closed);\n                            if (object.elems) {\n                                if (!Array.isArray(object.elems))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmSegOttReply.elems: array expected\");\n                                message.elems = [];\n                                for (var i = 0; i < object.elems.length; ++i) {\n                                    if (typeof object.elems[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmSegOttReply.elems: object expected\");\n                                    message.elems[i] = $root.bilibili.community.service.dm.v1.DanmakuElem.fromObject(object.elems[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegOttReply message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegOttReply} message DmSegOttReply\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegOttReply.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.elems = [];\n                            if (options.defaults)\n                                object.closed = false;\n                            if (message.closed != null && message.hasOwnProperty(\"closed\"))\n                                object.closed = message.closed;\n                            if (message.elems && message.elems.length) {\n                                object.elems = [];\n                                for (var j = 0; j < message.elems.length; ++j)\n                                    object.elems[j] = $root.bilibili.community.service.dm.v1.DanmakuElem.toObject(message.elems[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegOttReply to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegOttReply.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegOttReply\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReply\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegOttReply.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegOttReply\";\n                        };\n\n                        return DmSegOttReply;\n                    })();\n\n                    v1.DmSegOttReq = (function() {\n\n                        /**\n                         * Properties of a DmSegOttReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegOttReq\n                         * @property {number|Long|null} [pid] DmSegOttReq pid\n                         * @property {number|Long|null} [oid] DmSegOttReq oid\n                         * @property {number|null} [type] DmSegOttReq type\n                         * @property {number|Long|null} [segmentIndex] DmSegOttReq segmentIndex\n                         */\n\n                        /**\n                         * Constructs a new DmSegOttReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegOttReq.\n                         * @implements IDmSegOttReq\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReq=} [properties] Properties to set\n                         */\n                        function DmSegOttReq(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegOttReq pid.\n                         * @member {number|Long} pid\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @instance\n                         */\n                        DmSegOttReq.prototype.pid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegOttReq oid.\n                         * @member {number|Long} oid\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @instance\n                         */\n                        DmSegOttReq.prototype.oid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegOttReq type.\n                         * @member {number} type\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @instance\n                         */\n                        DmSegOttReq.prototype.type = 0;\n\n                        /**\n                         * DmSegOttReq segmentIndex.\n                         * @member {number|Long} segmentIndex\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @instance\n                         */\n                        DmSegOttReq.prototype.segmentIndex = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * Creates a new DmSegOttReq instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReq=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReq} DmSegOttReq instance\n                         */\n                        DmSegOttReq.create = function create(properties) {\n                            return new DmSegOttReq(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegOttReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReq.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReq} message DmSegOttReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegOttReq.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.pid != null && Object.hasOwnProperty.call(message, \"pid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.pid);\n                            if (message.oid != null && Object.hasOwnProperty.call(message, \"oid\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.oid);\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.type);\n                            if (message.segmentIndex != null && Object.hasOwnProperty.call(message, \"segmentIndex\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int64(message.segmentIndex);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegOttReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegOttReq.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegOttReq} message DmSegOttReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegOttReq.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegOttReq message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReq} DmSegOttReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegOttReq.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegOttReq();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.pid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.oid = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.segmentIndex = reader.int64();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegOttReq message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReq} DmSegOttReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegOttReq.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegOttReq message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegOttReq.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (!$util.isInteger(message.pid) && !(message.pid && $util.isInteger(message.pid.low) && $util.isInteger(message.pid.high)))\n                                    return \"pid: integer|Long expected\";\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (!$util.isInteger(message.oid) && !(message.oid && $util.isInteger(message.oid.low) && $util.isInteger(message.oid.high)))\n                                    return \"oid: integer|Long expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                if (!$util.isInteger(message.type))\n                                    return \"type: integer expected\";\n                            if (message.segmentIndex != null && message.hasOwnProperty(\"segmentIndex\"))\n                                if (!$util.isInteger(message.segmentIndex) && !(message.segmentIndex && $util.isInteger(message.segmentIndex.low) && $util.isInteger(message.segmentIndex.high)))\n                                    return \"segmentIndex: integer|Long expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegOttReq message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegOttReq} DmSegOttReq\n                         */\n                        DmSegOttReq.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegOttReq)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegOttReq();\n                            if (object.pid != null)\n                                if ($util.Long)\n                                    (message.pid = $util.Long.fromValue(object.pid)).unsigned = false;\n                                else if (typeof object.pid === \"string\")\n                                    message.pid = parseInt(object.pid, 10);\n                                else if (typeof object.pid === \"number\")\n                                    message.pid = object.pid;\n                                else if (typeof object.pid === \"object\")\n                                    message.pid = new $util.LongBits(object.pid.low >>> 0, object.pid.high >>> 0).toNumber();\n                            if (object.oid != null)\n                                if ($util.Long)\n                                    (message.oid = $util.Long.fromValue(object.oid)).unsigned = false;\n                                else if (typeof object.oid === \"string\")\n                                    message.oid = parseInt(object.oid, 10);\n                                else if (typeof object.oid === \"number\")\n                                    message.oid = object.oid;\n                                else if (typeof object.oid === \"object\")\n                                    message.oid = new $util.LongBits(object.oid.low >>> 0, object.oid.high >>> 0).toNumber();\n                            if (object.type != null)\n                                message.type = object.type | 0;\n                            if (object.segmentIndex != null)\n                                if ($util.Long)\n                                    (message.segmentIndex = $util.Long.fromValue(object.segmentIndex)).unsigned = false;\n                                else if (typeof object.segmentIndex === \"string\")\n                                    message.segmentIndex = parseInt(object.segmentIndex, 10);\n                                else if (typeof object.segmentIndex === \"number\")\n                                    message.segmentIndex = object.segmentIndex;\n                                else if (typeof object.segmentIndex === \"object\")\n                                    message.segmentIndex = new $util.LongBits(object.segmentIndex.low >>> 0, object.segmentIndex.high >>> 0).toNumber();\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegOttReq message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegOttReq} message DmSegOttReq\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegOttReq.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.pid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.pid = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.oid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.oid = options.longs === String ? \"0\" : 0;\n                                object.type = 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.segmentIndex = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.segmentIndex = options.longs === String ? \"0\" : 0;\n                            }\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (typeof message.pid === \"number\")\n                                    object.pid = options.longs === String ? String(message.pid) : message.pid;\n                                else\n                                    object.pid = options.longs === String ? $util.Long.prototype.toString.call(message.pid) : options.longs === Number ? new $util.LongBits(message.pid.low >>> 0, message.pid.high >>> 0).toNumber() : message.pid;\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (typeof message.oid === \"number\")\n                                    object.oid = options.longs === String ? String(message.oid) : message.oid;\n                                else\n                                    object.oid = options.longs === String ? $util.Long.prototype.toString.call(message.oid) : options.longs === Number ? new $util.LongBits(message.oid.low >>> 0, message.oid.high >>> 0).toNumber() : message.oid;\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = message.type;\n                            if (message.segmentIndex != null && message.hasOwnProperty(\"segmentIndex\"))\n                                if (typeof message.segmentIndex === \"number\")\n                                    object.segmentIndex = options.longs === String ? String(message.segmentIndex) : message.segmentIndex;\n                                else\n                                    object.segmentIndex = options.longs === String ? $util.Long.prototype.toString.call(message.segmentIndex) : options.longs === Number ? new $util.LongBits(message.segmentIndex.low >>> 0, message.segmentIndex.high >>> 0).toNumber() : message.segmentIndex;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegOttReq to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegOttReq.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegOttReq\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegOttReq\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegOttReq.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegOttReq\";\n                        };\n\n                        return DmSegOttReq;\n                    })();\n\n                    v1.DmSegSDKReply = (function() {\n\n                        /**\n                         * Properties of a DmSegSDKReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegSDKReply\n                         * @property {boolean|null} [closed] DmSegSDKReply closed\n                         * @property {Array.<bilibili.community.service.dm.v1.IDanmakuElem>|null} [elems] DmSegSDKReply elems\n                         */\n\n                        /**\n                         * Constructs a new DmSegSDKReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegSDKReply.\n                         * @implements IDmSegSDKReply\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReply=} [properties] Properties to set\n                         */\n                        function DmSegSDKReply(properties) {\n                            this.elems = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegSDKReply closed.\n                         * @member {boolean} closed\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @instance\n                         */\n                        DmSegSDKReply.prototype.closed = false;\n\n                        /**\n                         * DmSegSDKReply elems.\n                         * @member {Array.<bilibili.community.service.dm.v1.IDanmakuElem>} elems\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @instance\n                         */\n                        DmSegSDKReply.prototype.elems = $util.emptyArray;\n\n                        /**\n                         * Creates a new DmSegSDKReply instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReply=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReply} DmSegSDKReply instance\n                         */\n                        DmSegSDKReply.create = function create(properties) {\n                            return new DmSegSDKReply(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegSDKReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReply.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReply} message DmSegSDKReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegSDKReply.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.closed != null && Object.hasOwnProperty.call(message, \"closed\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.closed);\n                            if (message.elems != null && message.elems.length)\n                                for (var i = 0; i < message.elems.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.DanmakuElem.encode(message.elems[i], writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegSDKReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReply.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReply} message DmSegSDKReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegSDKReply.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegSDKReply message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReply} DmSegSDKReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegSDKReply.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegSDKReply();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.closed = reader.bool();\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.elems && message.elems.length))\n                                            message.elems = [];\n                                        message.elems.push($root.bilibili.community.service.dm.v1.DanmakuElem.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegSDKReply message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReply} DmSegSDKReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegSDKReply.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegSDKReply message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegSDKReply.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.closed != null && message.hasOwnProperty(\"closed\"))\n                                if (typeof message.closed !== \"boolean\")\n                                    return \"closed: boolean expected\";\n                            if (message.elems != null && message.hasOwnProperty(\"elems\")) {\n                                if (!Array.isArray(message.elems))\n                                    return \"elems: array expected\";\n                                for (var i = 0; i < message.elems.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.DanmakuElem.verify(message.elems[i]);\n                                    if (error)\n                                        return \"elems.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegSDKReply message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReply} DmSegSDKReply\n                         */\n                        DmSegSDKReply.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegSDKReply)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegSDKReply();\n                            if (object.closed != null)\n                                message.closed = Boolean(object.closed);\n                            if (object.elems) {\n                                if (!Array.isArray(object.elems))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmSegSDKReply.elems: array expected\");\n                                message.elems = [];\n                                for (var i = 0; i < object.elems.length; ++i) {\n                                    if (typeof object.elems[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmSegSDKReply.elems: object expected\");\n                                    message.elems[i] = $root.bilibili.community.service.dm.v1.DanmakuElem.fromObject(object.elems[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegSDKReply message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegSDKReply} message DmSegSDKReply\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegSDKReply.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.elems = [];\n                            if (options.defaults)\n                                object.closed = false;\n                            if (message.closed != null && message.hasOwnProperty(\"closed\"))\n                                object.closed = message.closed;\n                            if (message.elems && message.elems.length) {\n                                object.elems = [];\n                                for (var j = 0; j < message.elems.length; ++j)\n                                    object.elems[j] = $root.bilibili.community.service.dm.v1.DanmakuElem.toObject(message.elems[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegSDKReply to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegSDKReply.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegSDKReply\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReply\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegSDKReply.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegSDKReply\";\n                        };\n\n                        return DmSegSDKReply;\n                    })();\n\n                    v1.DmSegSDKReq = (function() {\n\n                        /**\n                         * Properties of a DmSegSDKReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmSegSDKReq\n                         * @property {number|Long|null} [pid] DmSegSDKReq pid\n                         * @property {number|Long|null} [oid] DmSegSDKReq oid\n                         * @property {number|null} [type] DmSegSDKReq type\n                         * @property {number|Long|null} [segmentIndex] DmSegSDKReq segmentIndex\n                         */\n\n                        /**\n                         * Constructs a new DmSegSDKReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmSegSDKReq.\n                         * @implements IDmSegSDKReq\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReq=} [properties] Properties to set\n                         */\n                        function DmSegSDKReq(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmSegSDKReq pid.\n                         * @member {number|Long} pid\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @instance\n                         */\n                        DmSegSDKReq.prototype.pid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegSDKReq oid.\n                         * @member {number|Long} oid\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @instance\n                         */\n                        DmSegSDKReq.prototype.oid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmSegSDKReq type.\n                         * @member {number} type\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @instance\n                         */\n                        DmSegSDKReq.prototype.type = 0;\n\n                        /**\n                         * DmSegSDKReq segmentIndex.\n                         * @member {number|Long} segmentIndex\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @instance\n                         */\n                        DmSegSDKReq.prototype.segmentIndex = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * Creates a new DmSegSDKReq instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReq=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReq} DmSegSDKReq instance\n                         */\n                        DmSegSDKReq.create = function create(properties) {\n                            return new DmSegSDKReq(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmSegSDKReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReq.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReq} message DmSegSDKReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegSDKReq.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.pid != null && Object.hasOwnProperty.call(message, \"pid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.pid);\n                            if (message.oid != null && Object.hasOwnProperty.call(message, \"oid\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.oid);\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.type);\n                            if (message.segmentIndex != null && Object.hasOwnProperty.call(message, \"segmentIndex\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int64(message.segmentIndex);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmSegSDKReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmSegSDKReq.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmSegSDKReq} message DmSegSDKReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmSegSDKReq.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmSegSDKReq message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReq} DmSegSDKReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegSDKReq.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmSegSDKReq();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.pid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.oid = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.segmentIndex = reader.int64();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmSegSDKReq message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReq} DmSegSDKReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmSegSDKReq.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmSegSDKReq message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmSegSDKReq.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (!$util.isInteger(message.pid) && !(message.pid && $util.isInteger(message.pid.low) && $util.isInteger(message.pid.high)))\n                                    return \"pid: integer|Long expected\";\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (!$util.isInteger(message.oid) && !(message.oid && $util.isInteger(message.oid.low) && $util.isInteger(message.oid.high)))\n                                    return \"oid: integer|Long expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                if (!$util.isInteger(message.type))\n                                    return \"type: integer expected\";\n                            if (message.segmentIndex != null && message.hasOwnProperty(\"segmentIndex\"))\n                                if (!$util.isInteger(message.segmentIndex) && !(message.segmentIndex && $util.isInteger(message.segmentIndex.low) && $util.isInteger(message.segmentIndex.high)))\n                                    return \"segmentIndex: integer|Long expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmSegSDKReq message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmSegSDKReq} DmSegSDKReq\n                         */\n                        DmSegSDKReq.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmSegSDKReq)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmSegSDKReq();\n                            if (object.pid != null)\n                                if ($util.Long)\n                                    (message.pid = $util.Long.fromValue(object.pid)).unsigned = false;\n                                else if (typeof object.pid === \"string\")\n                                    message.pid = parseInt(object.pid, 10);\n                                else if (typeof object.pid === \"number\")\n                                    message.pid = object.pid;\n                                else if (typeof object.pid === \"object\")\n                                    message.pid = new $util.LongBits(object.pid.low >>> 0, object.pid.high >>> 0).toNumber();\n                            if (object.oid != null)\n                                if ($util.Long)\n                                    (message.oid = $util.Long.fromValue(object.oid)).unsigned = false;\n                                else if (typeof object.oid === \"string\")\n                                    message.oid = parseInt(object.oid, 10);\n                                else if (typeof object.oid === \"number\")\n                                    message.oid = object.oid;\n                                else if (typeof object.oid === \"object\")\n                                    message.oid = new $util.LongBits(object.oid.low >>> 0, object.oid.high >>> 0).toNumber();\n                            if (object.type != null)\n                                message.type = object.type | 0;\n                            if (object.segmentIndex != null)\n                                if ($util.Long)\n                                    (message.segmentIndex = $util.Long.fromValue(object.segmentIndex)).unsigned = false;\n                                else if (typeof object.segmentIndex === \"string\")\n                                    message.segmentIndex = parseInt(object.segmentIndex, 10);\n                                else if (typeof object.segmentIndex === \"number\")\n                                    message.segmentIndex = object.segmentIndex;\n                                else if (typeof object.segmentIndex === \"object\")\n                                    message.segmentIndex = new $util.LongBits(object.segmentIndex.low >>> 0, object.segmentIndex.high >>> 0).toNumber();\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmSegSDKReq message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmSegSDKReq} message DmSegSDKReq\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmSegSDKReq.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.pid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.pid = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.oid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.oid = options.longs === String ? \"0\" : 0;\n                                object.type = 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.segmentIndex = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.segmentIndex = options.longs === String ? \"0\" : 0;\n                            }\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (typeof message.pid === \"number\")\n                                    object.pid = options.longs === String ? String(message.pid) : message.pid;\n                                else\n                                    object.pid = options.longs === String ? $util.Long.prototype.toString.call(message.pid) : options.longs === Number ? new $util.LongBits(message.pid.low >>> 0, message.pid.high >>> 0).toNumber() : message.pid;\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (typeof message.oid === \"number\")\n                                    object.oid = options.longs === String ? String(message.oid) : message.oid;\n                                else\n                                    object.oid = options.longs === String ? $util.Long.prototype.toString.call(message.oid) : options.longs === Number ? new $util.LongBits(message.oid.low >>> 0, message.oid.high >>> 0).toNumber() : message.oid;\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = message.type;\n                            if (message.segmentIndex != null && message.hasOwnProperty(\"segmentIndex\"))\n                                if (typeof message.segmentIndex === \"number\")\n                                    object.segmentIndex = options.longs === String ? String(message.segmentIndex) : message.segmentIndex;\n                                else\n                                    object.segmentIndex = options.longs === String ? $util.Long.prototype.toString.call(message.segmentIndex) : options.longs === Number ? new $util.LongBits(message.segmentIndex.low >>> 0, message.segmentIndex.high >>> 0).toNumber() : message.segmentIndex;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmSegSDKReq to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmSegSDKReq.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmSegSDKReq\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmSegSDKReq\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmSegSDKReq.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmSegSDKReq\";\n                        };\n\n                        return DmSegSDKReq;\n                    })();\n\n                    v1.DmViewReply = (function() {\n\n                        /**\n                         * Properties of a DmViewReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmViewReply\n                         * @property {boolean|null} [closed] DmViewReply closed\n                         * @property {bilibili.community.service.dm.v1.IVideoMask|null} [mask] DmViewReply mask\n                         * @property {bilibili.community.service.dm.v1.IVideoSubtitle|null} [subtitle] DmViewReply subtitle\n                         * @property {Array.<string>|null} [specialDms] DmViewReply specialDms\n                         * @property {bilibili.community.service.dm.v1.IDanmakuFlagConfig|null} [aiFlag] DmViewReply aiFlag\n                         * @property {bilibili.community.service.dm.v1.IDanmuPlayerViewConfig|null} [playerConfig] DmViewReply playerConfig\n                         * @property {number|null} [sendBoxStyle] DmViewReply sendBoxStyle\n                         * @property {boolean|null} [allow] DmViewReply allow\n                         * @property {string|null} [checkBox] DmViewReply checkBox\n                         * @property {string|null} [checkBoxShowMsg] DmViewReply checkBoxShowMsg\n                         * @property {string|null} [textPlaceholder] DmViewReply textPlaceholder\n                         * @property {string|null} [inputPlaceholder] DmViewReply inputPlaceholder\n                         * @property {Array.<string>|null} [reportFilterContent] DmViewReply reportFilterContent\n                         * @property {bilibili.community.service.dm.v1.IExpoReport|null} [expoReport] DmViewReply expoReport\n                         * @property {bilibili.community.service.dm.v1.IBuzzwordConfig|null} [buzzwordConfig] DmViewReply buzzwordConfig\n                         * @property {Array.<bilibili.community.service.dm.v1.IExpressions>|null} [expressions] DmViewReply expressions\n                         * @property {Array.<bilibili.community.service.dm.v1.IPostPanel>|null} [postPanel] DmViewReply postPanel\n                         * @property {Array.<string>|null} [activityMeta] DmViewReply activityMeta\n                         * @property {Array.<bilibili.community.service.dm.v1.IPostPanelV2>|null} [postPanel2] DmViewReply postPanel2\n                         */\n\n                        /**\n                         * Constructs a new DmViewReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmViewReply.\n                         * @implements IDmViewReply\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmViewReply=} [properties] Properties to set\n                         */\n                        function DmViewReply(properties) {\n                            this.specialDms = [];\n                            this.reportFilterContent = [];\n                            this.expressions = [];\n                            this.postPanel = [];\n                            this.activityMeta = [];\n                            this.postPanel2 = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmViewReply closed.\n                         * @member {boolean} closed\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.closed = false;\n\n                        /**\n                         * DmViewReply mask.\n                         * @member {bilibili.community.service.dm.v1.IVideoMask|null|undefined} mask\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.mask = null;\n\n                        /**\n                         * DmViewReply subtitle.\n                         * @member {bilibili.community.service.dm.v1.IVideoSubtitle|null|undefined} subtitle\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.subtitle = null;\n\n                        /**\n                         * DmViewReply specialDms.\n                         * @member {Array.<string>} specialDms\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.specialDms = $util.emptyArray;\n\n                        /**\n                         * DmViewReply aiFlag.\n                         * @member {bilibili.community.service.dm.v1.IDanmakuFlagConfig|null|undefined} aiFlag\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.aiFlag = null;\n\n                        /**\n                         * DmViewReply playerConfig.\n                         * @member {bilibili.community.service.dm.v1.IDanmuPlayerViewConfig|null|undefined} playerConfig\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.playerConfig = null;\n\n                        /**\n                         * DmViewReply sendBoxStyle.\n                         * @member {number} sendBoxStyle\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.sendBoxStyle = 0;\n\n                        /**\n                         * DmViewReply allow.\n                         * @member {boolean} allow\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.allow = false;\n\n                        /**\n                         * DmViewReply checkBox.\n                         * @member {string} checkBox\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.checkBox = \"\";\n\n                        /**\n                         * DmViewReply checkBoxShowMsg.\n                         * @member {string} checkBoxShowMsg\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.checkBoxShowMsg = \"\";\n\n                        /**\n                         * DmViewReply textPlaceholder.\n                         * @member {string} textPlaceholder\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.textPlaceholder = \"\";\n\n                        /**\n                         * DmViewReply inputPlaceholder.\n                         * @member {string} inputPlaceholder\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.inputPlaceholder = \"\";\n\n                        /**\n                         * DmViewReply reportFilterContent.\n                         * @member {Array.<string>} reportFilterContent\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.reportFilterContent = $util.emptyArray;\n\n                        /**\n                         * DmViewReply expoReport.\n                         * @member {bilibili.community.service.dm.v1.IExpoReport|null|undefined} expoReport\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.expoReport = null;\n\n                        /**\n                         * DmViewReply buzzwordConfig.\n                         * @member {bilibili.community.service.dm.v1.IBuzzwordConfig|null|undefined} buzzwordConfig\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.buzzwordConfig = null;\n\n                        /**\n                         * DmViewReply expressions.\n                         * @member {Array.<bilibili.community.service.dm.v1.IExpressions>} expressions\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.expressions = $util.emptyArray;\n\n                        /**\n                         * DmViewReply postPanel.\n                         * @member {Array.<bilibili.community.service.dm.v1.IPostPanel>} postPanel\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.postPanel = $util.emptyArray;\n\n                        /**\n                         * DmViewReply activityMeta.\n                         * @member {Array.<string>} activityMeta\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.activityMeta = $util.emptyArray;\n\n                        /**\n                         * DmViewReply postPanel2.\n                         * @member {Array.<bilibili.community.service.dm.v1.IPostPanelV2>} postPanel2\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         */\n                        DmViewReply.prototype.postPanel2 = $util.emptyArray;\n\n                        /**\n                         * Creates a new DmViewReply instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmViewReply=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmViewReply} DmViewReply instance\n                         */\n                        DmViewReply.create = function create(properties) {\n                            return new DmViewReply(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmViewReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReply.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmViewReply} message DmViewReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmViewReply.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.closed != null && Object.hasOwnProperty.call(message, \"closed\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.closed);\n                            if (message.mask != null && Object.hasOwnProperty.call(message, \"mask\"))\n                                $root.bilibili.community.service.dm.v1.VideoMask.encode(message.mask, writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim();\n                            if (message.subtitle != null && Object.hasOwnProperty.call(message, \"subtitle\"))\n                                $root.bilibili.community.service.dm.v1.VideoSubtitle.encode(message.subtitle, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            if (message.specialDms != null && message.specialDms.length)\n                                for (var i = 0; i < message.specialDms.length; ++i)\n                                    writer.uint32(/* id 4, wireType 2 =*/34).string(message.specialDms[i]);\n                            if (message.aiFlag != null && Object.hasOwnProperty.call(message, \"aiFlag\"))\n                                $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.encode(message.aiFlag, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim();\n                            if (message.playerConfig != null && Object.hasOwnProperty.call(message, \"playerConfig\"))\n                                $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig.encode(message.playerConfig, writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim();\n                            if (message.sendBoxStyle != null && Object.hasOwnProperty.call(message, \"sendBoxStyle\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).int32(message.sendBoxStyle);\n                            if (message.allow != null && Object.hasOwnProperty.call(message, \"allow\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).bool(message.allow);\n                            if (message.checkBox != null && Object.hasOwnProperty.call(message, \"checkBox\"))\n                                writer.uint32(/* id 9, wireType 2 =*/74).string(message.checkBox);\n                            if (message.checkBoxShowMsg != null && Object.hasOwnProperty.call(message, \"checkBoxShowMsg\"))\n                                writer.uint32(/* id 10, wireType 2 =*/82).string(message.checkBoxShowMsg);\n                            if (message.textPlaceholder != null && Object.hasOwnProperty.call(message, \"textPlaceholder\"))\n                                writer.uint32(/* id 11, wireType 2 =*/90).string(message.textPlaceholder);\n                            if (message.inputPlaceholder != null && Object.hasOwnProperty.call(message, \"inputPlaceholder\"))\n                                writer.uint32(/* id 12, wireType 2 =*/98).string(message.inputPlaceholder);\n                            if (message.reportFilterContent != null && message.reportFilterContent.length)\n                                for (var i = 0; i < message.reportFilterContent.length; ++i)\n                                    writer.uint32(/* id 13, wireType 2 =*/106).string(message.reportFilterContent[i]);\n                            if (message.expoReport != null && Object.hasOwnProperty.call(message, \"expoReport\"))\n                                $root.bilibili.community.service.dm.v1.ExpoReport.encode(message.expoReport, writer.uint32(/* id 14, wireType 2 =*/114).fork()).ldelim();\n                            if (message.buzzwordConfig != null && Object.hasOwnProperty.call(message, \"buzzwordConfig\"))\n                                $root.bilibili.community.service.dm.v1.BuzzwordConfig.encode(message.buzzwordConfig, writer.uint32(/* id 15, wireType 2 =*/122).fork()).ldelim();\n                            if (message.expressions != null && message.expressions.length)\n                                for (var i = 0; i < message.expressions.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.Expressions.encode(message.expressions[i], writer.uint32(/* id 16, wireType 2 =*/130).fork()).ldelim();\n                            if (message.postPanel != null && message.postPanel.length)\n                                for (var i = 0; i < message.postPanel.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.PostPanel.encode(message.postPanel[i], writer.uint32(/* id 17, wireType 2 =*/138).fork()).ldelim();\n                            if (message.activityMeta != null && message.activityMeta.length)\n                                for (var i = 0; i < message.activityMeta.length; ++i)\n                                    writer.uint32(/* id 18, wireType 2 =*/146).string(message.activityMeta[i]);\n                            if (message.postPanel2 != null && message.postPanel2.length)\n                                for (var i = 0; i < message.postPanel2.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.PostPanelV2.encode(message.postPanel2[i], writer.uint32(/* id 19, wireType 2 =*/154).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmViewReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReply.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmViewReply} message DmViewReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmViewReply.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmViewReply message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmViewReply} DmViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmViewReply.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmViewReply();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.closed = reader.bool();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.mask = $root.bilibili.community.service.dm.v1.VideoMask.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 3: {\n                                        message.subtitle = $root.bilibili.community.service.dm.v1.VideoSubtitle.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 4: {\n                                        if (!(message.specialDms && message.specialDms.length))\n                                            message.specialDms = [];\n                                        message.specialDms.push(reader.string());\n                                        break;\n                                    }\n                                case 5: {\n                                        message.aiFlag = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 6: {\n                                        message.playerConfig = $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 7: {\n                                        message.sendBoxStyle = reader.int32();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.allow = reader.bool();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.checkBox = reader.string();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.checkBoxShowMsg = reader.string();\n                                        break;\n                                    }\n                                case 11: {\n                                        message.textPlaceholder = reader.string();\n                                        break;\n                                    }\n                                case 12: {\n                                        message.inputPlaceholder = reader.string();\n                                        break;\n                                    }\n                                case 13: {\n                                        if (!(message.reportFilterContent && message.reportFilterContent.length))\n                                            message.reportFilterContent = [];\n                                        message.reportFilterContent.push(reader.string());\n                                        break;\n                                    }\n                                case 14: {\n                                        message.expoReport = $root.bilibili.community.service.dm.v1.ExpoReport.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 15: {\n                                        message.buzzwordConfig = $root.bilibili.community.service.dm.v1.BuzzwordConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 16: {\n                                        if (!(message.expressions && message.expressions.length))\n                                            message.expressions = [];\n                                        message.expressions.push($root.bilibili.community.service.dm.v1.Expressions.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 17: {\n                                        if (!(message.postPanel && message.postPanel.length))\n                                            message.postPanel = [];\n                                        message.postPanel.push($root.bilibili.community.service.dm.v1.PostPanel.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 18: {\n                                        if (!(message.activityMeta && message.activityMeta.length))\n                                            message.activityMeta = [];\n                                        message.activityMeta.push(reader.string());\n                                        break;\n                                    }\n                                case 19: {\n                                        if (!(message.postPanel2 && message.postPanel2.length))\n                                            message.postPanel2 = [];\n                                        message.postPanel2.push($root.bilibili.community.service.dm.v1.PostPanelV2.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmViewReply message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmViewReply} DmViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmViewReply.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmViewReply message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmViewReply.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.closed != null && message.hasOwnProperty(\"closed\"))\n                                if (typeof message.closed !== \"boolean\")\n                                    return \"closed: boolean expected\";\n                            if (message.mask != null && message.hasOwnProperty(\"mask\")) {\n                                var error = $root.bilibili.community.service.dm.v1.VideoMask.verify(message.mask);\n                                if (error)\n                                    return \"mask.\" + error;\n                            }\n                            if (message.subtitle != null && message.hasOwnProperty(\"subtitle\")) {\n                                var error = $root.bilibili.community.service.dm.v1.VideoSubtitle.verify(message.subtitle);\n                                if (error)\n                                    return \"subtitle.\" + error;\n                            }\n                            if (message.specialDms != null && message.hasOwnProperty(\"specialDms\")) {\n                                if (!Array.isArray(message.specialDms))\n                                    return \"specialDms: array expected\";\n                                for (var i = 0; i < message.specialDms.length; ++i)\n                                    if (!$util.isString(message.specialDms[i]))\n                                        return \"specialDms: string[] expected\";\n                            }\n                            if (message.aiFlag != null && message.hasOwnProperty(\"aiFlag\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.verify(message.aiFlag);\n                                if (error)\n                                    return \"aiFlag.\" + error;\n                            }\n                            if (message.playerConfig != null && message.hasOwnProperty(\"playerConfig\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig.verify(message.playerConfig);\n                                if (error)\n                                    return \"playerConfig.\" + error;\n                            }\n                            if (message.sendBoxStyle != null && message.hasOwnProperty(\"sendBoxStyle\"))\n                                if (!$util.isInteger(message.sendBoxStyle))\n                                    return \"sendBoxStyle: integer expected\";\n                            if (message.allow != null && message.hasOwnProperty(\"allow\"))\n                                if (typeof message.allow !== \"boolean\")\n                                    return \"allow: boolean expected\";\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\"))\n                                if (!$util.isString(message.checkBox))\n                                    return \"checkBox: string expected\";\n                            if (message.checkBoxShowMsg != null && message.hasOwnProperty(\"checkBoxShowMsg\"))\n                                if (!$util.isString(message.checkBoxShowMsg))\n                                    return \"checkBoxShowMsg: string expected\";\n                            if (message.textPlaceholder != null && message.hasOwnProperty(\"textPlaceholder\"))\n                                if (!$util.isString(message.textPlaceholder))\n                                    return \"textPlaceholder: string expected\";\n                            if (message.inputPlaceholder != null && message.hasOwnProperty(\"inputPlaceholder\"))\n                                if (!$util.isString(message.inputPlaceholder))\n                                    return \"inputPlaceholder: string expected\";\n                            if (message.reportFilterContent != null && message.hasOwnProperty(\"reportFilterContent\")) {\n                                if (!Array.isArray(message.reportFilterContent))\n                                    return \"reportFilterContent: array expected\";\n                                for (var i = 0; i < message.reportFilterContent.length; ++i)\n                                    if (!$util.isString(message.reportFilterContent[i]))\n                                        return \"reportFilterContent: string[] expected\";\n                            }\n                            if (message.expoReport != null && message.hasOwnProperty(\"expoReport\")) {\n                                var error = $root.bilibili.community.service.dm.v1.ExpoReport.verify(message.expoReport);\n                                if (error)\n                                    return \"expoReport.\" + error;\n                            }\n                            if (message.buzzwordConfig != null && message.hasOwnProperty(\"buzzwordConfig\")) {\n                                var error = $root.bilibili.community.service.dm.v1.BuzzwordConfig.verify(message.buzzwordConfig);\n                                if (error)\n                                    return \"buzzwordConfig.\" + error;\n                            }\n                            if (message.expressions != null && message.hasOwnProperty(\"expressions\")) {\n                                if (!Array.isArray(message.expressions))\n                                    return \"expressions: array expected\";\n                                for (var i = 0; i < message.expressions.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.Expressions.verify(message.expressions[i]);\n                                    if (error)\n                                        return \"expressions.\" + error;\n                                }\n                            }\n                            if (message.postPanel != null && message.hasOwnProperty(\"postPanel\")) {\n                                if (!Array.isArray(message.postPanel))\n                                    return \"postPanel: array expected\";\n                                for (var i = 0; i < message.postPanel.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.PostPanel.verify(message.postPanel[i]);\n                                    if (error)\n                                        return \"postPanel.\" + error;\n                                }\n                            }\n                            if (message.activityMeta != null && message.hasOwnProperty(\"activityMeta\")) {\n                                if (!Array.isArray(message.activityMeta))\n                                    return \"activityMeta: array expected\";\n                                for (var i = 0; i < message.activityMeta.length; ++i)\n                                    if (!$util.isString(message.activityMeta[i]))\n                                        return \"activityMeta: string[] expected\";\n                            }\n                            if (message.postPanel2 != null && message.hasOwnProperty(\"postPanel2\")) {\n                                if (!Array.isArray(message.postPanel2))\n                                    return \"postPanel2: array expected\";\n                                for (var i = 0; i < message.postPanel2.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.PostPanelV2.verify(message.postPanel2[i]);\n                                    if (error)\n                                        return \"postPanel2.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmViewReply message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmViewReply} DmViewReply\n                         */\n                        DmViewReply.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmViewReply)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmViewReply();\n                            if (object.closed != null)\n                                message.closed = Boolean(object.closed);\n                            if (object.mask != null) {\n                                if (typeof object.mask !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.mask: object expected\");\n                                message.mask = $root.bilibili.community.service.dm.v1.VideoMask.fromObject(object.mask);\n                            }\n                            if (object.subtitle != null) {\n                                if (typeof object.subtitle !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.subtitle: object expected\");\n                                message.subtitle = $root.bilibili.community.service.dm.v1.VideoSubtitle.fromObject(object.subtitle);\n                            }\n                            if (object.specialDms) {\n                                if (!Array.isArray(object.specialDms))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.specialDms: array expected\");\n                                message.specialDms = [];\n                                for (var i = 0; i < object.specialDms.length; ++i)\n                                    message.specialDms[i] = String(object.specialDms[i]);\n                            }\n                            if (object.aiFlag != null) {\n                                if (typeof object.aiFlag !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.aiFlag: object expected\");\n                                message.aiFlag = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.fromObject(object.aiFlag);\n                            }\n                            if (object.playerConfig != null) {\n                                if (typeof object.playerConfig !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.playerConfig: object expected\");\n                                message.playerConfig = $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig.fromObject(object.playerConfig);\n                            }\n                            if (object.sendBoxStyle != null)\n                                message.sendBoxStyle = object.sendBoxStyle | 0;\n                            if (object.allow != null)\n                                message.allow = Boolean(object.allow);\n                            if (object.checkBox != null)\n                                message.checkBox = String(object.checkBox);\n                            if (object.checkBoxShowMsg != null)\n                                message.checkBoxShowMsg = String(object.checkBoxShowMsg);\n                            if (object.textPlaceholder != null)\n                                message.textPlaceholder = String(object.textPlaceholder);\n                            if (object.inputPlaceholder != null)\n                                message.inputPlaceholder = String(object.inputPlaceholder);\n                            if (object.reportFilterContent) {\n                                if (!Array.isArray(object.reportFilterContent))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.reportFilterContent: array expected\");\n                                message.reportFilterContent = [];\n                                for (var i = 0; i < object.reportFilterContent.length; ++i)\n                                    message.reportFilterContent[i] = String(object.reportFilterContent[i]);\n                            }\n                            if (object.expoReport != null) {\n                                if (typeof object.expoReport !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.expoReport: object expected\");\n                                message.expoReport = $root.bilibili.community.service.dm.v1.ExpoReport.fromObject(object.expoReport);\n                            }\n                            if (object.buzzwordConfig != null) {\n                                if (typeof object.buzzwordConfig !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.buzzwordConfig: object expected\");\n                                message.buzzwordConfig = $root.bilibili.community.service.dm.v1.BuzzwordConfig.fromObject(object.buzzwordConfig);\n                            }\n                            if (object.expressions) {\n                                if (!Array.isArray(object.expressions))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.expressions: array expected\");\n                                message.expressions = [];\n                                for (var i = 0; i < object.expressions.length; ++i) {\n                                    if (typeof object.expressions[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.expressions: object expected\");\n                                    message.expressions[i] = $root.bilibili.community.service.dm.v1.Expressions.fromObject(object.expressions[i]);\n                                }\n                            }\n                            if (object.postPanel) {\n                                if (!Array.isArray(object.postPanel))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.postPanel: array expected\");\n                                message.postPanel = [];\n                                for (var i = 0; i < object.postPanel.length; ++i) {\n                                    if (typeof object.postPanel[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.postPanel: object expected\");\n                                    message.postPanel[i] = $root.bilibili.community.service.dm.v1.PostPanel.fromObject(object.postPanel[i]);\n                                }\n                            }\n                            if (object.activityMeta) {\n                                if (!Array.isArray(object.activityMeta))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.activityMeta: array expected\");\n                                message.activityMeta = [];\n                                for (var i = 0; i < object.activityMeta.length; ++i)\n                                    message.activityMeta[i] = String(object.activityMeta[i]);\n                            }\n                            if (object.postPanel2) {\n                                if (!Array.isArray(object.postPanel2))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.postPanel2: array expected\");\n                                message.postPanel2 = [];\n                                for (var i = 0; i < object.postPanel2.length; ++i) {\n                                    if (typeof object.postPanel2[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmViewReply.postPanel2: object expected\");\n                                    message.postPanel2[i] = $root.bilibili.community.service.dm.v1.PostPanelV2.fromObject(object.postPanel2[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmViewReply message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmViewReply} message DmViewReply\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmViewReply.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.specialDms = [];\n                                object.reportFilterContent = [];\n                                object.expressions = [];\n                                object.postPanel = [];\n                                object.activityMeta = [];\n                                object.postPanel2 = [];\n                            }\n                            if (options.defaults) {\n                                object.closed = false;\n                                object.mask = null;\n                                object.subtitle = null;\n                                object.aiFlag = null;\n                                object.playerConfig = null;\n                                object.sendBoxStyle = 0;\n                                object.allow = false;\n                                object.checkBox = \"\";\n                                object.checkBoxShowMsg = \"\";\n                                object.textPlaceholder = \"\";\n                                object.inputPlaceholder = \"\";\n                                object.expoReport = null;\n                                object.buzzwordConfig = null;\n                            }\n                            if (message.closed != null && message.hasOwnProperty(\"closed\"))\n                                object.closed = message.closed;\n                            if (message.mask != null && message.hasOwnProperty(\"mask\"))\n                                object.mask = $root.bilibili.community.service.dm.v1.VideoMask.toObject(message.mask, options);\n                            if (message.subtitle != null && message.hasOwnProperty(\"subtitle\"))\n                                object.subtitle = $root.bilibili.community.service.dm.v1.VideoSubtitle.toObject(message.subtitle, options);\n                            if (message.specialDms && message.specialDms.length) {\n                                object.specialDms = [];\n                                for (var j = 0; j < message.specialDms.length; ++j)\n                                    object.specialDms[j] = message.specialDms[j];\n                            }\n                            if (message.aiFlag != null && message.hasOwnProperty(\"aiFlag\"))\n                                object.aiFlag = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.toObject(message.aiFlag, options);\n                            if (message.playerConfig != null && message.hasOwnProperty(\"playerConfig\"))\n                                object.playerConfig = $root.bilibili.community.service.dm.v1.DanmuPlayerViewConfig.toObject(message.playerConfig, options);\n                            if (message.sendBoxStyle != null && message.hasOwnProperty(\"sendBoxStyle\"))\n                                object.sendBoxStyle = message.sendBoxStyle;\n                            if (message.allow != null && message.hasOwnProperty(\"allow\"))\n                                object.allow = message.allow;\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\"))\n                                object.checkBox = message.checkBox;\n                            if (message.checkBoxShowMsg != null && message.hasOwnProperty(\"checkBoxShowMsg\"))\n                                object.checkBoxShowMsg = message.checkBoxShowMsg;\n                            if (message.textPlaceholder != null && message.hasOwnProperty(\"textPlaceholder\"))\n                                object.textPlaceholder = message.textPlaceholder;\n                            if (message.inputPlaceholder != null && message.hasOwnProperty(\"inputPlaceholder\"))\n                                object.inputPlaceholder = message.inputPlaceholder;\n                            if (message.reportFilterContent && message.reportFilterContent.length) {\n                                object.reportFilterContent = [];\n                                for (var j = 0; j < message.reportFilterContent.length; ++j)\n                                    object.reportFilterContent[j] = message.reportFilterContent[j];\n                            }\n                            if (message.expoReport != null && message.hasOwnProperty(\"expoReport\"))\n                                object.expoReport = $root.bilibili.community.service.dm.v1.ExpoReport.toObject(message.expoReport, options);\n                            if (message.buzzwordConfig != null && message.hasOwnProperty(\"buzzwordConfig\"))\n                                object.buzzwordConfig = $root.bilibili.community.service.dm.v1.BuzzwordConfig.toObject(message.buzzwordConfig, options);\n                            if (message.expressions && message.expressions.length) {\n                                object.expressions = [];\n                                for (var j = 0; j < message.expressions.length; ++j)\n                                    object.expressions[j] = $root.bilibili.community.service.dm.v1.Expressions.toObject(message.expressions[j], options);\n                            }\n                            if (message.postPanel && message.postPanel.length) {\n                                object.postPanel = [];\n                                for (var j = 0; j < message.postPanel.length; ++j)\n                                    object.postPanel[j] = $root.bilibili.community.service.dm.v1.PostPanel.toObject(message.postPanel[j], options);\n                            }\n                            if (message.activityMeta && message.activityMeta.length) {\n                                object.activityMeta = [];\n                                for (var j = 0; j < message.activityMeta.length; ++j)\n                                    object.activityMeta[j] = message.activityMeta[j];\n                            }\n                            if (message.postPanel2 && message.postPanel2.length) {\n                                object.postPanel2 = [];\n                                for (var j = 0; j < message.postPanel2.length; ++j)\n                                    object.postPanel2[j] = $root.bilibili.community.service.dm.v1.PostPanelV2.toObject(message.postPanel2[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmViewReply to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmViewReply.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmViewReply\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmViewReply\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmViewReply.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmViewReply\";\n                        };\n\n                        return DmViewReply;\n                    })();\n\n                    v1.DmViewReq = (function() {\n\n                        /**\n                         * Properties of a DmViewReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmViewReq\n                         * @property {number|Long|null} [pid] DmViewReq pid\n                         * @property {number|Long|null} [oid] DmViewReq oid\n                         * @property {number|null} [type] DmViewReq type\n                         * @property {string|null} [spmid] DmViewReq spmid\n                         * @property {number|null} [isHardBoot] DmViewReq isHardBoot\n                         */\n\n                        /**\n                         * Constructs a new DmViewReq.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmViewReq.\n                         * @implements IDmViewReq\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmViewReq=} [properties] Properties to set\n                         */\n                        function DmViewReq(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmViewReq pid.\n                         * @member {number|Long} pid\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @instance\n                         */\n                        DmViewReq.prototype.pid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmViewReq oid.\n                         * @member {number|Long} oid\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @instance\n                         */\n                        DmViewReq.prototype.oid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmViewReq type.\n                         * @member {number} type\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @instance\n                         */\n                        DmViewReq.prototype.type = 0;\n\n                        /**\n                         * DmViewReq spmid.\n                         * @member {string} spmid\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @instance\n                         */\n                        DmViewReq.prototype.spmid = \"\";\n\n                        /**\n                         * DmViewReq isHardBoot.\n                         * @member {number} isHardBoot\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @instance\n                         */\n                        DmViewReq.prototype.isHardBoot = 0;\n\n                        /**\n                         * Creates a new DmViewReq instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmViewReq=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmViewReq} DmViewReq instance\n                         */\n                        DmViewReq.create = function create(properties) {\n                            return new DmViewReq(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmViewReq message. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReq.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmViewReq} message DmViewReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmViewReq.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.pid != null && Object.hasOwnProperty.call(message, \"pid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.pid);\n                            if (message.oid != null && Object.hasOwnProperty.call(message, \"oid\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.oid);\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.type);\n                            if (message.spmid != null && Object.hasOwnProperty.call(message, \"spmid\"))\n                                writer.uint32(/* id 4, wireType 2 =*/34).string(message.spmid);\n                            if (message.isHardBoot != null && Object.hasOwnProperty.call(message, \"isHardBoot\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.isHardBoot);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmViewReq message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmViewReq.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmViewReq} message DmViewReq message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmViewReq.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmViewReq message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmViewReq} DmViewReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmViewReq.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmViewReq();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.pid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.oid = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.spmid = reader.string();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.isHardBoot = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmViewReq message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmViewReq} DmViewReq\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmViewReq.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmViewReq message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmViewReq.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (!$util.isInteger(message.pid) && !(message.pid && $util.isInteger(message.pid.low) && $util.isInteger(message.pid.high)))\n                                    return \"pid: integer|Long expected\";\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (!$util.isInteger(message.oid) && !(message.oid && $util.isInteger(message.oid.low) && $util.isInteger(message.oid.high)))\n                                    return \"oid: integer|Long expected\";\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                if (!$util.isInteger(message.type))\n                                    return \"type: integer expected\";\n                            if (message.spmid != null && message.hasOwnProperty(\"spmid\"))\n                                if (!$util.isString(message.spmid))\n                                    return \"spmid: string expected\";\n                            if (message.isHardBoot != null && message.hasOwnProperty(\"isHardBoot\"))\n                                if (!$util.isInteger(message.isHardBoot))\n                                    return \"isHardBoot: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmViewReq message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmViewReq} DmViewReq\n                         */\n                        DmViewReq.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmViewReq)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmViewReq();\n                            if (object.pid != null)\n                                if ($util.Long)\n                                    (message.pid = $util.Long.fromValue(object.pid)).unsigned = false;\n                                else if (typeof object.pid === \"string\")\n                                    message.pid = parseInt(object.pid, 10);\n                                else if (typeof object.pid === \"number\")\n                                    message.pid = object.pid;\n                                else if (typeof object.pid === \"object\")\n                                    message.pid = new $util.LongBits(object.pid.low >>> 0, object.pid.high >>> 0).toNumber();\n                            if (object.oid != null)\n                                if ($util.Long)\n                                    (message.oid = $util.Long.fromValue(object.oid)).unsigned = false;\n                                else if (typeof object.oid === \"string\")\n                                    message.oid = parseInt(object.oid, 10);\n                                else if (typeof object.oid === \"number\")\n                                    message.oid = object.oid;\n                                else if (typeof object.oid === \"object\")\n                                    message.oid = new $util.LongBits(object.oid.low >>> 0, object.oid.high >>> 0).toNumber();\n                            if (object.type != null)\n                                message.type = object.type | 0;\n                            if (object.spmid != null)\n                                message.spmid = String(object.spmid);\n                            if (object.isHardBoot != null)\n                                message.isHardBoot = object.isHardBoot | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmViewReq message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmViewReq} message DmViewReq\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmViewReq.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.pid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.pid = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.oid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.oid = options.longs === String ? \"0\" : 0;\n                                object.type = 0;\n                                object.spmid = \"\";\n                                object.isHardBoot = 0;\n                            }\n                            if (message.pid != null && message.hasOwnProperty(\"pid\"))\n                                if (typeof message.pid === \"number\")\n                                    object.pid = options.longs === String ? String(message.pid) : message.pid;\n                                else\n                                    object.pid = options.longs === String ? $util.Long.prototype.toString.call(message.pid) : options.longs === Number ? new $util.LongBits(message.pid.low >>> 0, message.pid.high >>> 0).toNumber() : message.pid;\n                            if (message.oid != null && message.hasOwnProperty(\"oid\"))\n                                if (typeof message.oid === \"number\")\n                                    object.oid = options.longs === String ? String(message.oid) : message.oid;\n                                else\n                                    object.oid = options.longs === String ? $util.Long.prototype.toString.call(message.oid) : options.longs === Number ? new $util.LongBits(message.oid.low >>> 0, message.oid.high >>> 0).toNumber() : message.oid;\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = message.type;\n                            if (message.spmid != null && message.hasOwnProperty(\"spmid\"))\n                                object.spmid = message.spmid;\n                            if (message.isHardBoot != null && message.hasOwnProperty(\"isHardBoot\"))\n                                object.isHardBoot = message.isHardBoot;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmViewReq to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmViewReq.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmViewReq\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmViewReq\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmViewReq.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmViewReq\";\n                        };\n\n                        return DmViewReq;\n                    })();\n\n                    v1.DmWebViewReply = (function() {\n\n                        /**\n                         * Properties of a DmWebViewReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IDmWebViewReply\n                         * @property {number|null} [state] DmWebViewReply state\n                         * @property {string|null} [text] DmWebViewReply text\n                         * @property {string|null} [textSide] DmWebViewReply textSide\n                         * @property {bilibili.community.service.dm.v1.IDmSegConfig|null} [dmSge] DmWebViewReply dmSge\n                         * @property {bilibili.community.service.dm.v1.IDanmakuFlagConfig|null} [flag] DmWebViewReply flag\n                         * @property {Array.<string>|null} [specialDms] DmWebViewReply specialDms\n                         * @property {boolean|null} [checkBox] DmWebViewReply checkBox\n                         * @property {number|Long|null} [count] DmWebViewReply count\n                         * @property {Array.<bilibili.community.service.dm.v1.ICommandDm>|null} [commandDms] DmWebViewReply commandDms\n                         * @property {bilibili.community.service.dm.v1.IDanmuWebPlayerConfig|null} [playerConfig] DmWebViewReply playerConfig\n                         * @property {Array.<string>|null} [reportFilterContent] DmWebViewReply reportFilterContent\n                         * @property {Array.<bilibili.community.service.dm.v1.IExpressions>|null} [expressions] DmWebViewReply expressions\n                         * @property {Array.<bilibili.community.service.dm.v1.IPostPanel>|null} [postPanel] DmWebViewReply postPanel\n                         * @property {Array.<string>|null} [activityMeta] DmWebViewReply activityMeta\n                         */\n\n                        /**\n                         * Constructs a new DmWebViewReply.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a DmWebViewReply.\n                         * @implements IDmWebViewReply\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IDmWebViewReply=} [properties] Properties to set\n                         */\n                        function DmWebViewReply(properties) {\n                            this.specialDms = [];\n                            this.commandDms = [];\n                            this.reportFilterContent = [];\n                            this.expressions = [];\n                            this.postPanel = [];\n                            this.activityMeta = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * DmWebViewReply state.\n                         * @member {number} state\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.state = 0;\n\n                        /**\n                         * DmWebViewReply text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.text = \"\";\n\n                        /**\n                         * DmWebViewReply textSide.\n                         * @member {string} textSide\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.textSide = \"\";\n\n                        /**\n                         * DmWebViewReply dmSge.\n                         * @member {bilibili.community.service.dm.v1.IDmSegConfig|null|undefined} dmSge\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.dmSge = null;\n\n                        /**\n                         * DmWebViewReply flag.\n                         * @member {bilibili.community.service.dm.v1.IDanmakuFlagConfig|null|undefined} flag\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.flag = null;\n\n                        /**\n                         * DmWebViewReply specialDms.\n                         * @member {Array.<string>} specialDms\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.specialDms = $util.emptyArray;\n\n                        /**\n                         * DmWebViewReply checkBox.\n                         * @member {boolean} checkBox\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.checkBox = false;\n\n                        /**\n                         * DmWebViewReply count.\n                         * @member {number|Long} count\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.count = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * DmWebViewReply commandDms.\n                         * @member {Array.<bilibili.community.service.dm.v1.ICommandDm>} commandDms\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.commandDms = $util.emptyArray;\n\n                        /**\n                         * DmWebViewReply playerConfig.\n                         * @member {bilibili.community.service.dm.v1.IDanmuWebPlayerConfig|null|undefined} playerConfig\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.playerConfig = null;\n\n                        /**\n                         * DmWebViewReply reportFilterContent.\n                         * @member {Array.<string>} reportFilterContent\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.reportFilterContent = $util.emptyArray;\n\n                        /**\n                         * DmWebViewReply expressions.\n                         * @member {Array.<bilibili.community.service.dm.v1.IExpressions>} expressions\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.expressions = $util.emptyArray;\n\n                        /**\n                         * DmWebViewReply postPanel.\n                         * @member {Array.<bilibili.community.service.dm.v1.IPostPanel>} postPanel\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.postPanel = $util.emptyArray;\n\n                        /**\n                         * DmWebViewReply activityMeta.\n                         * @member {Array.<string>} activityMeta\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         */\n                        DmWebViewReply.prototype.activityMeta = $util.emptyArray;\n\n                        /**\n                         * Creates a new DmWebViewReply instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmWebViewReply=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.DmWebViewReply} DmWebViewReply instance\n                         */\n                        DmWebViewReply.create = function create(properties) {\n                            return new DmWebViewReply(properties);\n                        };\n\n                        /**\n                         * Encodes the specified DmWebViewReply message. Does not implicitly {@link bilibili.community.service.dm.v1.DmWebViewReply.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmWebViewReply} message DmWebViewReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmWebViewReply.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.state != null && Object.hasOwnProperty.call(message, \"state\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.state);\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.text);\n                            if (message.textSide != null && Object.hasOwnProperty.call(message, \"textSide\"))\n                                writer.uint32(/* id 3, wireType 2 =*/26).string(message.textSide);\n                            if (message.dmSge != null && Object.hasOwnProperty.call(message, \"dmSge\"))\n                                $root.bilibili.community.service.dm.v1.DmSegConfig.encode(message.dmSge, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim();\n                            if (message.flag != null && Object.hasOwnProperty.call(message, \"flag\"))\n                                $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.encode(message.flag, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim();\n                            if (message.specialDms != null && message.specialDms.length)\n                                for (var i = 0; i < message.specialDms.length; ++i)\n                                    writer.uint32(/* id 6, wireType 2 =*/50).string(message.specialDms[i]);\n                            if (message.checkBox != null && Object.hasOwnProperty.call(message, \"checkBox\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).bool(message.checkBox);\n                            if (message.count != null && Object.hasOwnProperty.call(message, \"count\"))\n                                writer.uint32(/* id 8, wireType 0 =*/64).int64(message.count);\n                            if (message.commandDms != null && message.commandDms.length)\n                                for (var i = 0; i < message.commandDms.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.CommandDm.encode(message.commandDms[i], writer.uint32(/* id 9, wireType 2 =*/74).fork()).ldelim();\n                            if (message.playerConfig != null && Object.hasOwnProperty.call(message, \"playerConfig\"))\n                                $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig.encode(message.playerConfig, writer.uint32(/* id 10, wireType 2 =*/82).fork()).ldelim();\n                            if (message.reportFilterContent != null && message.reportFilterContent.length)\n                                for (var i = 0; i < message.reportFilterContent.length; ++i)\n                                    writer.uint32(/* id 11, wireType 2 =*/90).string(message.reportFilterContent[i]);\n                            if (message.expressions != null && message.expressions.length)\n                                for (var i = 0; i < message.expressions.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.Expressions.encode(message.expressions[i], writer.uint32(/* id 12, wireType 2 =*/98).fork()).ldelim();\n                            if (message.postPanel != null && message.postPanel.length)\n                                for (var i = 0; i < message.postPanel.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.PostPanel.encode(message.postPanel[i], writer.uint32(/* id 13, wireType 2 =*/106).fork()).ldelim();\n                            if (message.activityMeta != null && message.activityMeta.length)\n                                for (var i = 0; i < message.activityMeta.length; ++i)\n                                    writer.uint32(/* id 14, wireType 2 =*/114).string(message.activityMeta[i]);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified DmWebViewReply message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.DmWebViewReply.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IDmWebViewReply} message DmWebViewReply message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        DmWebViewReply.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a DmWebViewReply message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.DmWebViewReply} DmWebViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmWebViewReply.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.DmWebViewReply();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.state = reader.int32();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.textSide = reader.string();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.dmSge = $root.bilibili.community.service.dm.v1.DmSegConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 5: {\n                                        message.flag = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 6: {\n                                        if (!(message.specialDms && message.specialDms.length))\n                                            message.specialDms = [];\n                                        message.specialDms.push(reader.string());\n                                        break;\n                                    }\n                                case 7: {\n                                        message.checkBox = reader.bool();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.count = reader.int64();\n                                        break;\n                                    }\n                                case 9: {\n                                        if (!(message.commandDms && message.commandDms.length))\n                                            message.commandDms = [];\n                                        message.commandDms.push($root.bilibili.community.service.dm.v1.CommandDm.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 10: {\n                                        message.playerConfig = $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 11: {\n                                        if (!(message.reportFilterContent && message.reportFilterContent.length))\n                                            message.reportFilterContent = [];\n                                        message.reportFilterContent.push(reader.string());\n                                        break;\n                                    }\n                                case 12: {\n                                        if (!(message.expressions && message.expressions.length))\n                                            message.expressions = [];\n                                        message.expressions.push($root.bilibili.community.service.dm.v1.Expressions.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 13: {\n                                        if (!(message.postPanel && message.postPanel.length))\n                                            message.postPanel = [];\n                                        message.postPanel.push($root.bilibili.community.service.dm.v1.PostPanel.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 14: {\n                                        if (!(message.activityMeta && message.activityMeta.length))\n                                            message.activityMeta = [];\n                                        message.activityMeta.push(reader.string());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a DmWebViewReply message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.DmWebViewReply} DmWebViewReply\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        DmWebViewReply.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a DmWebViewReply message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        DmWebViewReply.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.state != null && message.hasOwnProperty(\"state\"))\n                                if (!$util.isInteger(message.state))\n                                    return \"state: integer expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.textSide != null && message.hasOwnProperty(\"textSide\"))\n                                if (!$util.isString(message.textSide))\n                                    return \"textSide: string expected\";\n                            if (message.dmSge != null && message.hasOwnProperty(\"dmSge\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DmSegConfig.verify(message.dmSge);\n                                if (error)\n                                    return \"dmSge.\" + error;\n                            }\n                            if (message.flag != null && message.hasOwnProperty(\"flag\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.verify(message.flag);\n                                if (error)\n                                    return \"flag.\" + error;\n                            }\n                            if (message.specialDms != null && message.hasOwnProperty(\"specialDms\")) {\n                                if (!Array.isArray(message.specialDms))\n                                    return \"specialDms: array expected\";\n                                for (var i = 0; i < message.specialDms.length; ++i)\n                                    if (!$util.isString(message.specialDms[i]))\n                                        return \"specialDms: string[] expected\";\n                            }\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\"))\n                                if (typeof message.checkBox !== \"boolean\")\n                                    return \"checkBox: boolean expected\";\n                            if (message.count != null && message.hasOwnProperty(\"count\"))\n                                if (!$util.isInteger(message.count) && !(message.count && $util.isInteger(message.count.low) && $util.isInteger(message.count.high)))\n                                    return \"count: integer|Long expected\";\n                            if (message.commandDms != null && message.hasOwnProperty(\"commandDms\")) {\n                                if (!Array.isArray(message.commandDms))\n                                    return \"commandDms: array expected\";\n                                for (var i = 0; i < message.commandDms.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.CommandDm.verify(message.commandDms[i]);\n                                    if (error)\n                                        return \"commandDms.\" + error;\n                                }\n                            }\n                            if (message.playerConfig != null && message.hasOwnProperty(\"playerConfig\")) {\n                                var error = $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig.verify(message.playerConfig);\n                                if (error)\n                                    return \"playerConfig.\" + error;\n                            }\n                            if (message.reportFilterContent != null && message.hasOwnProperty(\"reportFilterContent\")) {\n                                if (!Array.isArray(message.reportFilterContent))\n                                    return \"reportFilterContent: array expected\";\n                                for (var i = 0; i < message.reportFilterContent.length; ++i)\n                                    if (!$util.isString(message.reportFilterContent[i]))\n                                        return \"reportFilterContent: string[] expected\";\n                            }\n                            if (message.expressions != null && message.hasOwnProperty(\"expressions\")) {\n                                if (!Array.isArray(message.expressions))\n                                    return \"expressions: array expected\";\n                                for (var i = 0; i < message.expressions.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.Expressions.verify(message.expressions[i]);\n                                    if (error)\n                                        return \"expressions.\" + error;\n                                }\n                            }\n                            if (message.postPanel != null && message.hasOwnProperty(\"postPanel\")) {\n                                if (!Array.isArray(message.postPanel))\n                                    return \"postPanel: array expected\";\n                                for (var i = 0; i < message.postPanel.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.PostPanel.verify(message.postPanel[i]);\n                                    if (error)\n                                        return \"postPanel.\" + error;\n                                }\n                            }\n                            if (message.activityMeta != null && message.hasOwnProperty(\"activityMeta\")) {\n                                if (!Array.isArray(message.activityMeta))\n                                    return \"activityMeta: array expected\";\n                                for (var i = 0; i < message.activityMeta.length; ++i)\n                                    if (!$util.isString(message.activityMeta[i]))\n                                        return \"activityMeta: string[] expected\";\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a DmWebViewReply message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.DmWebViewReply} DmWebViewReply\n                         */\n                        DmWebViewReply.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.DmWebViewReply)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.DmWebViewReply();\n                            if (object.state != null)\n                                message.state = object.state | 0;\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.textSide != null)\n                                message.textSide = String(object.textSide);\n                            if (object.dmSge != null) {\n                                if (typeof object.dmSge !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.dmSge: object expected\");\n                                message.dmSge = $root.bilibili.community.service.dm.v1.DmSegConfig.fromObject(object.dmSge);\n                            }\n                            if (object.flag != null) {\n                                if (typeof object.flag !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.flag: object expected\");\n                                message.flag = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.fromObject(object.flag);\n                            }\n                            if (object.specialDms) {\n                                if (!Array.isArray(object.specialDms))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.specialDms: array expected\");\n                                message.specialDms = [];\n                                for (var i = 0; i < object.specialDms.length; ++i)\n                                    message.specialDms[i] = String(object.specialDms[i]);\n                            }\n                            if (object.checkBox != null)\n                                message.checkBox = Boolean(object.checkBox);\n                            if (object.count != null)\n                                if ($util.Long)\n                                    (message.count = $util.Long.fromValue(object.count)).unsigned = false;\n                                else if (typeof object.count === \"string\")\n                                    message.count = parseInt(object.count, 10);\n                                else if (typeof object.count === \"number\")\n                                    message.count = object.count;\n                                else if (typeof object.count === \"object\")\n                                    message.count = new $util.LongBits(object.count.low >>> 0, object.count.high >>> 0).toNumber();\n                            if (object.commandDms) {\n                                if (!Array.isArray(object.commandDms))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.commandDms: array expected\");\n                                message.commandDms = [];\n                                for (var i = 0; i < object.commandDms.length; ++i) {\n                                    if (typeof object.commandDms[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.commandDms: object expected\");\n                                    message.commandDms[i] = $root.bilibili.community.service.dm.v1.CommandDm.fromObject(object.commandDms[i]);\n                                }\n                            }\n                            if (object.playerConfig != null) {\n                                if (typeof object.playerConfig !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.playerConfig: object expected\");\n                                message.playerConfig = $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig.fromObject(object.playerConfig);\n                            }\n                            if (object.reportFilterContent) {\n                                if (!Array.isArray(object.reportFilterContent))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.reportFilterContent: array expected\");\n                                message.reportFilterContent = [];\n                                for (var i = 0; i < object.reportFilterContent.length; ++i)\n                                    message.reportFilterContent[i] = String(object.reportFilterContent[i]);\n                            }\n                            if (object.expressions) {\n                                if (!Array.isArray(object.expressions))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.expressions: array expected\");\n                                message.expressions = [];\n                                for (var i = 0; i < object.expressions.length; ++i) {\n                                    if (typeof object.expressions[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.expressions: object expected\");\n                                    message.expressions[i] = $root.bilibili.community.service.dm.v1.Expressions.fromObject(object.expressions[i]);\n                                }\n                            }\n                            if (object.postPanel) {\n                                if (!Array.isArray(object.postPanel))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.postPanel: array expected\");\n                                message.postPanel = [];\n                                for (var i = 0; i < object.postPanel.length; ++i) {\n                                    if (typeof object.postPanel[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.postPanel: object expected\");\n                                    message.postPanel[i] = $root.bilibili.community.service.dm.v1.PostPanel.fromObject(object.postPanel[i]);\n                                }\n                            }\n                            if (object.activityMeta) {\n                                if (!Array.isArray(object.activityMeta))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.DmWebViewReply.activityMeta: array expected\");\n                                message.activityMeta = [];\n                                for (var i = 0; i < object.activityMeta.length; ++i)\n                                    message.activityMeta[i] = String(object.activityMeta[i]);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a DmWebViewReply message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.DmWebViewReply} message DmWebViewReply\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        DmWebViewReply.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.specialDms = [];\n                                object.commandDms = [];\n                                object.reportFilterContent = [];\n                                object.expressions = [];\n                                object.postPanel = [];\n                                object.activityMeta = [];\n                            }\n                            if (options.defaults) {\n                                object.state = 0;\n                                object.text = \"\";\n                                object.textSide = \"\";\n                                object.dmSge = null;\n                                object.flag = null;\n                                object.checkBox = false;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.count = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.count = options.longs === String ? \"0\" : 0;\n                                object.playerConfig = null;\n                            }\n                            if (message.state != null && message.hasOwnProperty(\"state\"))\n                                object.state = message.state;\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.textSide != null && message.hasOwnProperty(\"textSide\"))\n                                object.textSide = message.textSide;\n                            if (message.dmSge != null && message.hasOwnProperty(\"dmSge\"))\n                                object.dmSge = $root.bilibili.community.service.dm.v1.DmSegConfig.toObject(message.dmSge, options);\n                            if (message.flag != null && message.hasOwnProperty(\"flag\"))\n                                object.flag = $root.bilibili.community.service.dm.v1.DanmakuFlagConfig.toObject(message.flag, options);\n                            if (message.specialDms && message.specialDms.length) {\n                                object.specialDms = [];\n                                for (var j = 0; j < message.specialDms.length; ++j)\n                                    object.specialDms[j] = message.specialDms[j];\n                            }\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\"))\n                                object.checkBox = message.checkBox;\n                            if (message.count != null && message.hasOwnProperty(\"count\"))\n                                if (typeof message.count === \"number\")\n                                    object.count = options.longs === String ? String(message.count) : message.count;\n                                else\n                                    object.count = options.longs === String ? $util.Long.prototype.toString.call(message.count) : options.longs === Number ? new $util.LongBits(message.count.low >>> 0, message.count.high >>> 0).toNumber() : message.count;\n                            if (message.commandDms && message.commandDms.length) {\n                                object.commandDms = [];\n                                for (var j = 0; j < message.commandDms.length; ++j)\n                                    object.commandDms[j] = $root.bilibili.community.service.dm.v1.CommandDm.toObject(message.commandDms[j], options);\n                            }\n                            if (message.playerConfig != null && message.hasOwnProperty(\"playerConfig\"))\n                                object.playerConfig = $root.bilibili.community.service.dm.v1.DanmuWebPlayerConfig.toObject(message.playerConfig, options);\n                            if (message.reportFilterContent && message.reportFilterContent.length) {\n                                object.reportFilterContent = [];\n                                for (var j = 0; j < message.reportFilterContent.length; ++j)\n                                    object.reportFilterContent[j] = message.reportFilterContent[j];\n                            }\n                            if (message.expressions && message.expressions.length) {\n                                object.expressions = [];\n                                for (var j = 0; j < message.expressions.length; ++j)\n                                    object.expressions[j] = $root.bilibili.community.service.dm.v1.Expressions.toObject(message.expressions[j], options);\n                            }\n                            if (message.postPanel && message.postPanel.length) {\n                                object.postPanel = [];\n                                for (var j = 0; j < message.postPanel.length; ++j)\n                                    object.postPanel[j] = $root.bilibili.community.service.dm.v1.PostPanel.toObject(message.postPanel[j], options);\n                            }\n                            if (message.activityMeta && message.activityMeta.length) {\n                                object.activityMeta = [];\n                                for (var j = 0; j < message.activityMeta.length; ++j)\n                                    object.activityMeta[j] = message.activityMeta[j];\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this DmWebViewReply to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        DmWebViewReply.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for DmWebViewReply\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.DmWebViewReply\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        DmWebViewReply.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.DmWebViewReply\";\n                        };\n\n                        return DmWebViewReply;\n                    })();\n\n                    v1.ExpoReport = (function() {\n\n                        /**\n                         * Properties of an ExpoReport.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IExpoReport\n                         * @property {boolean|null} [shouldReportAtEnd] ExpoReport shouldReportAtEnd\n                         */\n\n                        /**\n                         * Constructs a new ExpoReport.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents an ExpoReport.\n                         * @implements IExpoReport\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IExpoReport=} [properties] Properties to set\n                         */\n                        function ExpoReport(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * ExpoReport shouldReportAtEnd.\n                         * @member {boolean} shouldReportAtEnd\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @instance\n                         */\n                        ExpoReport.prototype.shouldReportAtEnd = false;\n\n                        /**\n                         * Creates a new ExpoReport instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpoReport=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.ExpoReport} ExpoReport instance\n                         */\n                        ExpoReport.create = function create(properties) {\n                            return new ExpoReport(properties);\n                        };\n\n                        /**\n                         * Encodes the specified ExpoReport message. Does not implicitly {@link bilibili.community.service.dm.v1.ExpoReport.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpoReport} message ExpoReport message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ExpoReport.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.shouldReportAtEnd != null && Object.hasOwnProperty.call(message, \"shouldReportAtEnd\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.shouldReportAtEnd);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified ExpoReport message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ExpoReport.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpoReport} message ExpoReport message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ExpoReport.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes an ExpoReport message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.ExpoReport} ExpoReport\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ExpoReport.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.ExpoReport();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.shouldReportAtEnd = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes an ExpoReport message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.ExpoReport} ExpoReport\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ExpoReport.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies an ExpoReport message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        ExpoReport.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.shouldReportAtEnd != null && message.hasOwnProperty(\"shouldReportAtEnd\"))\n                                if (typeof message.shouldReportAtEnd !== \"boolean\")\n                                    return \"shouldReportAtEnd: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates an ExpoReport message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.ExpoReport} ExpoReport\n                         */\n                        ExpoReport.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.ExpoReport)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.ExpoReport();\n                            if (object.shouldReportAtEnd != null)\n                                message.shouldReportAtEnd = Boolean(object.shouldReportAtEnd);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from an ExpoReport message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ExpoReport} message ExpoReport\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        ExpoReport.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.shouldReportAtEnd = false;\n                            if (message.shouldReportAtEnd != null && message.hasOwnProperty(\"shouldReportAtEnd\"))\n                                object.shouldReportAtEnd = message.shouldReportAtEnd;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this ExpoReport to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        ExpoReport.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for ExpoReport\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.ExpoReport\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        ExpoReport.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.ExpoReport\";\n                        };\n\n                        return ExpoReport;\n                    })();\n\n                    /**\n                     * ExposureType enum.\n                     * @name bilibili.community.service.dm.v1.ExposureType\n                     * @enum {number}\n                     * @property {number} ExposureTypeNone=0 ExposureTypeNone value\n                     * @property {number} ExposureTypeDMSend=1 ExposureTypeDMSend value\n                     */\n                    v1.ExposureType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"ExposureTypeNone\"] = 0;\n                        values[valuesById[1] = \"ExposureTypeDMSend\"] = 1;\n                        return values;\n                    })();\n\n                    v1.Expression = (function() {\n\n                        /**\n                         * Properties of an Expression.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IExpression\n                         * @property {Array.<string>|null} [keyword] Expression keyword\n                         * @property {string|null} [url] Expression url\n                         * @property {Array.<bilibili.community.service.dm.v1.IPeriod>|null} [period] Expression period\n                         */\n\n                        /**\n                         * Constructs a new Expression.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents an Expression.\n                         * @implements IExpression\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IExpression=} [properties] Properties to set\n                         */\n                        function Expression(properties) {\n                            this.keyword = [];\n                            this.period = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Expression keyword.\n                         * @member {Array.<string>} keyword\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @instance\n                         */\n                        Expression.prototype.keyword = $util.emptyArray;\n\n                        /**\n                         * Expression url.\n                         * @member {string} url\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @instance\n                         */\n                        Expression.prototype.url = \"\";\n\n                        /**\n                         * Expression period.\n                         * @member {Array.<bilibili.community.service.dm.v1.IPeriod>} period\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @instance\n                         */\n                        Expression.prototype.period = $util.emptyArray;\n\n                        /**\n                         * Creates a new Expression instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpression=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Expression} Expression instance\n                         */\n                        Expression.create = function create(properties) {\n                            return new Expression(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Expression message. Does not implicitly {@link bilibili.community.service.dm.v1.Expression.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpression} message Expression message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Expression.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.keyword != null && message.keyword.length)\n                                for (var i = 0; i < message.keyword.length; ++i)\n                                    writer.uint32(/* id 1, wireType 2 =*/10).string(message.keyword[i]);\n                            if (message.url != null && Object.hasOwnProperty.call(message, \"url\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.url);\n                            if (message.period != null && message.period.length)\n                                for (var i = 0; i < message.period.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.Period.encode(message.period[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Expression message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Expression.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpression} message Expression message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Expression.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes an Expression message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Expression} Expression\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Expression.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Expression();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.keyword && message.keyword.length))\n                                            message.keyword = [];\n                                        message.keyword.push(reader.string());\n                                        break;\n                                    }\n                                case 2: {\n                                        message.url = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        if (!(message.period && message.period.length))\n                                            message.period = [];\n                                        message.period.push($root.bilibili.community.service.dm.v1.Period.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes an Expression message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Expression} Expression\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Expression.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies an Expression message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Expression.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.keyword != null && message.hasOwnProperty(\"keyword\")) {\n                                if (!Array.isArray(message.keyword))\n                                    return \"keyword: array expected\";\n                                for (var i = 0; i < message.keyword.length; ++i)\n                                    if (!$util.isString(message.keyword[i]))\n                                        return \"keyword: string[] expected\";\n                            }\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                if (!$util.isString(message.url))\n                                    return \"url: string expected\";\n                            if (message.period != null && message.hasOwnProperty(\"period\")) {\n                                if (!Array.isArray(message.period))\n                                    return \"period: array expected\";\n                                for (var i = 0; i < message.period.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.Period.verify(message.period[i]);\n                                    if (error)\n                                        return \"period.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates an Expression message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Expression} Expression\n                         */\n                        Expression.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Expression)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Expression();\n                            if (object.keyword) {\n                                if (!Array.isArray(object.keyword))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.Expression.keyword: array expected\");\n                                message.keyword = [];\n                                for (var i = 0; i < object.keyword.length; ++i)\n                                    message.keyword[i] = String(object.keyword[i]);\n                            }\n                            if (object.url != null)\n                                message.url = String(object.url);\n                            if (object.period) {\n                                if (!Array.isArray(object.period))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.Expression.period: array expected\");\n                                message.period = [];\n                                for (var i = 0; i < object.period.length; ++i) {\n                                    if (typeof object.period[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.Expression.period: object expected\");\n                                    message.period[i] = $root.bilibili.community.service.dm.v1.Period.fromObject(object.period[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from an Expression message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Expression} message Expression\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Expression.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.keyword = [];\n                                object.period = [];\n                            }\n                            if (options.defaults)\n                                object.url = \"\";\n                            if (message.keyword && message.keyword.length) {\n                                object.keyword = [];\n                                for (var j = 0; j < message.keyword.length; ++j)\n                                    object.keyword[j] = message.keyword[j];\n                            }\n                            if (message.url != null && message.hasOwnProperty(\"url\"))\n                                object.url = message.url;\n                            if (message.period && message.period.length) {\n                                object.period = [];\n                                for (var j = 0; j < message.period.length; ++j)\n                                    object.period[j] = $root.bilibili.community.service.dm.v1.Period.toObject(message.period[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Expression to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Expression.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Expression\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Expression\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Expression.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Expression\";\n                        };\n\n                        return Expression;\n                    })();\n\n                    v1.Expressions = (function() {\n\n                        /**\n                         * Properties of an Expressions.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IExpressions\n                         * @property {Array.<bilibili.community.service.dm.v1.IExpression>|null} [data] Expressions data\n                         */\n\n                        /**\n                         * Constructs a new Expressions.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents an Expressions.\n                         * @implements IExpressions\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IExpressions=} [properties] Properties to set\n                         */\n                        function Expressions(properties) {\n                            this.data = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Expressions data.\n                         * @member {Array.<bilibili.community.service.dm.v1.IExpression>} data\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @instance\n                         */\n                        Expressions.prototype.data = $util.emptyArray;\n\n                        /**\n                         * Creates a new Expressions instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpressions=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Expressions} Expressions instance\n                         */\n                        Expressions.create = function create(properties) {\n                            return new Expressions(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Expressions message. Does not implicitly {@link bilibili.community.service.dm.v1.Expressions.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpressions} message Expressions message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Expressions.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.data != null && message.data.length)\n                                for (var i = 0; i < message.data.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.Expression.encode(message.data[i], writer.uint32(/* id 1, wireType 2 =*/10).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Expressions message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Expressions.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IExpressions} message Expressions message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Expressions.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes an Expressions message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Expressions} Expressions\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Expressions.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Expressions();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.data && message.data.length))\n                                            message.data = [];\n                                        message.data.push($root.bilibili.community.service.dm.v1.Expression.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes an Expressions message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Expressions} Expressions\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Expressions.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies an Expressions message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Expressions.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.data != null && message.hasOwnProperty(\"data\")) {\n                                if (!Array.isArray(message.data))\n                                    return \"data: array expected\";\n                                for (var i = 0; i < message.data.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.Expression.verify(message.data[i]);\n                                    if (error)\n                                        return \"data.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates an Expressions message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Expressions} Expressions\n                         */\n                        Expressions.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Expressions)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Expressions();\n                            if (object.data) {\n                                if (!Array.isArray(object.data))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.Expressions.data: array expected\");\n                                message.data = [];\n                                for (var i = 0; i < object.data.length; ++i) {\n                                    if (typeof object.data[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.Expressions.data: object expected\");\n                                    message.data[i] = $root.bilibili.community.service.dm.v1.Expression.fromObject(object.data[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from an Expressions message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Expressions} message Expressions\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Expressions.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.data = [];\n                            if (message.data && message.data.length) {\n                                object.data = [];\n                                for (var j = 0; j < message.data.length; ++j)\n                                    object.data[j] = $root.bilibili.community.service.dm.v1.Expression.toObject(message.data[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Expressions to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Expressions.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Expressions\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Expressions\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Expressions.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Expressions\";\n                        };\n\n                        return Expressions;\n                    })();\n\n                    v1.InlinePlayerDanmakuSwitch = (function() {\n\n                        /**\n                         * Properties of an InlinePlayerDanmakuSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IInlinePlayerDanmakuSwitch\n                         * @property {boolean|null} [value] InlinePlayerDanmakuSwitch value\n                         */\n\n                        /**\n                         * Constructs a new InlinePlayerDanmakuSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents an InlinePlayerDanmakuSwitch.\n                         * @implements IInlinePlayerDanmakuSwitch\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch=} [properties] Properties to set\n                         */\n                        function InlinePlayerDanmakuSwitch(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * InlinePlayerDanmakuSwitch value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @instance\n                         */\n                        InlinePlayerDanmakuSwitch.prototype.value = false;\n\n                        /**\n                         * Creates a new InlinePlayerDanmakuSwitch instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch} InlinePlayerDanmakuSwitch instance\n                         */\n                        InlinePlayerDanmakuSwitch.create = function create(properties) {\n                            return new InlinePlayerDanmakuSwitch(properties);\n                        };\n\n                        /**\n                         * Encodes the specified InlinePlayerDanmakuSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch} message InlinePlayerDanmakuSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        InlinePlayerDanmakuSwitch.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified InlinePlayerDanmakuSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IInlinePlayerDanmakuSwitch} message InlinePlayerDanmakuSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        InlinePlayerDanmakuSwitch.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes an InlinePlayerDanmakuSwitch message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch} InlinePlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        InlinePlayerDanmakuSwitch.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes an InlinePlayerDanmakuSwitch message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch} InlinePlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        InlinePlayerDanmakuSwitch.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies an InlinePlayerDanmakuSwitch message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        InlinePlayerDanmakuSwitch.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates an InlinePlayerDanmakuSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch} InlinePlayerDanmakuSwitch\n                         */\n                        InlinePlayerDanmakuSwitch.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from an InlinePlayerDanmakuSwitch message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch} message InlinePlayerDanmakuSwitch\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        InlinePlayerDanmakuSwitch.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this InlinePlayerDanmakuSwitch to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        InlinePlayerDanmakuSwitch.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for InlinePlayerDanmakuSwitch\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        InlinePlayerDanmakuSwitch.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.InlinePlayerDanmakuSwitch\";\n                        };\n\n                        return InlinePlayerDanmakuSwitch;\n                    })();\n\n                    v1.Label = (function() {\n\n                        /**\n                         * Properties of a Label.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ILabel\n                         * @property {string|null} [title] Label title\n                         * @property {Array.<string>|null} [content] Label content\n                         */\n\n                        /**\n                         * Constructs a new Label.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a Label.\n                         * @implements ILabel\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ILabel=} [properties] Properties to set\n                         */\n                        function Label(properties) {\n                            this.content = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Label title.\n                         * @member {string} title\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @instance\n                         */\n                        Label.prototype.title = \"\";\n\n                        /**\n                         * Label content.\n                         * @member {Array.<string>} content\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @instance\n                         */\n                        Label.prototype.content = $util.emptyArray;\n\n                        /**\n                         * Creates a new Label instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ILabel=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Label} Label instance\n                         */\n                        Label.create = function create(properties) {\n                            return new Label(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Label message. Does not implicitly {@link bilibili.community.service.dm.v1.Label.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ILabel} message Label message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Label.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.title != null && Object.hasOwnProperty.call(message, \"title\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.title);\n                            if (message.content != null && message.content.length)\n                                for (var i = 0; i < message.content.length; ++i)\n                                    writer.uint32(/* id 2, wireType 2 =*/18).string(message.content[i]);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Label message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Label.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ILabel} message Label message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Label.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a Label message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Label} Label\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Label.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Label();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.title = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.content && message.content.length))\n                                            message.content = [];\n                                        message.content.push(reader.string());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a Label message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Label} Label\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Label.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a Label message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Label.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.title != null && message.hasOwnProperty(\"title\"))\n                                if (!$util.isString(message.title))\n                                    return \"title: string expected\";\n                            if (message.content != null && message.hasOwnProperty(\"content\")) {\n                                if (!Array.isArray(message.content))\n                                    return \"content: array expected\";\n                                for (var i = 0; i < message.content.length; ++i)\n                                    if (!$util.isString(message.content[i]))\n                                        return \"content: string[] expected\";\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a Label message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Label} Label\n                         */\n                        Label.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Label)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Label();\n                            if (object.title != null)\n                                message.title = String(object.title);\n                            if (object.content) {\n                                if (!Array.isArray(object.content))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.Label.content: array expected\");\n                                message.content = [];\n                                for (var i = 0; i < object.content.length; ++i)\n                                    message.content[i] = String(object.content[i]);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a Label message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Label} message Label\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Label.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.content = [];\n                            if (options.defaults)\n                                object.title = \"\";\n                            if (message.title != null && message.hasOwnProperty(\"title\"))\n                                object.title = message.title;\n                            if (message.content && message.content.length) {\n                                object.content = [];\n                                for (var j = 0; j < message.content.length; ++j)\n                                    object.content[j] = message.content[j];\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Label to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Label.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Label\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Label\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Label.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Label\";\n                        };\n\n                        return Label;\n                    })();\n\n                    v1.LabelV2 = (function() {\n\n                        /**\n                         * Properties of a LabelV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ILabelV2\n                         * @property {string|null} [title] LabelV2 title\n                         * @property {Array.<string>|null} [content] LabelV2 content\n                         * @property {boolean|null} [exposureOnce] LabelV2 exposureOnce\n                         * @property {number|null} [exposureType] LabelV2 exposureType\n                         */\n\n                        /**\n                         * Constructs a new LabelV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a LabelV2.\n                         * @implements ILabelV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ILabelV2=} [properties] Properties to set\n                         */\n                        function LabelV2(properties) {\n                            this.content = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * LabelV2 title.\n                         * @member {string} title\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @instance\n                         */\n                        LabelV2.prototype.title = \"\";\n\n                        /**\n                         * LabelV2 content.\n                         * @member {Array.<string>} content\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @instance\n                         */\n                        LabelV2.prototype.content = $util.emptyArray;\n\n                        /**\n                         * LabelV2 exposureOnce.\n                         * @member {boolean} exposureOnce\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @instance\n                         */\n                        LabelV2.prototype.exposureOnce = false;\n\n                        /**\n                         * LabelV2 exposureType.\n                         * @member {number} exposureType\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @instance\n                         */\n                        LabelV2.prototype.exposureType = 0;\n\n                        /**\n                         * Creates a new LabelV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ILabelV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.LabelV2} LabelV2 instance\n                         */\n                        LabelV2.create = function create(properties) {\n                            return new LabelV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified LabelV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.LabelV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ILabelV2} message LabelV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        LabelV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.title != null && Object.hasOwnProperty.call(message, \"title\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.title);\n                            if (message.content != null && message.content.length)\n                                for (var i = 0; i < message.content.length; ++i)\n                                    writer.uint32(/* id 2, wireType 2 =*/18).string(message.content[i]);\n                            if (message.exposureOnce != null && Object.hasOwnProperty.call(message, \"exposureOnce\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).bool(message.exposureOnce);\n                            if (message.exposureType != null && Object.hasOwnProperty.call(message, \"exposureType\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int32(message.exposureType);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified LabelV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.LabelV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ILabelV2} message LabelV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        LabelV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a LabelV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.LabelV2} LabelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        LabelV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.LabelV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.title = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.content && message.content.length))\n                                            message.content = [];\n                                        message.content.push(reader.string());\n                                        break;\n                                    }\n                                case 3: {\n                                        message.exposureOnce = reader.bool();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.exposureType = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a LabelV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.LabelV2} LabelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        LabelV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a LabelV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        LabelV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.title != null && message.hasOwnProperty(\"title\"))\n                                if (!$util.isString(message.title))\n                                    return \"title: string expected\";\n                            if (message.content != null && message.hasOwnProperty(\"content\")) {\n                                if (!Array.isArray(message.content))\n                                    return \"content: array expected\";\n                                for (var i = 0; i < message.content.length; ++i)\n                                    if (!$util.isString(message.content[i]))\n                                        return \"content: string[] expected\";\n                            }\n                            if (message.exposureOnce != null && message.hasOwnProperty(\"exposureOnce\"))\n                                if (typeof message.exposureOnce !== \"boolean\")\n                                    return \"exposureOnce: boolean expected\";\n                            if (message.exposureType != null && message.hasOwnProperty(\"exposureType\"))\n                                if (!$util.isInteger(message.exposureType))\n                                    return \"exposureType: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a LabelV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.LabelV2} LabelV2\n                         */\n                        LabelV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.LabelV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.LabelV2();\n                            if (object.title != null)\n                                message.title = String(object.title);\n                            if (object.content) {\n                                if (!Array.isArray(object.content))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.LabelV2.content: array expected\");\n                                message.content = [];\n                                for (var i = 0; i < object.content.length; ++i)\n                                    message.content[i] = String(object.content[i]);\n                            }\n                            if (object.exposureOnce != null)\n                                message.exposureOnce = Boolean(object.exposureOnce);\n                            if (object.exposureType != null)\n                                message.exposureType = object.exposureType | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a LabelV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.LabelV2} message LabelV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        LabelV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.content = [];\n                            if (options.defaults) {\n                                object.title = \"\";\n                                object.exposureOnce = false;\n                                object.exposureType = 0;\n                            }\n                            if (message.title != null && message.hasOwnProperty(\"title\"))\n                                object.title = message.title;\n                            if (message.content && message.content.length) {\n                                object.content = [];\n                                for (var j = 0; j < message.content.length; ++j)\n                                    object.content[j] = message.content[j];\n                            }\n                            if (message.exposureOnce != null && message.hasOwnProperty(\"exposureOnce\"))\n                                object.exposureOnce = message.exposureOnce;\n                            if (message.exposureType != null && message.hasOwnProperty(\"exposureType\"))\n                                object.exposureType = message.exposureType;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this LabelV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        LabelV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for LabelV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.LabelV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        LabelV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.LabelV2\";\n                        };\n\n                        return LabelV2;\n                    })();\n\n                    v1.Period = (function() {\n\n                        /**\n                         * Properties of a Period.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPeriod\n                         * @property {number|Long|null} [start] Period start\n                         * @property {number|Long|null} [end] Period end\n                         */\n\n                        /**\n                         * Constructs a new Period.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a Period.\n                         * @implements IPeriod\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPeriod=} [properties] Properties to set\n                         */\n                        function Period(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Period start.\n                         * @member {number|Long} start\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @instance\n                         */\n                        Period.prototype.start = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * Period end.\n                         * @member {number|Long} end\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @instance\n                         */\n                        Period.prototype.end = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * Creates a new Period instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPeriod=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Period} Period instance\n                         */\n                        Period.create = function create(properties) {\n                            return new Period(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Period message. Does not implicitly {@link bilibili.community.service.dm.v1.Period.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPeriod} message Period message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Period.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.start != null && Object.hasOwnProperty.call(message, \"start\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.start);\n                            if (message.end != null && Object.hasOwnProperty.call(message, \"end\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.end);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Period message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Period.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPeriod} message Period message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Period.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a Period message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Period} Period\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Period.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Period();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.start = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.end = reader.int64();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a Period message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Period} Period\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Period.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a Period message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Period.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.start != null && message.hasOwnProperty(\"start\"))\n                                if (!$util.isInteger(message.start) && !(message.start && $util.isInteger(message.start.low) && $util.isInteger(message.start.high)))\n                                    return \"start: integer|Long expected\";\n                            if (message.end != null && message.hasOwnProperty(\"end\"))\n                                if (!$util.isInteger(message.end) && !(message.end && $util.isInteger(message.end.low) && $util.isInteger(message.end.high)))\n                                    return \"end: integer|Long expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a Period message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Period} Period\n                         */\n                        Period.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Period)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Period();\n                            if (object.start != null)\n                                if ($util.Long)\n                                    (message.start = $util.Long.fromValue(object.start)).unsigned = false;\n                                else if (typeof object.start === \"string\")\n                                    message.start = parseInt(object.start, 10);\n                                else if (typeof object.start === \"number\")\n                                    message.start = object.start;\n                                else if (typeof object.start === \"object\")\n                                    message.start = new $util.LongBits(object.start.low >>> 0, object.start.high >>> 0).toNumber();\n                            if (object.end != null)\n                                if ($util.Long)\n                                    (message.end = $util.Long.fromValue(object.end)).unsigned = false;\n                                else if (typeof object.end === \"string\")\n                                    message.end = parseInt(object.end, 10);\n                                else if (typeof object.end === \"number\")\n                                    message.end = object.end;\n                                else if (typeof object.end === \"object\")\n                                    message.end = new $util.LongBits(object.end.low >>> 0, object.end.high >>> 0).toNumber();\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a Period message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Period} message Period\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Period.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.start = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.start = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.end = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.end = options.longs === String ? \"0\" : 0;\n                            }\n                            if (message.start != null && message.hasOwnProperty(\"start\"))\n                                if (typeof message.start === \"number\")\n                                    object.start = options.longs === String ? String(message.start) : message.start;\n                                else\n                                    object.start = options.longs === String ? $util.Long.prototype.toString.call(message.start) : options.longs === Number ? new $util.LongBits(message.start.low >>> 0, message.start.high >>> 0).toNumber() : message.start;\n                            if (message.end != null && message.hasOwnProperty(\"end\"))\n                                if (typeof message.end === \"number\")\n                                    object.end = options.longs === String ? String(message.end) : message.end;\n                                else\n                                    object.end = options.longs === String ? $util.Long.prototype.toString.call(message.end) : options.longs === Number ? new $util.LongBits(message.end.low >>> 0, message.end.high >>> 0).toNumber() : message.end;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Period to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Period.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Period\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Period\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Period.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Period\";\n                        };\n\n                        return Period;\n                    })();\n\n                    v1.PlayerDanmakuAiRecommendedLevel = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuAiRecommendedLevel.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuAiRecommendedLevel\n                         * @property {boolean|null} [value] PlayerDanmakuAiRecommendedLevel value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuAiRecommendedLevel.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuAiRecommendedLevel.\n                         * @implements IPlayerDanmakuAiRecommendedLevel\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuAiRecommendedLevel(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuAiRecommendedLevel value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @instance\n                         */\n                        PlayerDanmakuAiRecommendedLevel.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuAiRecommendedLevel instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel} PlayerDanmakuAiRecommendedLevel instance\n                         */\n                        PlayerDanmakuAiRecommendedLevel.create = function create(properties) {\n                            return new PlayerDanmakuAiRecommendedLevel(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevel message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel} message PlayerDanmakuAiRecommendedLevel message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuAiRecommendedLevel.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevel message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevel} message PlayerDanmakuAiRecommendedLevel message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuAiRecommendedLevel.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevel message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel} PlayerDanmakuAiRecommendedLevel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuAiRecommendedLevel.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevel message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel} PlayerDanmakuAiRecommendedLevel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuAiRecommendedLevel.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuAiRecommendedLevel message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuAiRecommendedLevel.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuAiRecommendedLevel message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel} PlayerDanmakuAiRecommendedLevel\n                         */\n                        PlayerDanmakuAiRecommendedLevel.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuAiRecommendedLevel message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel} message PlayerDanmakuAiRecommendedLevel\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuAiRecommendedLevel.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuAiRecommendedLevel to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuAiRecommendedLevel.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuAiRecommendedLevel\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuAiRecommendedLevel.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevel\";\n                        };\n\n                        return PlayerDanmakuAiRecommendedLevel;\n                    })();\n\n                    v1.PlayerDanmakuAiRecommendedLevelV2 = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuAiRecommendedLevelV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuAiRecommendedLevelV2\n                         * @property {number|null} [value] PlayerDanmakuAiRecommendedLevelV2 value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuAiRecommendedLevelV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuAiRecommendedLevelV2.\n                         * @implements IPlayerDanmakuAiRecommendedLevelV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuAiRecommendedLevelV2(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuAiRecommendedLevelV2 value.\n                         * @member {number} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @instance\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.prototype.value = 0;\n\n                        /**\n                         * Creates a new PlayerDanmakuAiRecommendedLevelV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2} PlayerDanmakuAiRecommendedLevelV2 instance\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.create = function create(properties) {\n                            return new PlayerDanmakuAiRecommendedLevelV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevelV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2} message PlayerDanmakuAiRecommendedLevelV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedLevelV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedLevelV2} message PlayerDanmakuAiRecommendedLevelV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevelV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2} PlayerDanmakuAiRecommendedLevelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedLevelV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2} PlayerDanmakuAiRecommendedLevelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuAiRecommendedLevelV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (!$util.isInteger(message.value))\n                                    return \"value: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuAiRecommendedLevelV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2} PlayerDanmakuAiRecommendedLevelV2\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2();\n                            if (object.value != null)\n                                message.value = object.value | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuAiRecommendedLevelV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2} message PlayerDanmakuAiRecommendedLevelV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = 0;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuAiRecommendedLevelV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuAiRecommendedLevelV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuAiRecommendedLevelV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedLevelV2\";\n                        };\n\n                        return PlayerDanmakuAiRecommendedLevelV2;\n                    })();\n\n                    v1.PlayerDanmakuAiRecommendedSwitch = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuAiRecommendedSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuAiRecommendedSwitch\n                         * @property {boolean|null} [value] PlayerDanmakuAiRecommendedSwitch value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuAiRecommendedSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuAiRecommendedSwitch.\n                         * @implements IPlayerDanmakuAiRecommendedSwitch\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuAiRecommendedSwitch(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuAiRecommendedSwitch value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @instance\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuAiRecommendedSwitch instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch} PlayerDanmakuAiRecommendedSwitch instance\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.create = function create(properties) {\n                            return new PlayerDanmakuAiRecommendedSwitch(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch} message PlayerDanmakuAiRecommendedSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuAiRecommendedSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuAiRecommendedSwitch} message PlayerDanmakuAiRecommendedSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedSwitch message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch} PlayerDanmakuAiRecommendedSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuAiRecommendedSwitch message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch} PlayerDanmakuAiRecommendedSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuAiRecommendedSwitch message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuAiRecommendedSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch} PlayerDanmakuAiRecommendedSwitch\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuAiRecommendedSwitch message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch} message PlayerDanmakuAiRecommendedSwitch\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuAiRecommendedSwitch to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuAiRecommendedSwitch\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuAiRecommendedSwitch.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuAiRecommendedSwitch\";\n                        };\n\n                        return PlayerDanmakuAiRecommendedSwitch;\n                    })();\n\n                    v1.PlayerDanmakuBlockbottom = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuBlockbottom.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuBlockbottom\n                         * @property {boolean|null} [value] PlayerDanmakuBlockbottom value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockbottom.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuBlockbottom.\n                         * @implements IPlayerDanmakuBlockbottom\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuBlockbottom(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuBlockbottom value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @instance\n                         */\n                        PlayerDanmakuBlockbottom.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockbottom instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom} PlayerDanmakuBlockbottom instance\n                         */\n                        PlayerDanmakuBlockbottom.create = function create(properties) {\n                            return new PlayerDanmakuBlockbottom(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockbottom message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom} message PlayerDanmakuBlockbottom message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockbottom.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockbottom message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockbottom} message PlayerDanmakuBlockbottom message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockbottom.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockbottom message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom} PlayerDanmakuBlockbottom\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockbottom.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockbottom message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom} PlayerDanmakuBlockbottom\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockbottom.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockbottom message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuBlockbottom.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuBlockbottom message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom} PlayerDanmakuBlockbottom\n                         */\n                        PlayerDanmakuBlockbottom.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockbottom message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom} message PlayerDanmakuBlockbottom\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuBlockbottom.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockbottom to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuBlockbottom.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockbottom\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuBlockbottom.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuBlockbottom\";\n                        };\n\n                        return PlayerDanmakuBlockbottom;\n                    })();\n\n                    v1.PlayerDanmakuBlockcolorful = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuBlockcolorful.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuBlockcolorful\n                         * @property {boolean|null} [value] PlayerDanmakuBlockcolorful value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockcolorful.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuBlockcolorful.\n                         * @implements IPlayerDanmakuBlockcolorful\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuBlockcolorful(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuBlockcolorful value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @instance\n                         */\n                        PlayerDanmakuBlockcolorful.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockcolorful instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful} PlayerDanmakuBlockcolorful instance\n                         */\n                        PlayerDanmakuBlockcolorful.create = function create(properties) {\n                            return new PlayerDanmakuBlockcolorful(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockcolorful message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful} message PlayerDanmakuBlockcolorful message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockcolorful.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockcolorful message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockcolorful} message PlayerDanmakuBlockcolorful message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockcolorful.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockcolorful message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful} PlayerDanmakuBlockcolorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockcolorful.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockcolorful message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful} PlayerDanmakuBlockcolorful\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockcolorful.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockcolorful message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuBlockcolorful.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuBlockcolorful message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful} PlayerDanmakuBlockcolorful\n                         */\n                        PlayerDanmakuBlockcolorful.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockcolorful message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful} message PlayerDanmakuBlockcolorful\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuBlockcolorful.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockcolorful to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuBlockcolorful.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockcolorful\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuBlockcolorful.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuBlockcolorful\";\n                        };\n\n                        return PlayerDanmakuBlockcolorful;\n                    })();\n\n                    v1.PlayerDanmakuBlockrepeat = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuBlockrepeat.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuBlockrepeat\n                         * @property {boolean|null} [value] PlayerDanmakuBlockrepeat value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockrepeat.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuBlockrepeat.\n                         * @implements IPlayerDanmakuBlockrepeat\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuBlockrepeat(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuBlockrepeat value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @instance\n                         */\n                        PlayerDanmakuBlockrepeat.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockrepeat instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat} PlayerDanmakuBlockrepeat instance\n                         */\n                        PlayerDanmakuBlockrepeat.create = function create(properties) {\n                            return new PlayerDanmakuBlockrepeat(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockrepeat message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat} message PlayerDanmakuBlockrepeat message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockrepeat.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockrepeat message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockrepeat} message PlayerDanmakuBlockrepeat message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockrepeat.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockrepeat message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat} PlayerDanmakuBlockrepeat\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockrepeat.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockrepeat message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat} PlayerDanmakuBlockrepeat\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockrepeat.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockrepeat message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuBlockrepeat.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuBlockrepeat message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat} PlayerDanmakuBlockrepeat\n                         */\n                        PlayerDanmakuBlockrepeat.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockrepeat message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat} message PlayerDanmakuBlockrepeat\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuBlockrepeat.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockrepeat to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuBlockrepeat.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockrepeat\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuBlockrepeat.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuBlockrepeat\";\n                        };\n\n                        return PlayerDanmakuBlockrepeat;\n                    })();\n\n                    v1.PlayerDanmakuBlockscroll = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuBlockscroll.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuBlockscroll\n                         * @property {boolean|null} [value] PlayerDanmakuBlockscroll value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockscroll.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuBlockscroll.\n                         * @implements IPlayerDanmakuBlockscroll\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuBlockscroll(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuBlockscroll value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @instance\n                         */\n                        PlayerDanmakuBlockscroll.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockscroll instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll} PlayerDanmakuBlockscroll instance\n                         */\n                        PlayerDanmakuBlockscroll.create = function create(properties) {\n                            return new PlayerDanmakuBlockscroll(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockscroll message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll} message PlayerDanmakuBlockscroll message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockscroll.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockscroll message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockscroll} message PlayerDanmakuBlockscroll message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockscroll.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockscroll message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll} PlayerDanmakuBlockscroll\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockscroll.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockscroll message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll} PlayerDanmakuBlockscroll\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockscroll.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockscroll message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuBlockscroll.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuBlockscroll message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll} PlayerDanmakuBlockscroll\n                         */\n                        PlayerDanmakuBlockscroll.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockscroll message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll} message PlayerDanmakuBlockscroll\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuBlockscroll.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockscroll to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuBlockscroll.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockscroll\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuBlockscroll.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuBlockscroll\";\n                        };\n\n                        return PlayerDanmakuBlockscroll;\n                    })();\n\n                    v1.PlayerDanmakuBlockspecial = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuBlockspecial.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuBlockspecial\n                         * @property {boolean|null} [value] PlayerDanmakuBlockspecial value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlockspecial.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuBlockspecial.\n                         * @implements IPlayerDanmakuBlockspecial\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuBlockspecial(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuBlockspecial value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @instance\n                         */\n                        PlayerDanmakuBlockspecial.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlockspecial instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial} PlayerDanmakuBlockspecial instance\n                         */\n                        PlayerDanmakuBlockspecial.create = function create(properties) {\n                            return new PlayerDanmakuBlockspecial(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockspecial message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial} message PlayerDanmakuBlockspecial message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockspecial.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlockspecial message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlockspecial} message PlayerDanmakuBlockspecial message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlockspecial.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockspecial message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial} PlayerDanmakuBlockspecial\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockspecial.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlockspecial message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial} PlayerDanmakuBlockspecial\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlockspecial.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuBlockspecial message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuBlockspecial.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuBlockspecial message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial} PlayerDanmakuBlockspecial\n                         */\n                        PlayerDanmakuBlockspecial.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlockspecial message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial} message PlayerDanmakuBlockspecial\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuBlockspecial.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuBlockspecial to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuBlockspecial.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlockspecial\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuBlockspecial.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuBlockspecial\";\n                        };\n\n                        return PlayerDanmakuBlockspecial;\n                    })();\n\n                    v1.PlayerDanmakuBlocktop = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuBlocktop.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuBlocktop\n                         * @property {boolean|null} [value] PlayerDanmakuBlocktop value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuBlocktop.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuBlocktop.\n                         * @implements IPlayerDanmakuBlocktop\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuBlocktop(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuBlocktop value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @instance\n                         */\n                        PlayerDanmakuBlocktop.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuBlocktop instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlocktop} PlayerDanmakuBlocktop instance\n                         */\n                        PlayerDanmakuBlocktop.create = function create(properties) {\n                            return new PlayerDanmakuBlocktop(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlocktop message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop} message PlayerDanmakuBlocktop message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlocktop.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuBlocktop message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuBlocktop.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuBlocktop} message PlayerDanmakuBlocktop message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuBlocktop.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlocktop message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlocktop} PlayerDanmakuBlocktop\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlocktop.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuBlocktop message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlocktop} PlayerDanmakuBlocktop\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuBlocktop.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuBlocktop message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuBlocktop.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuBlocktop message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuBlocktop} PlayerDanmakuBlocktop\n                         */\n                        PlayerDanmakuBlocktop.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuBlocktop();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuBlocktop message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuBlocktop} message PlayerDanmakuBlocktop\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuBlocktop.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuBlocktop to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuBlocktop.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuBlocktop\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuBlocktop.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuBlocktop\";\n                        };\n\n                        return PlayerDanmakuBlocktop;\n                    })();\n\n                    v1.PlayerDanmakuDomain = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuDomain.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuDomain\n                         * @property {number|null} [value] PlayerDanmakuDomain value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuDomain.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuDomain.\n                         * @implements IPlayerDanmakuDomain\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuDomain=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuDomain(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuDomain value.\n                         * @member {number} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @instance\n                         */\n                        PlayerDanmakuDomain.prototype.value = 0;\n\n                        /**\n                         * Creates a new PlayerDanmakuDomain instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuDomain=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuDomain} PlayerDanmakuDomain instance\n                         */\n                        PlayerDanmakuDomain.create = function create(properties) {\n                            return new PlayerDanmakuDomain(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuDomain message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuDomain.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuDomain} message PlayerDanmakuDomain message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuDomain.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 5 =*/13).float(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuDomain message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuDomain.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuDomain} message PlayerDanmakuDomain message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuDomain.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuDomain message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuDomain} PlayerDanmakuDomain\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuDomain.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.float();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuDomain message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuDomain} PlayerDanmakuDomain\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuDomain.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuDomain message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuDomain.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"number\")\n                                    return \"value: number expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuDomain message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuDomain} PlayerDanmakuDomain\n                         */\n                        PlayerDanmakuDomain.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuDomain();\n                            if (object.value != null)\n                                message.value = Number(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuDomain message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuDomain} message PlayerDanmakuDomain\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuDomain.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = 0;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = options.json && !isFinite(message.value) ? String(message.value) : message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuDomain to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuDomain.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuDomain\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuDomain\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuDomain.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuDomain\";\n                        };\n\n                        return PlayerDanmakuDomain;\n                    })();\n\n                    v1.PlayerDanmakuEnableblocklist = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuEnableblocklist.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuEnableblocklist\n                         * @property {boolean|null} [value] PlayerDanmakuEnableblocklist value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuEnableblocklist.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuEnableblocklist.\n                         * @implements IPlayerDanmakuEnableblocklist\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuEnableblocklist(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuEnableblocklist value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @instance\n                         */\n                        PlayerDanmakuEnableblocklist.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuEnableblocklist instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist} PlayerDanmakuEnableblocklist instance\n                         */\n                        PlayerDanmakuEnableblocklist.create = function create(properties) {\n                            return new PlayerDanmakuEnableblocklist(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuEnableblocklist message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist} message PlayerDanmakuEnableblocklist message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuEnableblocklist.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuEnableblocklist message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuEnableblocklist} message PlayerDanmakuEnableblocklist message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuEnableblocklist.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuEnableblocklist message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist} PlayerDanmakuEnableblocklist\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuEnableblocklist.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuEnableblocklist message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist} PlayerDanmakuEnableblocklist\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuEnableblocklist.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuEnableblocklist message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuEnableblocklist.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuEnableblocklist message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist} PlayerDanmakuEnableblocklist\n                         */\n                        PlayerDanmakuEnableblocklist.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuEnableblocklist message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist} message PlayerDanmakuEnableblocklist\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuEnableblocklist.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuEnableblocklist to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuEnableblocklist.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuEnableblocklist\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuEnableblocklist.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuEnableblocklist\";\n                        };\n\n                        return PlayerDanmakuEnableblocklist;\n                    })();\n\n                    v1.PlayerDanmakuOpacity = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuOpacity.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuOpacity\n                         * @property {number|null} [value] PlayerDanmakuOpacity value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuOpacity.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuOpacity.\n                         * @implements IPlayerDanmakuOpacity\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuOpacity=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuOpacity(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuOpacity value.\n                         * @member {number} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @instance\n                         */\n                        PlayerDanmakuOpacity.prototype.value = 0;\n\n                        /**\n                         * Creates a new PlayerDanmakuOpacity instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuOpacity=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuOpacity} PlayerDanmakuOpacity instance\n                         */\n                        PlayerDanmakuOpacity.create = function create(properties) {\n                            return new PlayerDanmakuOpacity(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuOpacity message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuOpacity.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuOpacity} message PlayerDanmakuOpacity message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuOpacity.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 5 =*/13).float(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuOpacity message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuOpacity.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuOpacity} message PlayerDanmakuOpacity message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuOpacity.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuOpacity message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuOpacity} PlayerDanmakuOpacity\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuOpacity.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.float();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuOpacity message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuOpacity} PlayerDanmakuOpacity\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuOpacity.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuOpacity message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuOpacity.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"number\")\n                                    return \"value: number expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuOpacity message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuOpacity} PlayerDanmakuOpacity\n                         */\n                        PlayerDanmakuOpacity.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuOpacity();\n                            if (object.value != null)\n                                message.value = Number(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuOpacity message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuOpacity} message PlayerDanmakuOpacity\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuOpacity.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = 0;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = options.json && !isFinite(message.value) ? String(message.value) : message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuOpacity to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuOpacity.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuOpacity\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuOpacity\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuOpacity.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuOpacity\";\n                        };\n\n                        return PlayerDanmakuOpacity;\n                    })();\n\n                    v1.PlayerDanmakuScalingfactor = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuScalingfactor.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuScalingfactor\n                         * @property {number|null} [value] PlayerDanmakuScalingfactor value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuScalingfactor.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuScalingfactor.\n                         * @implements IPlayerDanmakuScalingfactor\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuScalingfactor(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuScalingfactor value.\n                         * @member {number} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @instance\n                         */\n                        PlayerDanmakuScalingfactor.prototype.value = 0;\n\n                        /**\n                         * Creates a new PlayerDanmakuScalingfactor instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor} PlayerDanmakuScalingfactor instance\n                         */\n                        PlayerDanmakuScalingfactor.create = function create(properties) {\n                            return new PlayerDanmakuScalingfactor(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuScalingfactor message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor} message PlayerDanmakuScalingfactor message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuScalingfactor.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 5 =*/13).float(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuScalingfactor message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuScalingfactor} message PlayerDanmakuScalingfactor message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuScalingfactor.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuScalingfactor message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor} PlayerDanmakuScalingfactor\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuScalingfactor.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.float();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuScalingfactor message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor} PlayerDanmakuScalingfactor\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuScalingfactor.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuScalingfactor message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuScalingfactor.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"number\")\n                                    return \"value: number expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuScalingfactor message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor} PlayerDanmakuScalingfactor\n                         */\n                        PlayerDanmakuScalingfactor.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor();\n                            if (object.value != null)\n                                message.value = Number(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuScalingfactor message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor} message PlayerDanmakuScalingfactor\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuScalingfactor.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = 0;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = options.json && !isFinite(message.value) ? String(message.value) : message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuScalingfactor to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuScalingfactor.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuScalingfactor\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuScalingfactor.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuScalingfactor\";\n                        };\n\n                        return PlayerDanmakuScalingfactor;\n                    })();\n\n                    v1.PlayerDanmakuSeniorModeSwitch = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuSeniorModeSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuSeniorModeSwitch\n                         * @property {number|null} [value] PlayerDanmakuSeniorModeSwitch value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuSeniorModeSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuSeniorModeSwitch.\n                         * @implements IPlayerDanmakuSeniorModeSwitch\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuSeniorModeSwitch(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuSeniorModeSwitch value.\n                         * @member {number} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @instance\n                         */\n                        PlayerDanmakuSeniorModeSwitch.prototype.value = 0;\n\n                        /**\n                         * Creates a new PlayerDanmakuSeniorModeSwitch instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch} PlayerDanmakuSeniorModeSwitch instance\n                         */\n                        PlayerDanmakuSeniorModeSwitch.create = function create(properties) {\n                            return new PlayerDanmakuSeniorModeSwitch(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSeniorModeSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch} message PlayerDanmakuSeniorModeSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSeniorModeSwitch.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSeniorModeSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSeniorModeSwitch} message PlayerDanmakuSeniorModeSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSeniorModeSwitch.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSeniorModeSwitch message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch} PlayerDanmakuSeniorModeSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSeniorModeSwitch.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSeniorModeSwitch message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch} PlayerDanmakuSeniorModeSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSeniorModeSwitch.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuSeniorModeSwitch message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuSeniorModeSwitch.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (!$util.isInteger(message.value))\n                                    return \"value: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuSeniorModeSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch} PlayerDanmakuSeniorModeSwitch\n                         */\n                        PlayerDanmakuSeniorModeSwitch.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch();\n                            if (object.value != null)\n                                message.value = object.value | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSeniorModeSwitch message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch} message PlayerDanmakuSeniorModeSwitch\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuSeniorModeSwitch.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = 0;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuSeniorModeSwitch to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuSeniorModeSwitch.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSeniorModeSwitch\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuSeniorModeSwitch.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuSeniorModeSwitch\";\n                        };\n\n                        return PlayerDanmakuSeniorModeSwitch;\n                    })();\n\n                    v1.PlayerDanmakuSpeed = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuSpeed.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuSpeed\n                         * @property {number|null} [value] PlayerDanmakuSpeed value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuSpeed.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuSpeed.\n                         * @implements IPlayerDanmakuSpeed\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSpeed=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuSpeed(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuSpeed value.\n                         * @member {number} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @instance\n                         */\n                        PlayerDanmakuSpeed.prototype.value = 0;\n\n                        /**\n                         * Creates a new PlayerDanmakuSpeed instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSpeed=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSpeed} PlayerDanmakuSpeed instance\n                         */\n                        PlayerDanmakuSpeed.create = function create(properties) {\n                            return new PlayerDanmakuSpeed(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSpeed message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSpeed.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSpeed} message PlayerDanmakuSpeed message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSpeed.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSpeed message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSpeed.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSpeed} message PlayerDanmakuSpeed message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSpeed.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSpeed message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSpeed} PlayerDanmakuSpeed\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSpeed.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSpeed message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSpeed} PlayerDanmakuSpeed\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSpeed.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuSpeed message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuSpeed.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (!$util.isInteger(message.value))\n                                    return \"value: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuSpeed message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSpeed} PlayerDanmakuSpeed\n                         */\n                        PlayerDanmakuSpeed.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSpeed();\n                            if (object.value != null)\n                                message.value = object.value | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSpeed message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuSpeed} message PlayerDanmakuSpeed\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuSpeed.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = 0;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuSpeed to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuSpeed.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSpeed\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSpeed\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuSpeed.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuSpeed\";\n                        };\n\n                        return PlayerDanmakuSpeed;\n                    })();\n\n                    v1.PlayerDanmakuSwitch = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuSwitch\n                         * @property {boolean|null} [value] PlayerDanmakuSwitch value\n                         * @property {boolean|null} [canIgnore] PlayerDanmakuSwitch canIgnore\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuSwitch.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuSwitch.\n                         * @implements IPlayerDanmakuSwitch\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitch=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuSwitch(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuSwitch value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @instance\n                         */\n                        PlayerDanmakuSwitch.prototype.value = false;\n\n                        /**\n                         * PlayerDanmakuSwitch canIgnore.\n                         * @member {boolean} canIgnore\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @instance\n                         */\n                        PlayerDanmakuSwitch.prototype.canIgnore = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuSwitch instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitch=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitch} PlayerDanmakuSwitch instance\n                         */\n                        PlayerDanmakuSwitch.create = function create(properties) {\n                            return new PlayerDanmakuSwitch(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitch message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitch.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitch} message PlayerDanmakuSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSwitch.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            if (message.canIgnore != null && Object.hasOwnProperty.call(message, \"canIgnore\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).bool(message.canIgnore);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitch message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitch.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitch} message PlayerDanmakuSwitch message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSwitch.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitch message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitch} PlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSwitch.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.canIgnore = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitch message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitch} PlayerDanmakuSwitch\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSwitch.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuSwitch message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuSwitch.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            if (message.canIgnore != null && message.hasOwnProperty(\"canIgnore\"))\n                                if (typeof message.canIgnore !== \"boolean\")\n                                    return \"canIgnore: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuSwitch message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitch} PlayerDanmakuSwitch\n                         */\n                        PlayerDanmakuSwitch.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitch();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            if (object.canIgnore != null)\n                                message.canIgnore = Boolean(object.canIgnore);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSwitch message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuSwitch} message PlayerDanmakuSwitch\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuSwitch.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.value = false;\n                                object.canIgnore = false;\n                            }\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            if (message.canIgnore != null && message.hasOwnProperty(\"canIgnore\"))\n                                object.canIgnore = message.canIgnore;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuSwitch to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuSwitch.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSwitch\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitch\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuSwitch.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuSwitch\";\n                        };\n\n                        return PlayerDanmakuSwitch;\n                    })();\n\n                    v1.PlayerDanmakuSwitchSave = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuSwitchSave.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuSwitchSave\n                         * @property {boolean|null} [value] PlayerDanmakuSwitchSave value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuSwitchSave.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuSwitchSave.\n                         * @implements IPlayerDanmakuSwitchSave\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuSwitchSave(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuSwitchSave value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @instance\n                         */\n                        PlayerDanmakuSwitchSave.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuSwitchSave instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave} PlayerDanmakuSwitchSave instance\n                         */\n                        PlayerDanmakuSwitchSave.create = function create(properties) {\n                            return new PlayerDanmakuSwitchSave(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitchSave message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave} message PlayerDanmakuSwitchSave message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSwitchSave.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuSwitchSave message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuSwitchSave} message PlayerDanmakuSwitchSave message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuSwitchSave.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitchSave message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave} PlayerDanmakuSwitchSave\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSwitchSave.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuSwitchSave message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave} PlayerDanmakuSwitchSave\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuSwitchSave.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuSwitchSave message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuSwitchSave.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuSwitchSave message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave} PlayerDanmakuSwitchSave\n                         */\n                        PlayerDanmakuSwitchSave.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuSwitchSave message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave} message PlayerDanmakuSwitchSave\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuSwitchSave.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuSwitchSave to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuSwitchSave.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuSwitchSave\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuSwitchSave.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuSwitchSave\";\n                        };\n\n                        return PlayerDanmakuSwitchSave;\n                    })();\n\n                    v1.PlayerDanmakuUseDefaultConfig = (function() {\n\n                        /**\n                         * Properties of a PlayerDanmakuUseDefaultConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPlayerDanmakuUseDefaultConfig\n                         * @property {boolean|null} [value] PlayerDanmakuUseDefaultConfig value\n                         */\n\n                        /**\n                         * Constructs a new PlayerDanmakuUseDefaultConfig.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PlayerDanmakuUseDefaultConfig.\n                         * @implements IPlayerDanmakuUseDefaultConfig\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig=} [properties] Properties to set\n                         */\n                        function PlayerDanmakuUseDefaultConfig(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PlayerDanmakuUseDefaultConfig value.\n                         * @member {boolean} value\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @instance\n                         */\n                        PlayerDanmakuUseDefaultConfig.prototype.value = false;\n\n                        /**\n                         * Creates a new PlayerDanmakuUseDefaultConfig instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig} PlayerDanmakuUseDefaultConfig instance\n                         */\n                        PlayerDanmakuUseDefaultConfig.create = function create(properties) {\n                            return new PlayerDanmakuUseDefaultConfig(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuUseDefaultConfig message. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig} message PlayerDanmakuUseDefaultConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuUseDefaultConfig.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.value != null && Object.hasOwnProperty.call(message, \"value\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).bool(message.value);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PlayerDanmakuUseDefaultConfig message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPlayerDanmakuUseDefaultConfig} message PlayerDanmakuUseDefaultConfig message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PlayerDanmakuUseDefaultConfig.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuUseDefaultConfig message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig} PlayerDanmakuUseDefaultConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuUseDefaultConfig.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.value = reader.bool();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PlayerDanmakuUseDefaultConfig message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig} PlayerDanmakuUseDefaultConfig\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PlayerDanmakuUseDefaultConfig.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PlayerDanmakuUseDefaultConfig message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PlayerDanmakuUseDefaultConfig.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                if (typeof message.value !== \"boolean\")\n                                    return \"value: boolean expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PlayerDanmakuUseDefaultConfig message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig} PlayerDanmakuUseDefaultConfig\n                         */\n                        PlayerDanmakuUseDefaultConfig.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig();\n                            if (object.value != null)\n                                message.value = Boolean(object.value);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PlayerDanmakuUseDefaultConfig message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig} message PlayerDanmakuUseDefaultConfig\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PlayerDanmakuUseDefaultConfig.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults)\n                                object.value = false;\n                            if (message.value != null && message.hasOwnProperty(\"value\"))\n                                object.value = message.value;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PlayerDanmakuUseDefaultConfig to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PlayerDanmakuUseDefaultConfig.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PlayerDanmakuUseDefaultConfig\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PlayerDanmakuUseDefaultConfig.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PlayerDanmakuUseDefaultConfig\";\n                        };\n\n                        return PlayerDanmakuUseDefaultConfig;\n                    })();\n\n                    v1.PostPanel = (function() {\n\n                        /**\n                         * Properties of a PostPanel.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPostPanel\n                         * @property {number|Long|null} [start] PostPanel start\n                         * @property {number|Long|null} [end] PostPanel end\n                         * @property {number|Long|null} [priority] PostPanel priority\n                         * @property {number|Long|null} [bizId] PostPanel bizId\n                         * @property {bilibili.community.service.dm.v1.PostPanelBizType|null} [bizType] PostPanel bizType\n                         * @property {bilibili.community.service.dm.v1.IClickButton|null} [clickButton] PostPanel clickButton\n                         * @property {bilibili.community.service.dm.v1.ITextInput|null} [textInput] PostPanel textInput\n                         * @property {bilibili.community.service.dm.v1.ICheckBox|null} [checkBox] PostPanel checkBox\n                         * @property {bilibili.community.service.dm.v1.IToast|null} [toast] PostPanel toast\n                         */\n\n                        /**\n                         * Constructs a new PostPanel.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PostPanel.\n                         * @implements IPostPanel\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPostPanel=} [properties] Properties to set\n                         */\n                        function PostPanel(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PostPanel start.\n                         * @member {number|Long} start\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.start = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * PostPanel end.\n                         * @member {number|Long} end\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.end = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * PostPanel priority.\n                         * @member {number|Long} priority\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.priority = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * PostPanel bizId.\n                         * @member {number|Long} bizId\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.bizId = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * PostPanel bizType.\n                         * @member {bilibili.community.service.dm.v1.PostPanelBizType} bizType\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.bizType = 0;\n\n                        /**\n                         * PostPanel clickButton.\n                         * @member {bilibili.community.service.dm.v1.IClickButton|null|undefined} clickButton\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.clickButton = null;\n\n                        /**\n                         * PostPanel textInput.\n                         * @member {bilibili.community.service.dm.v1.ITextInput|null|undefined} textInput\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.textInput = null;\n\n                        /**\n                         * PostPanel checkBox.\n                         * @member {bilibili.community.service.dm.v1.ICheckBox|null|undefined} checkBox\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.checkBox = null;\n\n                        /**\n                         * PostPanel toast.\n                         * @member {bilibili.community.service.dm.v1.IToast|null|undefined} toast\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         */\n                        PostPanel.prototype.toast = null;\n\n                        /**\n                         * Creates a new PostPanel instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPostPanel=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PostPanel} PostPanel instance\n                         */\n                        PostPanel.create = function create(properties) {\n                            return new PostPanel(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PostPanel message. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanel.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPostPanel} message PostPanel message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PostPanel.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.start != null && Object.hasOwnProperty.call(message, \"start\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.start);\n                            if (message.end != null && Object.hasOwnProperty.call(message, \"end\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.end);\n                            if (message.priority != null && Object.hasOwnProperty.call(message, \"priority\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int64(message.priority);\n                            if (message.bizId != null && Object.hasOwnProperty.call(message, \"bizId\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int64(message.bizId);\n                            if (message.bizType != null && Object.hasOwnProperty.call(message, \"bizType\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).int32(message.bizType);\n                            if (message.clickButton != null && Object.hasOwnProperty.call(message, \"clickButton\"))\n                                $root.bilibili.community.service.dm.v1.ClickButton.encode(message.clickButton, writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim();\n                            if (message.textInput != null && Object.hasOwnProperty.call(message, \"textInput\"))\n                                $root.bilibili.community.service.dm.v1.TextInput.encode(message.textInput, writer.uint32(/* id 7, wireType 2 =*/58).fork()).ldelim();\n                            if (message.checkBox != null && Object.hasOwnProperty.call(message, \"checkBox\"))\n                                $root.bilibili.community.service.dm.v1.CheckBox.encode(message.checkBox, writer.uint32(/* id 8, wireType 2 =*/66).fork()).ldelim();\n                            if (message.toast != null && Object.hasOwnProperty.call(message, \"toast\"))\n                                $root.bilibili.community.service.dm.v1.Toast.encode(message.toast, writer.uint32(/* id 9, wireType 2 =*/74).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PostPanel message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanel.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPostPanel} message PostPanel message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PostPanel.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PostPanel message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PostPanel} PostPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PostPanel.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PostPanel();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.start = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.end = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.priority = reader.int64();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.bizId = reader.int64();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.bizType = reader.int32();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.clickButton = $root.bilibili.community.service.dm.v1.ClickButton.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 7: {\n                                        message.textInput = $root.bilibili.community.service.dm.v1.TextInput.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 8: {\n                                        message.checkBox = $root.bilibili.community.service.dm.v1.CheckBox.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 9: {\n                                        message.toast = $root.bilibili.community.service.dm.v1.Toast.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PostPanel message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PostPanel} PostPanel\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PostPanel.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PostPanel message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PostPanel.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.start != null && message.hasOwnProperty(\"start\"))\n                                if (!$util.isInteger(message.start) && !(message.start && $util.isInteger(message.start.low) && $util.isInteger(message.start.high)))\n                                    return \"start: integer|Long expected\";\n                            if (message.end != null && message.hasOwnProperty(\"end\"))\n                                if (!$util.isInteger(message.end) && !(message.end && $util.isInteger(message.end.low) && $util.isInteger(message.end.high)))\n                                    return \"end: integer|Long expected\";\n                            if (message.priority != null && message.hasOwnProperty(\"priority\"))\n                                if (!$util.isInteger(message.priority) && !(message.priority && $util.isInteger(message.priority.low) && $util.isInteger(message.priority.high)))\n                                    return \"priority: integer|Long expected\";\n                            if (message.bizId != null && message.hasOwnProperty(\"bizId\"))\n                                if (!$util.isInteger(message.bizId) && !(message.bizId && $util.isInteger(message.bizId.low) && $util.isInteger(message.bizId.high)))\n                                    return \"bizId: integer|Long expected\";\n                            if (message.bizType != null && message.hasOwnProperty(\"bizType\"))\n                                switch (message.bizType) {\n                                default:\n                                    return \"bizType: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                case 3:\n                                case 4:\n                                case 5:\n                                    break;\n                                }\n                            if (message.clickButton != null && message.hasOwnProperty(\"clickButton\")) {\n                                var error = $root.bilibili.community.service.dm.v1.ClickButton.verify(message.clickButton);\n                                if (error)\n                                    return \"clickButton.\" + error;\n                            }\n                            if (message.textInput != null && message.hasOwnProperty(\"textInput\")) {\n                                var error = $root.bilibili.community.service.dm.v1.TextInput.verify(message.textInput);\n                                if (error)\n                                    return \"textInput.\" + error;\n                            }\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\")) {\n                                var error = $root.bilibili.community.service.dm.v1.CheckBox.verify(message.checkBox);\n                                if (error)\n                                    return \"checkBox.\" + error;\n                            }\n                            if (message.toast != null && message.hasOwnProperty(\"toast\")) {\n                                var error = $root.bilibili.community.service.dm.v1.Toast.verify(message.toast);\n                                if (error)\n                                    return \"toast.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PostPanel message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PostPanel} PostPanel\n                         */\n                        PostPanel.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PostPanel)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PostPanel();\n                            if (object.start != null)\n                                if ($util.Long)\n                                    (message.start = $util.Long.fromValue(object.start)).unsigned = false;\n                                else if (typeof object.start === \"string\")\n                                    message.start = parseInt(object.start, 10);\n                                else if (typeof object.start === \"number\")\n                                    message.start = object.start;\n                                else if (typeof object.start === \"object\")\n                                    message.start = new $util.LongBits(object.start.low >>> 0, object.start.high >>> 0).toNumber();\n                            if (object.end != null)\n                                if ($util.Long)\n                                    (message.end = $util.Long.fromValue(object.end)).unsigned = false;\n                                else if (typeof object.end === \"string\")\n                                    message.end = parseInt(object.end, 10);\n                                else if (typeof object.end === \"number\")\n                                    message.end = object.end;\n                                else if (typeof object.end === \"object\")\n                                    message.end = new $util.LongBits(object.end.low >>> 0, object.end.high >>> 0).toNumber();\n                            if (object.priority != null)\n                                if ($util.Long)\n                                    (message.priority = $util.Long.fromValue(object.priority)).unsigned = false;\n                                else if (typeof object.priority === \"string\")\n                                    message.priority = parseInt(object.priority, 10);\n                                else if (typeof object.priority === \"number\")\n                                    message.priority = object.priority;\n                                else if (typeof object.priority === \"object\")\n                                    message.priority = new $util.LongBits(object.priority.low >>> 0, object.priority.high >>> 0).toNumber();\n                            if (object.bizId != null)\n                                if ($util.Long)\n                                    (message.bizId = $util.Long.fromValue(object.bizId)).unsigned = false;\n                                else if (typeof object.bizId === \"string\")\n                                    message.bizId = parseInt(object.bizId, 10);\n                                else if (typeof object.bizId === \"number\")\n                                    message.bizId = object.bizId;\n                                else if (typeof object.bizId === \"object\")\n                                    message.bizId = new $util.LongBits(object.bizId.low >>> 0, object.bizId.high >>> 0).toNumber();\n                            switch (object.bizType) {\n                            default:\n                                if (typeof object.bizType === \"number\") {\n                                    message.bizType = object.bizType;\n                                    break;\n                                }\n                                break;\n                            case \"PostPanelBizTypeNone\":\n                            case 0:\n                                message.bizType = 0;\n                                break;\n                            case \"PostPanelBizTypeEncourage\":\n                            case 1:\n                                message.bizType = 1;\n                                break;\n                            case \"PostPanelBizTypeColorDM\":\n                            case 2:\n                                message.bizType = 2;\n                                break;\n                            case \"PostPanelBizTypeNFTDM\":\n                            case 3:\n                                message.bizType = 3;\n                                break;\n                            case \"PostPanelBizTypeFragClose\":\n                            case 4:\n                                message.bizType = 4;\n                                break;\n                            case \"PostPanelBizTypeRecommend\":\n                            case 5:\n                                message.bizType = 5;\n                                break;\n                            }\n                            if (object.clickButton != null) {\n                                if (typeof object.clickButton !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanel.clickButton: object expected\");\n                                message.clickButton = $root.bilibili.community.service.dm.v1.ClickButton.fromObject(object.clickButton);\n                            }\n                            if (object.textInput != null) {\n                                if (typeof object.textInput !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanel.textInput: object expected\");\n                                message.textInput = $root.bilibili.community.service.dm.v1.TextInput.fromObject(object.textInput);\n                            }\n                            if (object.checkBox != null) {\n                                if (typeof object.checkBox !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanel.checkBox: object expected\");\n                                message.checkBox = $root.bilibili.community.service.dm.v1.CheckBox.fromObject(object.checkBox);\n                            }\n                            if (object.toast != null) {\n                                if (typeof object.toast !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanel.toast: object expected\");\n                                message.toast = $root.bilibili.community.service.dm.v1.Toast.fromObject(object.toast);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PostPanel message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PostPanel} message PostPanel\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PostPanel.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.start = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.start = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.end = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.end = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.priority = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.priority = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.bizId = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.bizId = options.longs === String ? \"0\" : 0;\n                                object.bizType = options.enums === String ? \"PostPanelBizTypeNone\" : 0;\n                                object.clickButton = null;\n                                object.textInput = null;\n                                object.checkBox = null;\n                                object.toast = null;\n                            }\n                            if (message.start != null && message.hasOwnProperty(\"start\"))\n                                if (typeof message.start === \"number\")\n                                    object.start = options.longs === String ? String(message.start) : message.start;\n                                else\n                                    object.start = options.longs === String ? $util.Long.prototype.toString.call(message.start) : options.longs === Number ? new $util.LongBits(message.start.low >>> 0, message.start.high >>> 0).toNumber() : message.start;\n                            if (message.end != null && message.hasOwnProperty(\"end\"))\n                                if (typeof message.end === \"number\")\n                                    object.end = options.longs === String ? String(message.end) : message.end;\n                                else\n                                    object.end = options.longs === String ? $util.Long.prototype.toString.call(message.end) : options.longs === Number ? new $util.LongBits(message.end.low >>> 0, message.end.high >>> 0).toNumber() : message.end;\n                            if (message.priority != null && message.hasOwnProperty(\"priority\"))\n                                if (typeof message.priority === \"number\")\n                                    object.priority = options.longs === String ? String(message.priority) : message.priority;\n                                else\n                                    object.priority = options.longs === String ? $util.Long.prototype.toString.call(message.priority) : options.longs === Number ? new $util.LongBits(message.priority.low >>> 0, message.priority.high >>> 0).toNumber() : message.priority;\n                            if (message.bizId != null && message.hasOwnProperty(\"bizId\"))\n                                if (typeof message.bizId === \"number\")\n                                    object.bizId = options.longs === String ? String(message.bizId) : message.bizId;\n                                else\n                                    object.bizId = options.longs === String ? $util.Long.prototype.toString.call(message.bizId) : options.longs === Number ? new $util.LongBits(message.bizId.low >>> 0, message.bizId.high >>> 0).toNumber() : message.bizId;\n                            if (message.bizType != null && message.hasOwnProperty(\"bizType\"))\n                                object.bizType = options.enums === String ? $root.bilibili.community.service.dm.v1.PostPanelBizType[message.bizType] === undefined ? message.bizType : $root.bilibili.community.service.dm.v1.PostPanelBizType[message.bizType] : message.bizType;\n                            if (message.clickButton != null && message.hasOwnProperty(\"clickButton\"))\n                                object.clickButton = $root.bilibili.community.service.dm.v1.ClickButton.toObject(message.clickButton, options);\n                            if (message.textInput != null && message.hasOwnProperty(\"textInput\"))\n                                object.textInput = $root.bilibili.community.service.dm.v1.TextInput.toObject(message.textInput, options);\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\"))\n                                object.checkBox = $root.bilibili.community.service.dm.v1.CheckBox.toObject(message.checkBox, options);\n                            if (message.toast != null && message.hasOwnProperty(\"toast\"))\n                                object.toast = $root.bilibili.community.service.dm.v1.Toast.toObject(message.toast, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PostPanel to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PostPanel.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PostPanel\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PostPanel\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PostPanel.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PostPanel\";\n                        };\n\n                        return PostPanel;\n                    })();\n\n                    /**\n                     * PostPanelBizType enum.\n                     * @name bilibili.community.service.dm.v1.PostPanelBizType\n                     * @enum {number}\n                     * @property {number} PostPanelBizTypeNone=0 PostPanelBizTypeNone value\n                     * @property {number} PostPanelBizTypeEncourage=1 PostPanelBizTypeEncourage value\n                     * @property {number} PostPanelBizTypeColorDM=2 PostPanelBizTypeColorDM value\n                     * @property {number} PostPanelBizTypeNFTDM=3 PostPanelBizTypeNFTDM value\n                     * @property {number} PostPanelBizTypeFragClose=4 PostPanelBizTypeFragClose value\n                     * @property {number} PostPanelBizTypeRecommend=5 PostPanelBizTypeRecommend value\n                     */\n                    v1.PostPanelBizType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"PostPanelBizTypeNone\"] = 0;\n                        values[valuesById[1] = \"PostPanelBizTypeEncourage\"] = 1;\n                        values[valuesById[2] = \"PostPanelBizTypeColorDM\"] = 2;\n                        values[valuesById[3] = \"PostPanelBizTypeNFTDM\"] = 3;\n                        values[valuesById[4] = \"PostPanelBizTypeFragClose\"] = 4;\n                        values[valuesById[5] = \"PostPanelBizTypeRecommend\"] = 5;\n                        return values;\n                    })();\n\n                    v1.PostPanelV2 = (function() {\n\n                        /**\n                         * Properties of a PostPanelV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IPostPanelV2\n                         * @property {number|Long|null} [start] PostPanelV2 start\n                         * @property {number|Long|null} [end] PostPanelV2 end\n                         * @property {number|null} [bizType] PostPanelV2 bizType\n                         * @property {bilibili.community.service.dm.v1.IClickButtonV2|null} [clickButton] PostPanelV2 clickButton\n                         * @property {bilibili.community.service.dm.v1.ITextInputV2|null} [textInput] PostPanelV2 textInput\n                         * @property {bilibili.community.service.dm.v1.ICheckBoxV2|null} [checkBox] PostPanelV2 checkBox\n                         * @property {bilibili.community.service.dm.v1.IToastV2|null} [toast] PostPanelV2 toast\n                         * @property {bilibili.community.service.dm.v1.IBubbleV2|null} [bubble] PostPanelV2 bubble\n                         * @property {bilibili.community.service.dm.v1.ILabelV2|null} [label] PostPanelV2 label\n                         * @property {number|null} [postStatus] PostPanelV2 postStatus\n                         */\n\n                        /**\n                         * Constructs a new PostPanelV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a PostPanelV2.\n                         * @implements IPostPanelV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IPostPanelV2=} [properties] Properties to set\n                         */\n                        function PostPanelV2(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * PostPanelV2 start.\n                         * @member {number|Long} start\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.start = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * PostPanelV2 end.\n                         * @member {number|Long} end\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.end = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * PostPanelV2 bizType.\n                         * @member {number} bizType\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.bizType = 0;\n\n                        /**\n                         * PostPanelV2 clickButton.\n                         * @member {bilibili.community.service.dm.v1.IClickButtonV2|null|undefined} clickButton\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.clickButton = null;\n\n                        /**\n                         * PostPanelV2 textInput.\n                         * @member {bilibili.community.service.dm.v1.ITextInputV2|null|undefined} textInput\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.textInput = null;\n\n                        /**\n                         * PostPanelV2 checkBox.\n                         * @member {bilibili.community.service.dm.v1.ICheckBoxV2|null|undefined} checkBox\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.checkBox = null;\n\n                        /**\n                         * PostPanelV2 toast.\n                         * @member {bilibili.community.service.dm.v1.IToastV2|null|undefined} toast\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.toast = null;\n\n                        /**\n                         * PostPanelV2 bubble.\n                         * @member {bilibili.community.service.dm.v1.IBubbleV2|null|undefined} bubble\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.bubble = null;\n\n                        /**\n                         * PostPanelV2 label.\n                         * @member {bilibili.community.service.dm.v1.ILabelV2|null|undefined} label\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.label = null;\n\n                        /**\n                         * PostPanelV2 postStatus.\n                         * @member {number} postStatus\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         */\n                        PostPanelV2.prototype.postStatus = 0;\n\n                        /**\n                         * Creates a new PostPanelV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPostPanelV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.PostPanelV2} PostPanelV2 instance\n                         */\n                        PostPanelV2.create = function create(properties) {\n                            return new PostPanelV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified PostPanelV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanelV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPostPanelV2} message PostPanelV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PostPanelV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.start != null && Object.hasOwnProperty.call(message, \"start\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.start);\n                            if (message.end != null && Object.hasOwnProperty.call(message, \"end\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int64(message.end);\n                            if (message.bizType != null && Object.hasOwnProperty.call(message, \"bizType\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.bizType);\n                            if (message.clickButton != null && Object.hasOwnProperty.call(message, \"clickButton\"))\n                                $root.bilibili.community.service.dm.v1.ClickButtonV2.encode(message.clickButton, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim();\n                            if (message.textInput != null && Object.hasOwnProperty.call(message, \"textInput\"))\n                                $root.bilibili.community.service.dm.v1.TextInputV2.encode(message.textInput, writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim();\n                            if (message.checkBox != null && Object.hasOwnProperty.call(message, \"checkBox\"))\n                                $root.bilibili.community.service.dm.v1.CheckBoxV2.encode(message.checkBox, writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim();\n                            if (message.toast != null && Object.hasOwnProperty.call(message, \"toast\"))\n                                $root.bilibili.community.service.dm.v1.ToastV2.encode(message.toast, writer.uint32(/* id 7, wireType 2 =*/58).fork()).ldelim();\n                            if (message.bubble != null && Object.hasOwnProperty.call(message, \"bubble\"))\n                                $root.bilibili.community.service.dm.v1.BubbleV2.encode(message.bubble, writer.uint32(/* id 8, wireType 2 =*/66).fork()).ldelim();\n                            if (message.label != null && Object.hasOwnProperty.call(message, \"label\"))\n                                $root.bilibili.community.service.dm.v1.LabelV2.encode(message.label, writer.uint32(/* id 9, wireType 2 =*/74).fork()).ldelim();\n                            if (message.postStatus != null && Object.hasOwnProperty.call(message, \"postStatus\"))\n                                writer.uint32(/* id 10, wireType 0 =*/80).int32(message.postStatus);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified PostPanelV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.PostPanelV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IPostPanelV2} message PostPanelV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        PostPanelV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a PostPanelV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.PostPanelV2} PostPanelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PostPanelV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.PostPanelV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.start = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.end = reader.int64();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.bizType = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.clickButton = $root.bilibili.community.service.dm.v1.ClickButtonV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 5: {\n                                        message.textInput = $root.bilibili.community.service.dm.v1.TextInputV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 6: {\n                                        message.checkBox = $root.bilibili.community.service.dm.v1.CheckBoxV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 7: {\n                                        message.toast = $root.bilibili.community.service.dm.v1.ToastV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 8: {\n                                        message.bubble = $root.bilibili.community.service.dm.v1.BubbleV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 9: {\n                                        message.label = $root.bilibili.community.service.dm.v1.LabelV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 10: {\n                                        message.postStatus = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a PostPanelV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.PostPanelV2} PostPanelV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        PostPanelV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a PostPanelV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        PostPanelV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.start != null && message.hasOwnProperty(\"start\"))\n                                if (!$util.isInteger(message.start) && !(message.start && $util.isInteger(message.start.low) && $util.isInteger(message.start.high)))\n                                    return \"start: integer|Long expected\";\n                            if (message.end != null && message.hasOwnProperty(\"end\"))\n                                if (!$util.isInteger(message.end) && !(message.end && $util.isInteger(message.end.low) && $util.isInteger(message.end.high)))\n                                    return \"end: integer|Long expected\";\n                            if (message.bizType != null && message.hasOwnProperty(\"bizType\"))\n                                if (!$util.isInteger(message.bizType))\n                                    return \"bizType: integer expected\";\n                            if (message.clickButton != null && message.hasOwnProperty(\"clickButton\")) {\n                                var error = $root.bilibili.community.service.dm.v1.ClickButtonV2.verify(message.clickButton);\n                                if (error)\n                                    return \"clickButton.\" + error;\n                            }\n                            if (message.textInput != null && message.hasOwnProperty(\"textInput\")) {\n                                var error = $root.bilibili.community.service.dm.v1.TextInputV2.verify(message.textInput);\n                                if (error)\n                                    return \"textInput.\" + error;\n                            }\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\")) {\n                                var error = $root.bilibili.community.service.dm.v1.CheckBoxV2.verify(message.checkBox);\n                                if (error)\n                                    return \"checkBox.\" + error;\n                            }\n                            if (message.toast != null && message.hasOwnProperty(\"toast\")) {\n                                var error = $root.bilibili.community.service.dm.v1.ToastV2.verify(message.toast);\n                                if (error)\n                                    return \"toast.\" + error;\n                            }\n                            if (message.bubble != null && message.hasOwnProperty(\"bubble\")) {\n                                var error = $root.bilibili.community.service.dm.v1.BubbleV2.verify(message.bubble);\n                                if (error)\n                                    return \"bubble.\" + error;\n                            }\n                            if (message.label != null && message.hasOwnProperty(\"label\")) {\n                                var error = $root.bilibili.community.service.dm.v1.LabelV2.verify(message.label);\n                                if (error)\n                                    return \"label.\" + error;\n                            }\n                            if (message.postStatus != null && message.hasOwnProperty(\"postStatus\"))\n                                if (!$util.isInteger(message.postStatus))\n                                    return \"postStatus: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a PostPanelV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.PostPanelV2} PostPanelV2\n                         */\n                        PostPanelV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.PostPanelV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.PostPanelV2();\n                            if (object.start != null)\n                                if ($util.Long)\n                                    (message.start = $util.Long.fromValue(object.start)).unsigned = false;\n                                else if (typeof object.start === \"string\")\n                                    message.start = parseInt(object.start, 10);\n                                else if (typeof object.start === \"number\")\n                                    message.start = object.start;\n                                else if (typeof object.start === \"object\")\n                                    message.start = new $util.LongBits(object.start.low >>> 0, object.start.high >>> 0).toNumber();\n                            if (object.end != null)\n                                if ($util.Long)\n                                    (message.end = $util.Long.fromValue(object.end)).unsigned = false;\n                                else if (typeof object.end === \"string\")\n                                    message.end = parseInt(object.end, 10);\n                                else if (typeof object.end === \"number\")\n                                    message.end = object.end;\n                                else if (typeof object.end === \"object\")\n                                    message.end = new $util.LongBits(object.end.low >>> 0, object.end.high >>> 0).toNumber();\n                            if (object.bizType != null)\n                                message.bizType = object.bizType | 0;\n                            if (object.clickButton != null) {\n                                if (typeof object.clickButton !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanelV2.clickButton: object expected\");\n                                message.clickButton = $root.bilibili.community.service.dm.v1.ClickButtonV2.fromObject(object.clickButton);\n                            }\n                            if (object.textInput != null) {\n                                if (typeof object.textInput !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanelV2.textInput: object expected\");\n                                message.textInput = $root.bilibili.community.service.dm.v1.TextInputV2.fromObject(object.textInput);\n                            }\n                            if (object.checkBox != null) {\n                                if (typeof object.checkBox !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanelV2.checkBox: object expected\");\n                                message.checkBox = $root.bilibili.community.service.dm.v1.CheckBoxV2.fromObject(object.checkBox);\n                            }\n                            if (object.toast != null) {\n                                if (typeof object.toast !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanelV2.toast: object expected\");\n                                message.toast = $root.bilibili.community.service.dm.v1.ToastV2.fromObject(object.toast);\n                            }\n                            if (object.bubble != null) {\n                                if (typeof object.bubble !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanelV2.bubble: object expected\");\n                                message.bubble = $root.bilibili.community.service.dm.v1.BubbleV2.fromObject(object.bubble);\n                            }\n                            if (object.label != null) {\n                                if (typeof object.label !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.PostPanelV2.label: object expected\");\n                                message.label = $root.bilibili.community.service.dm.v1.LabelV2.fromObject(object.label);\n                            }\n                            if (object.postStatus != null)\n                                message.postStatus = object.postStatus | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a PostPanelV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.PostPanelV2} message PostPanelV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        PostPanelV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.start = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.start = options.longs === String ? \"0\" : 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.end = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.end = options.longs === String ? \"0\" : 0;\n                                object.bizType = 0;\n                                object.clickButton = null;\n                                object.textInput = null;\n                                object.checkBox = null;\n                                object.toast = null;\n                                object.bubble = null;\n                                object.label = null;\n                                object.postStatus = 0;\n                            }\n                            if (message.start != null && message.hasOwnProperty(\"start\"))\n                                if (typeof message.start === \"number\")\n                                    object.start = options.longs === String ? String(message.start) : message.start;\n                                else\n                                    object.start = options.longs === String ? $util.Long.prototype.toString.call(message.start) : options.longs === Number ? new $util.LongBits(message.start.low >>> 0, message.start.high >>> 0).toNumber() : message.start;\n                            if (message.end != null && message.hasOwnProperty(\"end\"))\n                                if (typeof message.end === \"number\")\n                                    object.end = options.longs === String ? String(message.end) : message.end;\n                                else\n                                    object.end = options.longs === String ? $util.Long.prototype.toString.call(message.end) : options.longs === Number ? new $util.LongBits(message.end.low >>> 0, message.end.high >>> 0).toNumber() : message.end;\n                            if (message.bizType != null && message.hasOwnProperty(\"bizType\"))\n                                object.bizType = message.bizType;\n                            if (message.clickButton != null && message.hasOwnProperty(\"clickButton\"))\n                                object.clickButton = $root.bilibili.community.service.dm.v1.ClickButtonV2.toObject(message.clickButton, options);\n                            if (message.textInput != null && message.hasOwnProperty(\"textInput\"))\n                                object.textInput = $root.bilibili.community.service.dm.v1.TextInputV2.toObject(message.textInput, options);\n                            if (message.checkBox != null && message.hasOwnProperty(\"checkBox\"))\n                                object.checkBox = $root.bilibili.community.service.dm.v1.CheckBoxV2.toObject(message.checkBox, options);\n                            if (message.toast != null && message.hasOwnProperty(\"toast\"))\n                                object.toast = $root.bilibili.community.service.dm.v1.ToastV2.toObject(message.toast, options);\n                            if (message.bubble != null && message.hasOwnProperty(\"bubble\"))\n                                object.bubble = $root.bilibili.community.service.dm.v1.BubbleV2.toObject(message.bubble, options);\n                            if (message.label != null && message.hasOwnProperty(\"label\"))\n                                object.label = $root.bilibili.community.service.dm.v1.LabelV2.toObject(message.label, options);\n                            if (message.postStatus != null && message.hasOwnProperty(\"postStatus\"))\n                                object.postStatus = message.postStatus;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this PostPanelV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        PostPanelV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for PostPanelV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.PostPanelV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        PostPanelV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.PostPanelV2\";\n                        };\n\n                        return PostPanelV2;\n                    })();\n\n                    /**\n                     * PostStatus enum.\n                     * @name bilibili.community.service.dm.v1.PostStatus\n                     * @enum {number}\n                     * @property {number} PostStatusNormal=0 PostStatusNormal value\n                     * @property {number} PostStatusClosed=1 PostStatusClosed value\n                     */\n                    v1.PostStatus = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"PostStatusNormal\"] = 0;\n                        values[valuesById[1] = \"PostStatusClosed\"] = 1;\n                        return values;\n                    })();\n\n                    /**\n                     * RenderType enum.\n                     * @name bilibili.community.service.dm.v1.RenderType\n                     * @enum {number}\n                     * @property {number} RenderTypeNone=0 RenderTypeNone value\n                     * @property {number} RenderTypeSingle=1 RenderTypeSingle value\n                     * @property {number} RenderTypeRotation=2 RenderTypeRotation value\n                     */\n                    v1.RenderType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"RenderTypeNone\"] = 0;\n                        values[valuesById[1] = \"RenderTypeSingle\"] = 1;\n                        values[valuesById[2] = \"RenderTypeRotation\"] = 2;\n                        return values;\n                    })();\n\n                    v1.Response = (function() {\n\n                        /**\n                         * Properties of a Response.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IResponse\n                         * @property {number|null} [code] Response code\n                         * @property {string|null} [message] Response message\n                         */\n\n                        /**\n                         * Constructs a new Response.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a Response.\n                         * @implements IResponse\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IResponse=} [properties] Properties to set\n                         */\n                        function Response(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Response code.\n                         * @member {number} code\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @instance\n                         */\n                        Response.prototype.code = 0;\n\n                        /**\n                         * Response message.\n                         * @member {string} message\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @instance\n                         */\n                        Response.prototype.message = \"\";\n\n                        /**\n                         * Creates a new Response instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IResponse=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Response} Response instance\n                         */\n                        Response.create = function create(properties) {\n                            return new Response(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Response message. Does not implicitly {@link bilibili.community.service.dm.v1.Response.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IResponse} message Response message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Response.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.code != null && Object.hasOwnProperty.call(message, \"code\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int32(message.code);\n                            if (message.message != null && Object.hasOwnProperty.call(message, \"message\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.message);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Response message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Response.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IResponse} message Response message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Response.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a Response message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Response} Response\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Response.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Response();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.code = reader.int32();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.message = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a Response message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Response} Response\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Response.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a Response message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Response.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.code != null && message.hasOwnProperty(\"code\"))\n                                if (!$util.isInteger(message.code))\n                                    return \"code: integer expected\";\n                            if (message.message != null && message.hasOwnProperty(\"message\"))\n                                if (!$util.isString(message.message))\n                                    return \"message: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a Response message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Response} Response\n                         */\n                        Response.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Response)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Response();\n                            if (object.code != null)\n                                message.code = object.code | 0;\n                            if (object.message != null)\n                                message.message = String(object.message);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a Response message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Response} message Response\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Response.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.code = 0;\n                                object.message = \"\";\n                            }\n                            if (message.code != null && message.hasOwnProperty(\"code\"))\n                                object.code = message.code;\n                            if (message.message != null && message.hasOwnProperty(\"message\"))\n                                object.message = message.message;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Response to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Response.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Response\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Response\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Response.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Response\";\n                        };\n\n                        return Response;\n                    })();\n\n                    /**\n                     * SubtitleAiStatus enum.\n                     * @name bilibili.community.service.dm.v1.SubtitleAiStatus\n                     * @enum {number}\n                     * @property {number} None=0 None value\n                     * @property {number} Exposure=1 Exposure value\n                     * @property {number} Assist=2 Assist value\n                     */\n                    v1.SubtitleAiStatus = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"None\"] = 0;\n                        values[valuesById[1] = \"Exposure\"] = 1;\n                        values[valuesById[2] = \"Assist\"] = 2;\n                        return values;\n                    })();\n\n                    /**\n                     * SubtitleAiType enum.\n                     * @name bilibili.community.service.dm.v1.SubtitleAiType\n                     * @enum {number}\n                     * @property {number} Normal=0 Normal value\n                     * @property {number} Translate=1 Translate value\n                     */\n                    v1.SubtitleAiType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"Normal\"] = 0;\n                        values[valuesById[1] = \"Translate\"] = 1;\n                        return values;\n                    })();\n\n                    v1.SubtitleItem = (function() {\n\n                        /**\n                         * Properties of a SubtitleItem.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ISubtitleItem\n                         * @property {number|Long|null} [id] SubtitleItem id\n                         * @property {string|null} [idStr] SubtitleItem idStr\n                         * @property {string|null} [lan] SubtitleItem lan\n                         * @property {string|null} [lanDoc] SubtitleItem lanDoc\n                         * @property {string|null} [subtitleUrl] SubtitleItem subtitleUrl\n                         * @property {bilibili.community.service.dm.v1.IUserInfo|null} [author] SubtitleItem author\n                         * @property {bilibili.community.service.dm.v1.SubtitleType|null} [type] SubtitleItem type\n                         * @property {string|null} [lanDocBrief] SubtitleItem lanDocBrief\n                         * @property {bilibili.community.service.dm.v1.SubtitleAiType|null} [aiType] SubtitleItem aiType\n                         * @property {bilibili.community.service.dm.v1.SubtitleAiStatus|null} [aiStatus] SubtitleItem aiStatus\n                         */\n\n                        /**\n                         * Constructs a new SubtitleItem.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a SubtitleItem.\n                         * @implements ISubtitleItem\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ISubtitleItem=} [properties] Properties to set\n                         */\n                        function SubtitleItem(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * SubtitleItem id.\n                         * @member {number|Long} id\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.id = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * SubtitleItem idStr.\n                         * @member {string} idStr\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.idStr = \"\";\n\n                        /**\n                         * SubtitleItem lan.\n                         * @member {string} lan\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.lan = \"\";\n\n                        /**\n                         * SubtitleItem lanDoc.\n                         * @member {string} lanDoc\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.lanDoc = \"\";\n\n                        /**\n                         * SubtitleItem subtitleUrl.\n                         * @member {string} subtitleUrl\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.subtitleUrl = \"\";\n\n                        /**\n                         * SubtitleItem author.\n                         * @member {bilibili.community.service.dm.v1.IUserInfo|null|undefined} author\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.author = null;\n\n                        /**\n                         * SubtitleItem type.\n                         * @member {bilibili.community.service.dm.v1.SubtitleType} type\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.type = 0;\n\n                        /**\n                         * SubtitleItem lanDocBrief.\n                         * @member {string} lanDocBrief\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.lanDocBrief = \"\";\n\n                        /**\n                         * SubtitleItem aiType.\n                         * @member {bilibili.community.service.dm.v1.SubtitleAiType} aiType\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.aiType = 0;\n\n                        /**\n                         * SubtitleItem aiStatus.\n                         * @member {bilibili.community.service.dm.v1.SubtitleAiStatus} aiStatus\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         */\n                        SubtitleItem.prototype.aiStatus = 0;\n\n                        /**\n                         * Creates a new SubtitleItem instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ISubtitleItem=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.SubtitleItem} SubtitleItem instance\n                         */\n                        SubtitleItem.create = function create(properties) {\n                            return new SubtitleItem(properties);\n                        };\n\n                        /**\n                         * Encodes the specified SubtitleItem message. Does not implicitly {@link bilibili.community.service.dm.v1.SubtitleItem.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ISubtitleItem} message SubtitleItem message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        SubtitleItem.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.id != null && Object.hasOwnProperty.call(message, \"id\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.id);\n                            if (message.idStr != null && Object.hasOwnProperty.call(message, \"idStr\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.idStr);\n                            if (message.lan != null && Object.hasOwnProperty.call(message, \"lan\"))\n                                writer.uint32(/* id 3, wireType 2 =*/26).string(message.lan);\n                            if (message.lanDoc != null && Object.hasOwnProperty.call(message, \"lanDoc\"))\n                                writer.uint32(/* id 4, wireType 2 =*/34).string(message.lanDoc);\n                            if (message.subtitleUrl != null && Object.hasOwnProperty.call(message, \"subtitleUrl\"))\n                                writer.uint32(/* id 5, wireType 2 =*/42).string(message.subtitleUrl);\n                            if (message.author != null && Object.hasOwnProperty.call(message, \"author\"))\n                                $root.bilibili.community.service.dm.v1.UserInfo.encode(message.author, writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim();\n                            if (message.type != null && Object.hasOwnProperty.call(message, \"type\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).int32(message.type);\n                            if (message.lanDocBrief != null && Object.hasOwnProperty.call(message, \"lanDocBrief\"))\n                                writer.uint32(/* id 8, wireType 2 =*/66).string(message.lanDocBrief);\n                            if (message.aiType != null && Object.hasOwnProperty.call(message, \"aiType\"))\n                                writer.uint32(/* id 9, wireType 0 =*/72).int32(message.aiType);\n                            if (message.aiStatus != null && Object.hasOwnProperty.call(message, \"aiStatus\"))\n                                writer.uint32(/* id 10, wireType 0 =*/80).int32(message.aiStatus);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified SubtitleItem message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.SubtitleItem.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ISubtitleItem} message SubtitleItem message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        SubtitleItem.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a SubtitleItem message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.SubtitleItem} SubtitleItem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        SubtitleItem.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.SubtitleItem();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.id = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.idStr = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.lan = reader.string();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.lanDoc = reader.string();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.subtitleUrl = reader.string();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.author = $root.bilibili.community.service.dm.v1.UserInfo.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                case 7: {\n                                        message.type = reader.int32();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.lanDocBrief = reader.string();\n                                        break;\n                                    }\n                                case 9: {\n                                        message.aiType = reader.int32();\n                                        break;\n                                    }\n                                case 10: {\n                                        message.aiStatus = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a SubtitleItem message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.SubtitleItem} SubtitleItem\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        SubtitleItem.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a SubtitleItem message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        SubtitleItem.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (!$util.isInteger(message.id) && !(message.id && $util.isInteger(message.id.low) && $util.isInteger(message.id.high)))\n                                    return \"id: integer|Long expected\";\n                            if (message.idStr != null && message.hasOwnProperty(\"idStr\"))\n                                if (!$util.isString(message.idStr))\n                                    return \"idStr: string expected\";\n                            if (message.lan != null && message.hasOwnProperty(\"lan\"))\n                                if (!$util.isString(message.lan))\n                                    return \"lan: string expected\";\n                            if (message.lanDoc != null && message.hasOwnProperty(\"lanDoc\"))\n                                if (!$util.isString(message.lanDoc))\n                                    return \"lanDoc: string expected\";\n                            if (message.subtitleUrl != null && message.hasOwnProperty(\"subtitleUrl\"))\n                                if (!$util.isString(message.subtitleUrl))\n                                    return \"subtitleUrl: string expected\";\n                            if (message.author != null && message.hasOwnProperty(\"author\")) {\n                                var error = $root.bilibili.community.service.dm.v1.UserInfo.verify(message.author);\n                                if (error)\n                                    return \"author.\" + error;\n                            }\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                switch (message.type) {\n                                default:\n                                    return \"type: enum value expected\";\n                                case 0:\n                                case 1:\n                                    break;\n                                }\n                            if (message.lanDocBrief != null && message.hasOwnProperty(\"lanDocBrief\"))\n                                if (!$util.isString(message.lanDocBrief))\n                                    return \"lanDocBrief: string expected\";\n                            if (message.aiType != null && message.hasOwnProperty(\"aiType\"))\n                                switch (message.aiType) {\n                                default:\n                                    return \"aiType: enum value expected\";\n                                case 0:\n                                case 1:\n                                    break;\n                                }\n                            if (message.aiStatus != null && message.hasOwnProperty(\"aiStatus\"))\n                                switch (message.aiStatus) {\n                                default:\n                                    return \"aiStatus: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                    break;\n                                }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a SubtitleItem message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.SubtitleItem} SubtitleItem\n                         */\n                        SubtitleItem.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.SubtitleItem)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.SubtitleItem();\n                            if (object.id != null)\n                                if ($util.Long)\n                                    (message.id = $util.Long.fromValue(object.id)).unsigned = false;\n                                else if (typeof object.id === \"string\")\n                                    message.id = parseInt(object.id, 10);\n                                else if (typeof object.id === \"number\")\n                                    message.id = object.id;\n                                else if (typeof object.id === \"object\")\n                                    message.id = new $util.LongBits(object.id.low >>> 0, object.id.high >>> 0).toNumber();\n                            if (object.idStr != null)\n                                message.idStr = String(object.idStr);\n                            if (object.lan != null)\n                                message.lan = String(object.lan);\n                            if (object.lanDoc != null)\n                                message.lanDoc = String(object.lanDoc);\n                            if (object.subtitleUrl != null)\n                                message.subtitleUrl = String(object.subtitleUrl);\n                            if (object.author != null) {\n                                if (typeof object.author !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.SubtitleItem.author: object expected\");\n                                message.author = $root.bilibili.community.service.dm.v1.UserInfo.fromObject(object.author);\n                            }\n                            switch (object.type) {\n                            default:\n                                if (typeof object.type === \"number\") {\n                                    message.type = object.type;\n                                    break;\n                                }\n                                break;\n                            case \"CC\":\n                            case 0:\n                                message.type = 0;\n                                break;\n                            case \"AI\":\n                            case 1:\n                                message.type = 1;\n                                break;\n                            }\n                            if (object.lanDocBrief != null)\n                                message.lanDocBrief = String(object.lanDocBrief);\n                            switch (object.aiType) {\n                            default:\n                                if (typeof object.aiType === \"number\") {\n                                    message.aiType = object.aiType;\n                                    break;\n                                }\n                                break;\n                            case \"Normal\":\n                            case 0:\n                                message.aiType = 0;\n                                break;\n                            case \"Translate\":\n                            case 1:\n                                message.aiType = 1;\n                                break;\n                            }\n                            switch (object.aiStatus) {\n                            default:\n                                if (typeof object.aiStatus === \"number\") {\n                                    message.aiStatus = object.aiStatus;\n                                    break;\n                                }\n                                break;\n                            case \"None\":\n                            case 0:\n                                message.aiStatus = 0;\n                                break;\n                            case \"Exposure\":\n                            case 1:\n                                message.aiStatus = 1;\n                                break;\n                            case \"Assist\":\n                            case 2:\n                                message.aiStatus = 2;\n                                break;\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a SubtitleItem message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.SubtitleItem} message SubtitleItem\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        SubtitleItem.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.id = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.id = options.longs === String ? \"0\" : 0;\n                                object.idStr = \"\";\n                                object.lan = \"\";\n                                object.lanDoc = \"\";\n                                object.subtitleUrl = \"\";\n                                object.author = null;\n                                object.type = options.enums === String ? \"CC\" : 0;\n                                object.lanDocBrief = \"\";\n                                object.aiType = options.enums === String ? \"Normal\" : 0;\n                                object.aiStatus = options.enums === String ? \"None\" : 0;\n                            }\n                            if (message.id != null && message.hasOwnProperty(\"id\"))\n                                if (typeof message.id === \"number\")\n                                    object.id = options.longs === String ? String(message.id) : message.id;\n                                else\n                                    object.id = options.longs === String ? $util.Long.prototype.toString.call(message.id) : options.longs === Number ? new $util.LongBits(message.id.low >>> 0, message.id.high >>> 0).toNumber() : message.id;\n                            if (message.idStr != null && message.hasOwnProperty(\"idStr\"))\n                                object.idStr = message.idStr;\n                            if (message.lan != null && message.hasOwnProperty(\"lan\"))\n                                object.lan = message.lan;\n                            if (message.lanDoc != null && message.hasOwnProperty(\"lanDoc\"))\n                                object.lanDoc = message.lanDoc;\n                            if (message.subtitleUrl != null && message.hasOwnProperty(\"subtitleUrl\"))\n                                object.subtitleUrl = message.subtitleUrl;\n                            if (message.author != null && message.hasOwnProperty(\"author\"))\n                                object.author = $root.bilibili.community.service.dm.v1.UserInfo.toObject(message.author, options);\n                            if (message.type != null && message.hasOwnProperty(\"type\"))\n                                object.type = options.enums === String ? $root.bilibili.community.service.dm.v1.SubtitleType[message.type] === undefined ? message.type : $root.bilibili.community.service.dm.v1.SubtitleType[message.type] : message.type;\n                            if (message.lanDocBrief != null && message.hasOwnProperty(\"lanDocBrief\"))\n                                object.lanDocBrief = message.lanDocBrief;\n                            if (message.aiType != null && message.hasOwnProperty(\"aiType\"))\n                                object.aiType = options.enums === String ? $root.bilibili.community.service.dm.v1.SubtitleAiType[message.aiType] === undefined ? message.aiType : $root.bilibili.community.service.dm.v1.SubtitleAiType[message.aiType] : message.aiType;\n                            if (message.aiStatus != null && message.hasOwnProperty(\"aiStatus\"))\n                                object.aiStatus = options.enums === String ? $root.bilibili.community.service.dm.v1.SubtitleAiStatus[message.aiStatus] === undefined ? message.aiStatus : $root.bilibili.community.service.dm.v1.SubtitleAiStatus[message.aiStatus] : message.aiStatus;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this SubtitleItem to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        SubtitleItem.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for SubtitleItem\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.SubtitleItem\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        SubtitleItem.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.SubtitleItem\";\n                        };\n\n                        return SubtitleItem;\n                    })();\n\n                    /**\n                     * SubtitleType enum.\n                     * @name bilibili.community.service.dm.v1.SubtitleType\n                     * @enum {number}\n                     * @property {number} CC=0 CC value\n                     * @property {number} AI=1 AI value\n                     */\n                    v1.SubtitleType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"CC\"] = 0;\n                        values[valuesById[1] = \"AI\"] = 1;\n                        return values;\n                    })();\n\n                    v1.TextInput = (function() {\n\n                        /**\n                         * Properties of a TextInput.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ITextInput\n                         * @property {Array.<string>|null} [portraitPlaceholder] TextInput portraitPlaceholder\n                         * @property {Array.<string>|null} [landscapePlaceholder] TextInput landscapePlaceholder\n                         * @property {bilibili.community.service.dm.v1.RenderType|null} [renderType] TextInput renderType\n                         * @property {boolean|null} [placeholderPost] TextInput placeholderPost\n                         * @property {boolean|null} [show] TextInput show\n                         * @property {Array.<bilibili.community.service.dm.v1.IAvatar>|null} [avatar] TextInput avatar\n                         * @property {bilibili.community.service.dm.v1.PostStatus|null} [postStatus] TextInput postStatus\n                         * @property {bilibili.community.service.dm.v1.ILabel|null} [label] TextInput label\n                         */\n\n                        /**\n                         * Constructs a new TextInput.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a TextInput.\n                         * @implements ITextInput\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ITextInput=} [properties] Properties to set\n                         */\n                        function TextInput(properties) {\n                            this.portraitPlaceholder = [];\n                            this.landscapePlaceholder = [];\n                            this.avatar = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * TextInput portraitPlaceholder.\n                         * @member {Array.<string>} portraitPlaceholder\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.portraitPlaceholder = $util.emptyArray;\n\n                        /**\n                         * TextInput landscapePlaceholder.\n                         * @member {Array.<string>} landscapePlaceholder\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.landscapePlaceholder = $util.emptyArray;\n\n                        /**\n                         * TextInput renderType.\n                         * @member {bilibili.community.service.dm.v1.RenderType} renderType\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.renderType = 0;\n\n                        /**\n                         * TextInput placeholderPost.\n                         * @member {boolean} placeholderPost\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.placeholderPost = false;\n\n                        /**\n                         * TextInput show.\n                         * @member {boolean} show\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.show = false;\n\n                        /**\n                         * TextInput avatar.\n                         * @member {Array.<bilibili.community.service.dm.v1.IAvatar>} avatar\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.avatar = $util.emptyArray;\n\n                        /**\n                         * TextInput postStatus.\n                         * @member {bilibili.community.service.dm.v1.PostStatus} postStatus\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.postStatus = 0;\n\n                        /**\n                         * TextInput label.\n                         * @member {bilibili.community.service.dm.v1.ILabel|null|undefined} label\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         */\n                        TextInput.prototype.label = null;\n\n                        /**\n                         * Creates a new TextInput instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ITextInput=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.TextInput} TextInput instance\n                         */\n                        TextInput.create = function create(properties) {\n                            return new TextInput(properties);\n                        };\n\n                        /**\n                         * Encodes the specified TextInput message. Does not implicitly {@link bilibili.community.service.dm.v1.TextInput.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ITextInput} message TextInput message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        TextInput.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.portraitPlaceholder != null && message.portraitPlaceholder.length)\n                                for (var i = 0; i < message.portraitPlaceholder.length; ++i)\n                                    writer.uint32(/* id 1, wireType 2 =*/10).string(message.portraitPlaceholder[i]);\n                            if (message.landscapePlaceholder != null && message.landscapePlaceholder.length)\n                                for (var i = 0; i < message.landscapePlaceholder.length; ++i)\n                                    writer.uint32(/* id 2, wireType 2 =*/18).string(message.landscapePlaceholder[i]);\n                            if (message.renderType != null && Object.hasOwnProperty.call(message, \"renderType\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.renderType);\n                            if (message.placeholderPost != null && Object.hasOwnProperty.call(message, \"placeholderPost\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.placeholderPost);\n                            if (message.show != null && Object.hasOwnProperty.call(message, \"show\"))\n                                writer.uint32(/* id 5, wireType 0 =*/40).bool(message.show);\n                            if (message.avatar != null && message.avatar.length)\n                                for (var i = 0; i < message.avatar.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.Avatar.encode(message.avatar[i], writer.uint32(/* id 6, wireType 2 =*/50).fork()).ldelim();\n                            if (message.postStatus != null && Object.hasOwnProperty.call(message, \"postStatus\"))\n                                writer.uint32(/* id 7, wireType 0 =*/56).int32(message.postStatus);\n                            if (message.label != null && Object.hasOwnProperty.call(message, \"label\"))\n                                $root.bilibili.community.service.dm.v1.Label.encode(message.label, writer.uint32(/* id 8, wireType 2 =*/66).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified TextInput message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.TextInput.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ITextInput} message TextInput message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        TextInput.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a TextInput message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.TextInput} TextInput\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        TextInput.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.TextInput();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.portraitPlaceholder && message.portraitPlaceholder.length))\n                                            message.portraitPlaceholder = [];\n                                        message.portraitPlaceholder.push(reader.string());\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.landscapePlaceholder && message.landscapePlaceholder.length))\n                                            message.landscapePlaceholder = [];\n                                        message.landscapePlaceholder.push(reader.string());\n                                        break;\n                                    }\n                                case 3: {\n                                        message.renderType = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.placeholderPost = reader.bool();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.show = reader.bool();\n                                        break;\n                                    }\n                                case 6: {\n                                        if (!(message.avatar && message.avatar.length))\n                                            message.avatar = [];\n                                        message.avatar.push($root.bilibili.community.service.dm.v1.Avatar.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 7: {\n                                        message.postStatus = reader.int32();\n                                        break;\n                                    }\n                                case 8: {\n                                        message.label = $root.bilibili.community.service.dm.v1.Label.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a TextInput message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.TextInput} TextInput\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        TextInput.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a TextInput message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        TextInput.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.portraitPlaceholder != null && message.hasOwnProperty(\"portraitPlaceholder\")) {\n                                if (!Array.isArray(message.portraitPlaceholder))\n                                    return \"portraitPlaceholder: array expected\";\n                                for (var i = 0; i < message.portraitPlaceholder.length; ++i)\n                                    if (!$util.isString(message.portraitPlaceholder[i]))\n                                        return \"portraitPlaceholder: string[] expected\";\n                            }\n                            if (message.landscapePlaceholder != null && message.hasOwnProperty(\"landscapePlaceholder\")) {\n                                if (!Array.isArray(message.landscapePlaceholder))\n                                    return \"landscapePlaceholder: array expected\";\n                                for (var i = 0; i < message.landscapePlaceholder.length; ++i)\n                                    if (!$util.isString(message.landscapePlaceholder[i]))\n                                        return \"landscapePlaceholder: string[] expected\";\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                switch (message.renderType) {\n                                default:\n                                    return \"renderType: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                    break;\n                                }\n                            if (message.placeholderPost != null && message.hasOwnProperty(\"placeholderPost\"))\n                                if (typeof message.placeholderPost !== \"boolean\")\n                                    return \"placeholderPost: boolean expected\";\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                if (typeof message.show !== \"boolean\")\n                                    return \"show: boolean expected\";\n                            if (message.avatar != null && message.hasOwnProperty(\"avatar\")) {\n                                if (!Array.isArray(message.avatar))\n                                    return \"avatar: array expected\";\n                                for (var i = 0; i < message.avatar.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.Avatar.verify(message.avatar[i]);\n                                    if (error)\n                                        return \"avatar.\" + error;\n                                }\n                            }\n                            if (message.postStatus != null && message.hasOwnProperty(\"postStatus\"))\n                                switch (message.postStatus) {\n                                default:\n                                    return \"postStatus: enum value expected\";\n                                case 0:\n                                case 1:\n                                    break;\n                                }\n                            if (message.label != null && message.hasOwnProperty(\"label\")) {\n                                var error = $root.bilibili.community.service.dm.v1.Label.verify(message.label);\n                                if (error)\n                                    return \"label.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a TextInput message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.TextInput} TextInput\n                         */\n                        TextInput.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.TextInput)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.TextInput();\n                            if (object.portraitPlaceholder) {\n                                if (!Array.isArray(object.portraitPlaceholder))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInput.portraitPlaceholder: array expected\");\n                                message.portraitPlaceholder = [];\n                                for (var i = 0; i < object.portraitPlaceholder.length; ++i)\n                                    message.portraitPlaceholder[i] = String(object.portraitPlaceholder[i]);\n                            }\n                            if (object.landscapePlaceholder) {\n                                if (!Array.isArray(object.landscapePlaceholder))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInput.landscapePlaceholder: array expected\");\n                                message.landscapePlaceholder = [];\n                                for (var i = 0; i < object.landscapePlaceholder.length; ++i)\n                                    message.landscapePlaceholder[i] = String(object.landscapePlaceholder[i]);\n                            }\n                            switch (object.renderType) {\n                            default:\n                                if (typeof object.renderType === \"number\") {\n                                    message.renderType = object.renderType;\n                                    break;\n                                }\n                                break;\n                            case \"RenderTypeNone\":\n                            case 0:\n                                message.renderType = 0;\n                                break;\n                            case \"RenderTypeSingle\":\n                            case 1:\n                                message.renderType = 1;\n                                break;\n                            case \"RenderTypeRotation\":\n                            case 2:\n                                message.renderType = 2;\n                                break;\n                            }\n                            if (object.placeholderPost != null)\n                                message.placeholderPost = Boolean(object.placeholderPost);\n                            if (object.show != null)\n                                message.show = Boolean(object.show);\n                            if (object.avatar) {\n                                if (!Array.isArray(object.avatar))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInput.avatar: array expected\");\n                                message.avatar = [];\n                                for (var i = 0; i < object.avatar.length; ++i) {\n                                    if (typeof object.avatar[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.TextInput.avatar: object expected\");\n                                    message.avatar[i] = $root.bilibili.community.service.dm.v1.Avatar.fromObject(object.avatar[i]);\n                                }\n                            }\n                            switch (object.postStatus) {\n                            default:\n                                if (typeof object.postStatus === \"number\") {\n                                    message.postStatus = object.postStatus;\n                                    break;\n                                }\n                                break;\n                            case \"PostStatusNormal\":\n                            case 0:\n                                message.postStatus = 0;\n                                break;\n                            case \"PostStatusClosed\":\n                            case 1:\n                                message.postStatus = 1;\n                                break;\n                            }\n                            if (object.label != null) {\n                                if (typeof object.label !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInput.label: object expected\");\n                                message.label = $root.bilibili.community.service.dm.v1.Label.fromObject(object.label);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a TextInput message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.TextInput} message TextInput\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        TextInput.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.portraitPlaceholder = [];\n                                object.landscapePlaceholder = [];\n                                object.avatar = [];\n                            }\n                            if (options.defaults) {\n                                object.renderType = options.enums === String ? \"RenderTypeNone\" : 0;\n                                object.placeholderPost = false;\n                                object.show = false;\n                                object.postStatus = options.enums === String ? \"PostStatusNormal\" : 0;\n                                object.label = null;\n                            }\n                            if (message.portraitPlaceholder && message.portraitPlaceholder.length) {\n                                object.portraitPlaceholder = [];\n                                for (var j = 0; j < message.portraitPlaceholder.length; ++j)\n                                    object.portraitPlaceholder[j] = message.portraitPlaceholder[j];\n                            }\n                            if (message.landscapePlaceholder && message.landscapePlaceholder.length) {\n                                object.landscapePlaceholder = [];\n                                for (var j = 0; j < message.landscapePlaceholder.length; ++j)\n                                    object.landscapePlaceholder[j] = message.landscapePlaceholder[j];\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                object.renderType = options.enums === String ? $root.bilibili.community.service.dm.v1.RenderType[message.renderType] === undefined ? message.renderType : $root.bilibili.community.service.dm.v1.RenderType[message.renderType] : message.renderType;\n                            if (message.placeholderPost != null && message.hasOwnProperty(\"placeholderPost\"))\n                                object.placeholderPost = message.placeholderPost;\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                object.show = message.show;\n                            if (message.avatar && message.avatar.length) {\n                                object.avatar = [];\n                                for (var j = 0; j < message.avatar.length; ++j)\n                                    object.avatar[j] = $root.bilibili.community.service.dm.v1.Avatar.toObject(message.avatar[j], options);\n                            }\n                            if (message.postStatus != null && message.hasOwnProperty(\"postStatus\"))\n                                object.postStatus = options.enums === String ? $root.bilibili.community.service.dm.v1.PostStatus[message.postStatus] === undefined ? message.postStatus : $root.bilibili.community.service.dm.v1.PostStatus[message.postStatus] : message.postStatus;\n                            if (message.label != null && message.hasOwnProperty(\"label\"))\n                                object.label = $root.bilibili.community.service.dm.v1.Label.toObject(message.label, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this TextInput to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        TextInput.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for TextInput\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.TextInput\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        TextInput.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.TextInput\";\n                        };\n\n                        return TextInput;\n                    })();\n\n                    v1.TextInputV2 = (function() {\n\n                        /**\n                         * Properties of a TextInputV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface ITextInputV2\n                         * @property {Array.<string>|null} [portraitPlaceholder] TextInputV2 portraitPlaceholder\n                         * @property {Array.<string>|null} [landscapePlaceholder] TextInputV2 landscapePlaceholder\n                         * @property {bilibili.community.service.dm.v1.RenderType|null} [renderType] TextInputV2 renderType\n                         * @property {boolean|null} [placeholderPost] TextInputV2 placeholderPost\n                         * @property {Array.<bilibili.community.service.dm.v1.IAvatar>|null} [avatar] TextInputV2 avatar\n                         * @property {number|null} [textInputLimit] TextInputV2 textInputLimit\n                         */\n\n                        /**\n                         * Constructs a new TextInputV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a TextInputV2.\n                         * @implements ITextInputV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.ITextInputV2=} [properties] Properties to set\n                         */\n                        function TextInputV2(properties) {\n                            this.portraitPlaceholder = [];\n                            this.landscapePlaceholder = [];\n                            this.avatar = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * TextInputV2 portraitPlaceholder.\n                         * @member {Array.<string>} portraitPlaceholder\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         */\n                        TextInputV2.prototype.portraitPlaceholder = $util.emptyArray;\n\n                        /**\n                         * TextInputV2 landscapePlaceholder.\n                         * @member {Array.<string>} landscapePlaceholder\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         */\n                        TextInputV2.prototype.landscapePlaceholder = $util.emptyArray;\n\n                        /**\n                         * TextInputV2 renderType.\n                         * @member {bilibili.community.service.dm.v1.RenderType} renderType\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         */\n                        TextInputV2.prototype.renderType = 0;\n\n                        /**\n                         * TextInputV2 placeholderPost.\n                         * @member {boolean} placeholderPost\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         */\n                        TextInputV2.prototype.placeholderPost = false;\n\n                        /**\n                         * TextInputV2 avatar.\n                         * @member {Array.<bilibili.community.service.dm.v1.IAvatar>} avatar\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         */\n                        TextInputV2.prototype.avatar = $util.emptyArray;\n\n                        /**\n                         * TextInputV2 textInputLimit.\n                         * @member {number} textInputLimit\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         */\n                        TextInputV2.prototype.textInputLimit = 0;\n\n                        /**\n                         * Creates a new TextInputV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ITextInputV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.TextInputV2} TextInputV2 instance\n                         */\n                        TextInputV2.create = function create(properties) {\n                            return new TextInputV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified TextInputV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.TextInputV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ITextInputV2} message TextInputV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        TextInputV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.portraitPlaceholder != null && message.portraitPlaceholder.length)\n                                for (var i = 0; i < message.portraitPlaceholder.length; ++i)\n                                    writer.uint32(/* id 1, wireType 2 =*/10).string(message.portraitPlaceholder[i]);\n                            if (message.landscapePlaceholder != null && message.landscapePlaceholder.length)\n                                for (var i = 0; i < message.landscapePlaceholder.length; ++i)\n                                    writer.uint32(/* id 2, wireType 2 =*/18).string(message.landscapePlaceholder[i]);\n                            if (message.renderType != null && Object.hasOwnProperty.call(message, \"renderType\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.renderType);\n                            if (message.placeholderPost != null && Object.hasOwnProperty.call(message, \"placeholderPost\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).bool(message.placeholderPost);\n                            if (message.avatar != null && message.avatar.length)\n                                for (var i = 0; i < message.avatar.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.Avatar.encode(message.avatar[i], writer.uint32(/* id 5, wireType 2 =*/42).fork()).ldelim();\n                            if (message.textInputLimit != null && Object.hasOwnProperty.call(message, \"textInputLimit\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).int32(message.textInputLimit);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified TextInputV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.TextInputV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ITextInputV2} message TextInputV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        TextInputV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a TextInputV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.TextInputV2} TextInputV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        TextInputV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.TextInputV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        if (!(message.portraitPlaceholder && message.portraitPlaceholder.length))\n                                            message.portraitPlaceholder = [];\n                                        message.portraitPlaceholder.push(reader.string());\n                                        break;\n                                    }\n                                case 2: {\n                                        if (!(message.landscapePlaceholder && message.landscapePlaceholder.length))\n                                            message.landscapePlaceholder = [];\n                                        message.landscapePlaceholder.push(reader.string());\n                                        break;\n                                    }\n                                case 3: {\n                                        message.renderType = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.placeholderPost = reader.bool();\n                                        break;\n                                    }\n                                case 5: {\n                                        if (!(message.avatar && message.avatar.length))\n                                            message.avatar = [];\n                                        message.avatar.push($root.bilibili.community.service.dm.v1.Avatar.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                case 6: {\n                                        message.textInputLimit = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a TextInputV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.TextInputV2} TextInputV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        TextInputV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a TextInputV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        TextInputV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.portraitPlaceholder != null && message.hasOwnProperty(\"portraitPlaceholder\")) {\n                                if (!Array.isArray(message.portraitPlaceholder))\n                                    return \"portraitPlaceholder: array expected\";\n                                for (var i = 0; i < message.portraitPlaceholder.length; ++i)\n                                    if (!$util.isString(message.portraitPlaceholder[i]))\n                                        return \"portraitPlaceholder: string[] expected\";\n                            }\n                            if (message.landscapePlaceholder != null && message.hasOwnProperty(\"landscapePlaceholder\")) {\n                                if (!Array.isArray(message.landscapePlaceholder))\n                                    return \"landscapePlaceholder: array expected\";\n                                for (var i = 0; i < message.landscapePlaceholder.length; ++i)\n                                    if (!$util.isString(message.landscapePlaceholder[i]))\n                                        return \"landscapePlaceholder: string[] expected\";\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                switch (message.renderType) {\n                                default:\n                                    return \"renderType: enum value expected\";\n                                case 0:\n                                case 1:\n                                case 2:\n                                    break;\n                                }\n                            if (message.placeholderPost != null && message.hasOwnProperty(\"placeholderPost\"))\n                                if (typeof message.placeholderPost !== \"boolean\")\n                                    return \"placeholderPost: boolean expected\";\n                            if (message.avatar != null && message.hasOwnProperty(\"avatar\")) {\n                                if (!Array.isArray(message.avatar))\n                                    return \"avatar: array expected\";\n                                for (var i = 0; i < message.avatar.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.Avatar.verify(message.avatar[i]);\n                                    if (error)\n                                        return \"avatar.\" + error;\n                                }\n                            }\n                            if (message.textInputLimit != null && message.hasOwnProperty(\"textInputLimit\"))\n                                if (!$util.isInteger(message.textInputLimit))\n                                    return \"textInputLimit: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a TextInputV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.TextInputV2} TextInputV2\n                         */\n                        TextInputV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.TextInputV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.TextInputV2();\n                            if (object.portraitPlaceholder) {\n                                if (!Array.isArray(object.portraitPlaceholder))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInputV2.portraitPlaceholder: array expected\");\n                                message.portraitPlaceholder = [];\n                                for (var i = 0; i < object.portraitPlaceholder.length; ++i)\n                                    message.portraitPlaceholder[i] = String(object.portraitPlaceholder[i]);\n                            }\n                            if (object.landscapePlaceholder) {\n                                if (!Array.isArray(object.landscapePlaceholder))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInputV2.landscapePlaceholder: array expected\");\n                                message.landscapePlaceholder = [];\n                                for (var i = 0; i < object.landscapePlaceholder.length; ++i)\n                                    message.landscapePlaceholder[i] = String(object.landscapePlaceholder[i]);\n                            }\n                            switch (object.renderType) {\n                            default:\n                                if (typeof object.renderType === \"number\") {\n                                    message.renderType = object.renderType;\n                                    break;\n                                }\n                                break;\n                            case \"RenderTypeNone\":\n                            case 0:\n                                message.renderType = 0;\n                                break;\n                            case \"RenderTypeSingle\":\n                            case 1:\n                                message.renderType = 1;\n                                break;\n                            case \"RenderTypeRotation\":\n                            case 2:\n                                message.renderType = 2;\n                                break;\n                            }\n                            if (object.placeholderPost != null)\n                                message.placeholderPost = Boolean(object.placeholderPost);\n                            if (object.avatar) {\n                                if (!Array.isArray(object.avatar))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.TextInputV2.avatar: array expected\");\n                                message.avatar = [];\n                                for (var i = 0; i < object.avatar.length; ++i) {\n                                    if (typeof object.avatar[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.TextInputV2.avatar: object expected\");\n                                    message.avatar[i] = $root.bilibili.community.service.dm.v1.Avatar.fromObject(object.avatar[i]);\n                                }\n                            }\n                            if (object.textInputLimit != null)\n                                message.textInputLimit = object.textInputLimit | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a TextInputV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.TextInputV2} message TextInputV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        TextInputV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults) {\n                                object.portraitPlaceholder = [];\n                                object.landscapePlaceholder = [];\n                                object.avatar = [];\n                            }\n                            if (options.defaults) {\n                                object.renderType = options.enums === String ? \"RenderTypeNone\" : 0;\n                                object.placeholderPost = false;\n                                object.textInputLimit = 0;\n                            }\n                            if (message.portraitPlaceholder && message.portraitPlaceholder.length) {\n                                object.portraitPlaceholder = [];\n                                for (var j = 0; j < message.portraitPlaceholder.length; ++j)\n                                    object.portraitPlaceholder[j] = message.portraitPlaceholder[j];\n                            }\n                            if (message.landscapePlaceholder && message.landscapePlaceholder.length) {\n                                object.landscapePlaceholder = [];\n                                for (var j = 0; j < message.landscapePlaceholder.length; ++j)\n                                    object.landscapePlaceholder[j] = message.landscapePlaceholder[j];\n                            }\n                            if (message.renderType != null && message.hasOwnProperty(\"renderType\"))\n                                object.renderType = options.enums === String ? $root.bilibili.community.service.dm.v1.RenderType[message.renderType] === undefined ? message.renderType : $root.bilibili.community.service.dm.v1.RenderType[message.renderType] : message.renderType;\n                            if (message.placeholderPost != null && message.hasOwnProperty(\"placeholderPost\"))\n                                object.placeholderPost = message.placeholderPost;\n                            if (message.avatar && message.avatar.length) {\n                                object.avatar = [];\n                                for (var j = 0; j < message.avatar.length; ++j)\n                                    object.avatar[j] = $root.bilibili.community.service.dm.v1.Avatar.toObject(message.avatar[j], options);\n                            }\n                            if (message.textInputLimit != null && message.hasOwnProperty(\"textInputLimit\"))\n                                object.textInputLimit = message.textInputLimit;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this TextInputV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        TextInputV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for TextInputV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.TextInputV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        TextInputV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.TextInputV2\";\n                        };\n\n                        return TextInputV2;\n                    })();\n\n                    v1.Toast = (function() {\n\n                        /**\n                         * Properties of a Toast.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IToast\n                         * @property {string|null} [text] Toast text\n                         * @property {number|null} [duration] Toast duration\n                         * @property {boolean|null} [show] Toast show\n                         * @property {bilibili.community.service.dm.v1.IButton|null} [button] Toast button\n                         */\n\n                        /**\n                         * Constructs a new Toast.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a Toast.\n                         * @implements IToast\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IToast=} [properties] Properties to set\n                         */\n                        function Toast(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * Toast text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @instance\n                         */\n                        Toast.prototype.text = \"\";\n\n                        /**\n                         * Toast duration.\n                         * @member {number} duration\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @instance\n                         */\n                        Toast.prototype.duration = 0;\n\n                        /**\n                         * Toast show.\n                         * @member {boolean} show\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @instance\n                         */\n                        Toast.prototype.show = false;\n\n                        /**\n                         * Toast button.\n                         * @member {bilibili.community.service.dm.v1.IButton|null|undefined} button\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @instance\n                         */\n                        Toast.prototype.button = null;\n\n                        /**\n                         * Creates a new Toast instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToast=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.Toast} Toast instance\n                         */\n                        Toast.create = function create(properties) {\n                            return new Toast(properties);\n                        };\n\n                        /**\n                         * Encodes the specified Toast message. Does not implicitly {@link bilibili.community.service.dm.v1.Toast.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToast} message Toast message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Toast.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.duration != null && Object.hasOwnProperty.call(message, \"duration\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.duration);\n                            if (message.show != null && Object.hasOwnProperty.call(message, \"show\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).bool(message.show);\n                            if (message.button != null && Object.hasOwnProperty.call(message, \"button\"))\n                                $root.bilibili.community.service.dm.v1.Button.encode(message.button, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified Toast message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.Toast.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToast} message Toast message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        Toast.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a Toast message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.Toast} Toast\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Toast.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.Toast();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.duration = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.show = reader.bool();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.button = $root.bilibili.community.service.dm.v1.Button.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a Toast message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.Toast} Toast\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        Toast.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a Toast message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        Toast.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.duration != null && message.hasOwnProperty(\"duration\"))\n                                if (!$util.isInteger(message.duration))\n                                    return \"duration: integer expected\";\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                if (typeof message.show !== \"boolean\")\n                                    return \"show: boolean expected\";\n                            if (message.button != null && message.hasOwnProperty(\"button\")) {\n                                var error = $root.bilibili.community.service.dm.v1.Button.verify(message.button);\n                                if (error)\n                                    return \"button.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a Toast message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.Toast} Toast\n                         */\n                        Toast.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.Toast)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.Toast();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.duration != null)\n                                message.duration = object.duration | 0;\n                            if (object.show != null)\n                                message.show = Boolean(object.show);\n                            if (object.button != null) {\n                                if (typeof object.button !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.Toast.button: object expected\");\n                                message.button = $root.bilibili.community.service.dm.v1.Button.fromObject(object.button);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a Toast message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.Toast} message Toast\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        Toast.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.duration = 0;\n                                object.show = false;\n                                object.button = null;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.duration != null && message.hasOwnProperty(\"duration\"))\n                                object.duration = message.duration;\n                            if (message.show != null && message.hasOwnProperty(\"show\"))\n                                object.show = message.show;\n                            if (message.button != null && message.hasOwnProperty(\"button\"))\n                                object.button = $root.bilibili.community.service.dm.v1.Button.toObject(message.button, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this Toast to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        Toast.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for Toast\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.Toast\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        Toast.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.Toast\";\n                        };\n\n                        return Toast;\n                    })();\n\n                    v1.ToastButtonV2 = (function() {\n\n                        /**\n                         * Properties of a ToastButtonV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IToastButtonV2\n                         * @property {string|null} [text] ToastButtonV2 text\n                         * @property {number|null} [action] ToastButtonV2 action\n                         */\n\n                        /**\n                         * Constructs a new ToastButtonV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a ToastButtonV2.\n                         * @implements IToastButtonV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IToastButtonV2=} [properties] Properties to set\n                         */\n                        function ToastButtonV2(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * ToastButtonV2 text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @instance\n                         */\n                        ToastButtonV2.prototype.text = \"\";\n\n                        /**\n                         * ToastButtonV2 action.\n                         * @member {number} action\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @instance\n                         */\n                        ToastButtonV2.prototype.action = 0;\n\n                        /**\n                         * Creates a new ToastButtonV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToastButtonV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.ToastButtonV2} ToastButtonV2 instance\n                         */\n                        ToastButtonV2.create = function create(properties) {\n                            return new ToastButtonV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified ToastButtonV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.ToastButtonV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToastButtonV2} message ToastButtonV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ToastButtonV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.action != null && Object.hasOwnProperty.call(message, \"action\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.action);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified ToastButtonV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ToastButtonV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToastButtonV2} message ToastButtonV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ToastButtonV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a ToastButtonV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.ToastButtonV2} ToastButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ToastButtonV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.ToastButtonV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.action = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a ToastButtonV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.ToastButtonV2} ToastButtonV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ToastButtonV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a ToastButtonV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        ToastButtonV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.action != null && message.hasOwnProperty(\"action\"))\n                                if (!$util.isInteger(message.action))\n                                    return \"action: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a ToastButtonV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.ToastButtonV2} ToastButtonV2\n                         */\n                        ToastButtonV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.ToastButtonV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.ToastButtonV2();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.action != null)\n                                message.action = object.action | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a ToastButtonV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ToastButtonV2} message ToastButtonV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        ToastButtonV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.action = 0;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.action != null && message.hasOwnProperty(\"action\"))\n                                object.action = message.action;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this ToastButtonV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        ToastButtonV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for ToastButtonV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.ToastButtonV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        ToastButtonV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.ToastButtonV2\";\n                        };\n\n                        return ToastButtonV2;\n                    })();\n\n                    /**\n                     * ToastFunctionType enum.\n                     * @name bilibili.community.service.dm.v1.ToastFunctionType\n                     * @enum {number}\n                     * @property {number} ToastFunctionTypeNone=0 ToastFunctionTypeNone value\n                     * @property {number} ToastFunctionTypePostPanel=1 ToastFunctionTypePostPanel value\n                     */\n                    v1.ToastFunctionType = (function() {\n                        var valuesById = {}, values = Object.create(valuesById);\n                        values[valuesById[0] = \"ToastFunctionTypeNone\"] = 0;\n                        values[valuesById[1] = \"ToastFunctionTypePostPanel\"] = 1;\n                        return values;\n                    })();\n\n                    v1.ToastV2 = (function() {\n\n                        /**\n                         * Properties of a ToastV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IToastV2\n                         * @property {string|null} [text] ToastV2 text\n                         * @property {number|null} [duration] ToastV2 duration\n                         * @property {bilibili.community.service.dm.v1.IToastButtonV2|null} [toastButtonV2] ToastV2 toastButtonV2\n                         */\n\n                        /**\n                         * Constructs a new ToastV2.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a ToastV2.\n                         * @implements IToastV2\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IToastV2=} [properties] Properties to set\n                         */\n                        function ToastV2(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * ToastV2 text.\n                         * @member {string} text\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @instance\n                         */\n                        ToastV2.prototype.text = \"\";\n\n                        /**\n                         * ToastV2 duration.\n                         * @member {number} duration\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @instance\n                         */\n                        ToastV2.prototype.duration = 0;\n\n                        /**\n                         * ToastV2 toastButtonV2.\n                         * @member {bilibili.community.service.dm.v1.IToastButtonV2|null|undefined} toastButtonV2\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @instance\n                         */\n                        ToastV2.prototype.toastButtonV2 = null;\n\n                        /**\n                         * Creates a new ToastV2 instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToastV2=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.ToastV2} ToastV2 instance\n                         */\n                        ToastV2.create = function create(properties) {\n                            return new ToastV2(properties);\n                        };\n\n                        /**\n                         * Encodes the specified ToastV2 message. Does not implicitly {@link bilibili.community.service.dm.v1.ToastV2.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToastV2} message ToastV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ToastV2.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.text != null && Object.hasOwnProperty.call(message, \"text\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);\n                            if (message.duration != null && Object.hasOwnProperty.call(message, \"duration\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.duration);\n                            if (message.toastButtonV2 != null && Object.hasOwnProperty.call(message, \"toastButtonV2\"))\n                                $root.bilibili.community.service.dm.v1.ToastButtonV2.encode(message.toastButtonV2, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified ToastV2 message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.ToastV2.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IToastV2} message ToastV2 message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        ToastV2.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a ToastV2 message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.ToastV2} ToastV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ToastV2.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.ToastV2();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.text = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.duration = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.toastButtonV2 = $root.bilibili.community.service.dm.v1.ToastButtonV2.decode(reader, reader.uint32());\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a ToastV2 message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.ToastV2} ToastV2\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        ToastV2.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a ToastV2 message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        ToastV2.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                if (!$util.isString(message.text))\n                                    return \"text: string expected\";\n                            if (message.duration != null && message.hasOwnProperty(\"duration\"))\n                                if (!$util.isInteger(message.duration))\n                                    return \"duration: integer expected\";\n                            if (message.toastButtonV2 != null && message.hasOwnProperty(\"toastButtonV2\")) {\n                                var error = $root.bilibili.community.service.dm.v1.ToastButtonV2.verify(message.toastButtonV2);\n                                if (error)\n                                    return \"toastButtonV2.\" + error;\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a ToastV2 message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.ToastV2} ToastV2\n                         */\n                        ToastV2.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.ToastV2)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.ToastV2();\n                            if (object.text != null)\n                                message.text = String(object.text);\n                            if (object.duration != null)\n                                message.duration = object.duration | 0;\n                            if (object.toastButtonV2 != null) {\n                                if (typeof object.toastButtonV2 !== \"object\")\n                                    throw TypeError(\".bilibili.community.service.dm.v1.ToastV2.toastButtonV2: object expected\");\n                                message.toastButtonV2 = $root.bilibili.community.service.dm.v1.ToastButtonV2.fromObject(object.toastButtonV2);\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a ToastV2 message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.ToastV2} message ToastV2\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        ToastV2.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                object.text = \"\";\n                                object.duration = 0;\n                                object.toastButtonV2 = null;\n                            }\n                            if (message.text != null && message.hasOwnProperty(\"text\"))\n                                object.text = message.text;\n                            if (message.duration != null && message.hasOwnProperty(\"duration\"))\n                                object.duration = message.duration;\n                            if (message.toastButtonV2 != null && message.hasOwnProperty(\"toastButtonV2\"))\n                                object.toastButtonV2 = $root.bilibili.community.service.dm.v1.ToastButtonV2.toObject(message.toastButtonV2, options);\n                            return object;\n                        };\n\n                        /**\n                         * Converts this ToastV2 to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        ToastV2.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for ToastV2\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.ToastV2\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        ToastV2.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.ToastV2\";\n                        };\n\n                        return ToastV2;\n                    })();\n\n                    v1.UserInfo = (function() {\n\n                        /**\n                         * Properties of a UserInfo.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IUserInfo\n                         * @property {number|Long|null} [mid] UserInfo mid\n                         * @property {string|null} [name] UserInfo name\n                         * @property {string|null} [sex] UserInfo sex\n                         * @property {string|null} [face] UserInfo face\n                         * @property {string|null} [sign] UserInfo sign\n                         * @property {number|null} [rank] UserInfo rank\n                         */\n\n                        /**\n                         * Constructs a new UserInfo.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a UserInfo.\n                         * @implements IUserInfo\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IUserInfo=} [properties] Properties to set\n                         */\n                        function UserInfo(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * UserInfo mid.\n                         * @member {number|Long} mid\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         */\n                        UserInfo.prototype.mid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * UserInfo name.\n                         * @member {string} name\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         */\n                        UserInfo.prototype.name = \"\";\n\n                        /**\n                         * UserInfo sex.\n                         * @member {string} sex\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         */\n                        UserInfo.prototype.sex = \"\";\n\n                        /**\n                         * UserInfo face.\n                         * @member {string} face\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         */\n                        UserInfo.prototype.face = \"\";\n\n                        /**\n                         * UserInfo sign.\n                         * @member {string} sign\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         */\n                        UserInfo.prototype.sign = \"\";\n\n                        /**\n                         * UserInfo rank.\n                         * @member {number} rank\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         */\n                        UserInfo.prototype.rank = 0;\n\n                        /**\n                         * Creates a new UserInfo instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IUserInfo=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.UserInfo} UserInfo instance\n                         */\n                        UserInfo.create = function create(properties) {\n                            return new UserInfo(properties);\n                        };\n\n                        /**\n                         * Encodes the specified UserInfo message. Does not implicitly {@link bilibili.community.service.dm.v1.UserInfo.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IUserInfo} message UserInfo message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        UserInfo.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.mid != null && Object.hasOwnProperty.call(message, \"mid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.mid);\n                            if (message.name != null && Object.hasOwnProperty.call(message, \"name\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.name);\n                            if (message.sex != null && Object.hasOwnProperty.call(message, \"sex\"))\n                                writer.uint32(/* id 3, wireType 2 =*/26).string(message.sex);\n                            if (message.face != null && Object.hasOwnProperty.call(message, \"face\"))\n                                writer.uint32(/* id 4, wireType 2 =*/34).string(message.face);\n                            if (message.sign != null && Object.hasOwnProperty.call(message, \"sign\"))\n                                writer.uint32(/* id 5, wireType 2 =*/42).string(message.sign);\n                            if (message.rank != null && Object.hasOwnProperty.call(message, \"rank\"))\n                                writer.uint32(/* id 6, wireType 0 =*/48).int32(message.rank);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified UserInfo message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.UserInfo.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IUserInfo} message UserInfo message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        UserInfo.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a UserInfo message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.UserInfo} UserInfo\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        UserInfo.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.UserInfo();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.mid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.name = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.sex = reader.string();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.face = reader.string();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.sign = reader.string();\n                                        break;\n                                    }\n                                case 6: {\n                                        message.rank = reader.int32();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a UserInfo message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.UserInfo} UserInfo\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        UserInfo.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a UserInfo message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        UserInfo.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.mid != null && message.hasOwnProperty(\"mid\"))\n                                if (!$util.isInteger(message.mid) && !(message.mid && $util.isInteger(message.mid.low) && $util.isInteger(message.mid.high)))\n                                    return \"mid: integer|Long expected\";\n                            if (message.name != null && message.hasOwnProperty(\"name\"))\n                                if (!$util.isString(message.name))\n                                    return \"name: string expected\";\n                            if (message.sex != null && message.hasOwnProperty(\"sex\"))\n                                if (!$util.isString(message.sex))\n                                    return \"sex: string expected\";\n                            if (message.face != null && message.hasOwnProperty(\"face\"))\n                                if (!$util.isString(message.face))\n                                    return \"face: string expected\";\n                            if (message.sign != null && message.hasOwnProperty(\"sign\"))\n                                if (!$util.isString(message.sign))\n                                    return \"sign: string expected\";\n                            if (message.rank != null && message.hasOwnProperty(\"rank\"))\n                                if (!$util.isInteger(message.rank))\n                                    return \"rank: integer expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a UserInfo message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.UserInfo} UserInfo\n                         */\n                        UserInfo.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.UserInfo)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.UserInfo();\n                            if (object.mid != null)\n                                if ($util.Long)\n                                    (message.mid = $util.Long.fromValue(object.mid)).unsigned = false;\n                                else if (typeof object.mid === \"string\")\n                                    message.mid = parseInt(object.mid, 10);\n                                else if (typeof object.mid === \"number\")\n                                    message.mid = object.mid;\n                                else if (typeof object.mid === \"object\")\n                                    message.mid = new $util.LongBits(object.mid.low >>> 0, object.mid.high >>> 0).toNumber();\n                            if (object.name != null)\n                                message.name = String(object.name);\n                            if (object.sex != null)\n                                message.sex = String(object.sex);\n                            if (object.face != null)\n                                message.face = String(object.face);\n                            if (object.sign != null)\n                                message.sign = String(object.sign);\n                            if (object.rank != null)\n                                message.rank = object.rank | 0;\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a UserInfo message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.UserInfo} message UserInfo\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        UserInfo.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.mid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.mid = options.longs === String ? \"0\" : 0;\n                                object.name = \"\";\n                                object.sex = \"\";\n                                object.face = \"\";\n                                object.sign = \"\";\n                                object.rank = 0;\n                            }\n                            if (message.mid != null && message.hasOwnProperty(\"mid\"))\n                                if (typeof message.mid === \"number\")\n                                    object.mid = options.longs === String ? String(message.mid) : message.mid;\n                                else\n                                    object.mid = options.longs === String ? $util.Long.prototype.toString.call(message.mid) : options.longs === Number ? new $util.LongBits(message.mid.low >>> 0, message.mid.high >>> 0).toNumber() : message.mid;\n                            if (message.name != null && message.hasOwnProperty(\"name\"))\n                                object.name = message.name;\n                            if (message.sex != null && message.hasOwnProperty(\"sex\"))\n                                object.sex = message.sex;\n                            if (message.face != null && message.hasOwnProperty(\"face\"))\n                                object.face = message.face;\n                            if (message.sign != null && message.hasOwnProperty(\"sign\"))\n                                object.sign = message.sign;\n                            if (message.rank != null && message.hasOwnProperty(\"rank\"))\n                                object.rank = message.rank;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this UserInfo to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        UserInfo.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for UserInfo\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.UserInfo\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        UserInfo.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.UserInfo\";\n                        };\n\n                        return UserInfo;\n                    })();\n\n                    v1.VideoMask = (function() {\n\n                        /**\n                         * Properties of a VideoMask.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IVideoMask\n                         * @property {number|Long|null} [cid] VideoMask cid\n                         * @property {number|null} [plat] VideoMask plat\n                         * @property {number|null} [fps] VideoMask fps\n                         * @property {number|Long|null} [time] VideoMask time\n                         * @property {string|null} [maskUrl] VideoMask maskUrl\n                         */\n\n                        /**\n                         * Constructs a new VideoMask.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a VideoMask.\n                         * @implements IVideoMask\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IVideoMask=} [properties] Properties to set\n                         */\n                        function VideoMask(properties) {\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * VideoMask cid.\n                         * @member {number|Long} cid\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @instance\n                         */\n                        VideoMask.prototype.cid = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * VideoMask plat.\n                         * @member {number} plat\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @instance\n                         */\n                        VideoMask.prototype.plat = 0;\n\n                        /**\n                         * VideoMask fps.\n                         * @member {number} fps\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @instance\n                         */\n                        VideoMask.prototype.fps = 0;\n\n                        /**\n                         * VideoMask time.\n                         * @member {number|Long} time\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @instance\n                         */\n                        VideoMask.prototype.time = $util.Long ? $util.Long.fromBits(0,0,false) : 0;\n\n                        /**\n                         * VideoMask maskUrl.\n                         * @member {string} maskUrl\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @instance\n                         */\n                        VideoMask.prototype.maskUrl = \"\";\n\n                        /**\n                         * Creates a new VideoMask instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IVideoMask=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.VideoMask} VideoMask instance\n                         */\n                        VideoMask.create = function create(properties) {\n                            return new VideoMask(properties);\n                        };\n\n                        /**\n                         * Encodes the specified VideoMask message. Does not implicitly {@link bilibili.community.service.dm.v1.VideoMask.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IVideoMask} message VideoMask message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        VideoMask.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.cid != null && Object.hasOwnProperty.call(message, \"cid\"))\n                                writer.uint32(/* id 1, wireType 0 =*/8).int64(message.cid);\n                            if (message.plat != null && Object.hasOwnProperty.call(message, \"plat\"))\n                                writer.uint32(/* id 2, wireType 0 =*/16).int32(message.plat);\n                            if (message.fps != null && Object.hasOwnProperty.call(message, \"fps\"))\n                                writer.uint32(/* id 3, wireType 0 =*/24).int32(message.fps);\n                            if (message.time != null && Object.hasOwnProperty.call(message, \"time\"))\n                                writer.uint32(/* id 4, wireType 0 =*/32).int64(message.time);\n                            if (message.maskUrl != null && Object.hasOwnProperty.call(message, \"maskUrl\"))\n                                writer.uint32(/* id 5, wireType 2 =*/42).string(message.maskUrl);\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified VideoMask message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.VideoMask.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IVideoMask} message VideoMask message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        VideoMask.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a VideoMask message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.VideoMask} VideoMask\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        VideoMask.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.VideoMask();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.cid = reader.int64();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.plat = reader.int32();\n                                        break;\n                                    }\n                                case 3: {\n                                        message.fps = reader.int32();\n                                        break;\n                                    }\n                                case 4: {\n                                        message.time = reader.int64();\n                                        break;\n                                    }\n                                case 5: {\n                                        message.maskUrl = reader.string();\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a VideoMask message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.VideoMask} VideoMask\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        VideoMask.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a VideoMask message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        VideoMask.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.cid != null && message.hasOwnProperty(\"cid\"))\n                                if (!$util.isInteger(message.cid) && !(message.cid && $util.isInteger(message.cid.low) && $util.isInteger(message.cid.high)))\n                                    return \"cid: integer|Long expected\";\n                            if (message.plat != null && message.hasOwnProperty(\"plat\"))\n                                if (!$util.isInteger(message.plat))\n                                    return \"plat: integer expected\";\n                            if (message.fps != null && message.hasOwnProperty(\"fps\"))\n                                if (!$util.isInteger(message.fps))\n                                    return \"fps: integer expected\";\n                            if (message.time != null && message.hasOwnProperty(\"time\"))\n                                if (!$util.isInteger(message.time) && !(message.time && $util.isInteger(message.time.low) && $util.isInteger(message.time.high)))\n                                    return \"time: integer|Long expected\";\n                            if (message.maskUrl != null && message.hasOwnProperty(\"maskUrl\"))\n                                if (!$util.isString(message.maskUrl))\n                                    return \"maskUrl: string expected\";\n                            return null;\n                        };\n\n                        /**\n                         * Creates a VideoMask message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.VideoMask} VideoMask\n                         */\n                        VideoMask.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.VideoMask)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.VideoMask();\n                            if (object.cid != null)\n                                if ($util.Long)\n                                    (message.cid = $util.Long.fromValue(object.cid)).unsigned = false;\n                                else if (typeof object.cid === \"string\")\n                                    message.cid = parseInt(object.cid, 10);\n                                else if (typeof object.cid === \"number\")\n                                    message.cid = object.cid;\n                                else if (typeof object.cid === \"object\")\n                                    message.cid = new $util.LongBits(object.cid.low >>> 0, object.cid.high >>> 0).toNumber();\n                            if (object.plat != null)\n                                message.plat = object.plat | 0;\n                            if (object.fps != null)\n                                message.fps = object.fps | 0;\n                            if (object.time != null)\n                                if ($util.Long)\n                                    (message.time = $util.Long.fromValue(object.time)).unsigned = false;\n                                else if (typeof object.time === \"string\")\n                                    message.time = parseInt(object.time, 10);\n                                else if (typeof object.time === \"number\")\n                                    message.time = object.time;\n                                else if (typeof object.time === \"object\")\n                                    message.time = new $util.LongBits(object.time.low >>> 0, object.time.high >>> 0).toNumber();\n                            if (object.maskUrl != null)\n                                message.maskUrl = String(object.maskUrl);\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a VideoMask message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.VideoMask} message VideoMask\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        VideoMask.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.defaults) {\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.cid = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.cid = options.longs === String ? \"0\" : 0;\n                                object.plat = 0;\n                                object.fps = 0;\n                                if ($util.Long) {\n                                    var long = new $util.Long(0, 0, false);\n                                    object.time = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long;\n                                } else\n                                    object.time = options.longs === String ? \"0\" : 0;\n                                object.maskUrl = \"\";\n                            }\n                            if (message.cid != null && message.hasOwnProperty(\"cid\"))\n                                if (typeof message.cid === \"number\")\n                                    object.cid = options.longs === String ? String(message.cid) : message.cid;\n                                else\n                                    object.cid = options.longs === String ? $util.Long.prototype.toString.call(message.cid) : options.longs === Number ? new $util.LongBits(message.cid.low >>> 0, message.cid.high >>> 0).toNumber() : message.cid;\n                            if (message.plat != null && message.hasOwnProperty(\"plat\"))\n                                object.plat = message.plat;\n                            if (message.fps != null && message.hasOwnProperty(\"fps\"))\n                                object.fps = message.fps;\n                            if (message.time != null && message.hasOwnProperty(\"time\"))\n                                if (typeof message.time === \"number\")\n                                    object.time = options.longs === String ? String(message.time) : message.time;\n                                else\n                                    object.time = options.longs === String ? $util.Long.prototype.toString.call(message.time) : options.longs === Number ? new $util.LongBits(message.time.low >>> 0, message.time.high >>> 0).toNumber() : message.time;\n                            if (message.maskUrl != null && message.hasOwnProperty(\"maskUrl\"))\n                                object.maskUrl = message.maskUrl;\n                            return object;\n                        };\n\n                        /**\n                         * Converts this VideoMask to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        VideoMask.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for VideoMask\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.VideoMask\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        VideoMask.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.VideoMask\";\n                        };\n\n                        return VideoMask;\n                    })();\n\n                    v1.VideoSubtitle = (function() {\n\n                        /**\n                         * Properties of a VideoSubtitle.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @interface IVideoSubtitle\n                         * @property {string|null} [lan] VideoSubtitle lan\n                         * @property {string|null} [lanDoc] VideoSubtitle lanDoc\n                         * @property {Array.<bilibili.community.service.dm.v1.ISubtitleItem>|null} [subtitles] VideoSubtitle subtitles\n                         */\n\n                        /**\n                         * Constructs a new VideoSubtitle.\n                         * @memberof bilibili.community.service.dm.v1\n                         * @classdesc Represents a VideoSubtitle.\n                         * @implements IVideoSubtitle\n                         * @constructor\n                         * @param {bilibili.community.service.dm.v1.IVideoSubtitle=} [properties] Properties to set\n                         */\n                        function VideoSubtitle(properties) {\n                            this.subtitles = [];\n                            if (properties)\n                                for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)\n                                    if (properties[keys[i]] != null)\n                                        this[keys[i]] = properties[keys[i]];\n                        }\n\n                        /**\n                         * VideoSubtitle lan.\n                         * @member {string} lan\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @instance\n                         */\n                        VideoSubtitle.prototype.lan = \"\";\n\n                        /**\n                         * VideoSubtitle lanDoc.\n                         * @member {string} lanDoc\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @instance\n                         */\n                        VideoSubtitle.prototype.lanDoc = \"\";\n\n                        /**\n                         * VideoSubtitle subtitles.\n                         * @member {Array.<bilibili.community.service.dm.v1.ISubtitleItem>} subtitles\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @instance\n                         */\n                        VideoSubtitle.prototype.subtitles = $util.emptyArray;\n\n                        /**\n                         * Creates a new VideoSubtitle instance using the specified properties.\n                         * @function create\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IVideoSubtitle=} [properties] Properties to set\n                         * @returns {bilibili.community.service.dm.v1.VideoSubtitle} VideoSubtitle instance\n                         */\n                        VideoSubtitle.create = function create(properties) {\n                            return new VideoSubtitle(properties);\n                        };\n\n                        /**\n                         * Encodes the specified VideoSubtitle message. Does not implicitly {@link bilibili.community.service.dm.v1.VideoSubtitle.verify|verify} messages.\n                         * @function encode\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IVideoSubtitle} message VideoSubtitle message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        VideoSubtitle.encode = function encode(message, writer) {\n                            if (!writer)\n                                writer = $Writer.create();\n                            if (message.lan != null && Object.hasOwnProperty.call(message, \"lan\"))\n                                writer.uint32(/* id 1, wireType 2 =*/10).string(message.lan);\n                            if (message.lanDoc != null && Object.hasOwnProperty.call(message, \"lanDoc\"))\n                                writer.uint32(/* id 2, wireType 2 =*/18).string(message.lanDoc);\n                            if (message.subtitles != null && message.subtitles.length)\n                                for (var i = 0; i < message.subtitles.length; ++i)\n                                    $root.bilibili.community.service.dm.v1.SubtitleItem.encode(message.subtitles[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();\n                            return writer;\n                        };\n\n                        /**\n                         * Encodes the specified VideoSubtitle message, length delimited. Does not implicitly {@link bilibili.community.service.dm.v1.VideoSubtitle.verify|verify} messages.\n                         * @function encodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.IVideoSubtitle} message VideoSubtitle message or plain object to encode\n                         * @param {$protobuf.Writer} [writer] Writer to encode to\n                         * @returns {$protobuf.Writer} Writer\n                         */\n                        VideoSubtitle.encodeDelimited = function encodeDelimited(message, writer) {\n                            return this.encode(message, writer).ldelim();\n                        };\n\n                        /**\n                         * Decodes a VideoSubtitle message from the specified reader or buffer.\n                         * @function decode\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @param {number} [length] Message length if known beforehand\n                         * @returns {bilibili.community.service.dm.v1.VideoSubtitle} VideoSubtitle\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        VideoSubtitle.decode = function decode(reader, length, error) {\n                            if (!(reader instanceof $Reader))\n                                reader = $Reader.create(reader);\n                            var end = length === undefined ? reader.len : reader.pos + length, message = new $root.bilibili.community.service.dm.v1.VideoSubtitle();\n                            while (reader.pos < end) {\n                                var tag = reader.uint32();\n                                if (tag === error)\n                                    break;\n                                switch (tag >>> 3) {\n                                case 1: {\n                                        message.lan = reader.string();\n                                        break;\n                                    }\n                                case 2: {\n                                        message.lanDoc = reader.string();\n                                        break;\n                                    }\n                                case 3: {\n                                        if (!(message.subtitles && message.subtitles.length))\n                                            message.subtitles = [];\n                                        message.subtitles.push($root.bilibili.community.service.dm.v1.SubtitleItem.decode(reader, reader.uint32()));\n                                        break;\n                                    }\n                                default:\n                                    reader.skipType(tag & 7);\n                                    break;\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Decodes a VideoSubtitle message from the specified reader or buffer, length delimited.\n                         * @function decodeDelimited\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from\n                         * @returns {bilibili.community.service.dm.v1.VideoSubtitle} VideoSubtitle\n                         * @throws {Error} If the payload is not a reader or valid buffer\n                         * @throws {$protobuf.util.ProtocolError} If required fields are missing\n                         */\n                        VideoSubtitle.decodeDelimited = function decodeDelimited(reader) {\n                            if (!(reader instanceof $Reader))\n                                reader = new $Reader(reader);\n                            return this.decode(reader, reader.uint32());\n                        };\n\n                        /**\n                         * Verifies a VideoSubtitle message.\n                         * @function verify\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {Object.<string,*>} message Plain object to verify\n                         * @returns {string|null} `null` if valid, otherwise the reason why it is not\n                         */\n                        VideoSubtitle.verify = function verify(message) {\n                            if (typeof message !== \"object\" || message === null)\n                                return \"object expected\";\n                            if (message.lan != null && message.hasOwnProperty(\"lan\"))\n                                if (!$util.isString(message.lan))\n                                    return \"lan: string expected\";\n                            if (message.lanDoc != null && message.hasOwnProperty(\"lanDoc\"))\n                                if (!$util.isString(message.lanDoc))\n                                    return \"lanDoc: string expected\";\n                            if (message.subtitles != null && message.hasOwnProperty(\"subtitles\")) {\n                                if (!Array.isArray(message.subtitles))\n                                    return \"subtitles: array expected\";\n                                for (var i = 0; i < message.subtitles.length; ++i) {\n                                    var error = $root.bilibili.community.service.dm.v1.SubtitleItem.verify(message.subtitles[i]);\n                                    if (error)\n                                        return \"subtitles.\" + error;\n                                }\n                            }\n                            return null;\n                        };\n\n                        /**\n                         * Creates a VideoSubtitle message from a plain object. Also converts values to their respective internal types.\n                         * @function fromObject\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {Object.<string,*>} object Plain object\n                         * @returns {bilibili.community.service.dm.v1.VideoSubtitle} VideoSubtitle\n                         */\n                        VideoSubtitle.fromObject = function fromObject(object) {\n                            if (object instanceof $root.bilibili.community.service.dm.v1.VideoSubtitle)\n                                return object;\n                            var message = new $root.bilibili.community.service.dm.v1.VideoSubtitle();\n                            if (object.lan != null)\n                                message.lan = String(object.lan);\n                            if (object.lanDoc != null)\n                                message.lanDoc = String(object.lanDoc);\n                            if (object.subtitles) {\n                                if (!Array.isArray(object.subtitles))\n                                    throw TypeError(\".bilibili.community.service.dm.v1.VideoSubtitle.subtitles: array expected\");\n                                message.subtitles = [];\n                                for (var i = 0; i < object.subtitles.length; ++i) {\n                                    if (typeof object.subtitles[i] !== \"object\")\n                                        throw TypeError(\".bilibili.community.service.dm.v1.VideoSubtitle.subtitles: object expected\");\n                                    message.subtitles[i] = $root.bilibili.community.service.dm.v1.SubtitleItem.fromObject(object.subtitles[i]);\n                                }\n                            }\n                            return message;\n                        };\n\n                        /**\n                         * Creates a plain object from a VideoSubtitle message. Also converts values to other types if specified.\n                         * @function toObject\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {bilibili.community.service.dm.v1.VideoSubtitle} message VideoSubtitle\n                         * @param {$protobuf.IConversionOptions} [options] Conversion options\n                         * @returns {Object.<string,*>} Plain object\n                         */\n                        VideoSubtitle.toObject = function toObject(message, options) {\n                            if (!options)\n                                options = {};\n                            var object = {};\n                            if (options.arrays || options.defaults)\n                                object.subtitles = [];\n                            if (options.defaults) {\n                                object.lan = \"\";\n                                object.lanDoc = \"\";\n                            }\n                            if (message.lan != null && message.hasOwnProperty(\"lan\"))\n                                object.lan = message.lan;\n                            if (message.lanDoc != null && message.hasOwnProperty(\"lanDoc\"))\n                                object.lanDoc = message.lanDoc;\n                            if (message.subtitles && message.subtitles.length) {\n                                object.subtitles = [];\n                                for (var j = 0; j < message.subtitles.length; ++j)\n                                    object.subtitles[j] = $root.bilibili.community.service.dm.v1.SubtitleItem.toObject(message.subtitles[j], options);\n                            }\n                            return object;\n                        };\n\n                        /**\n                         * Converts this VideoSubtitle to JSON.\n                         * @function toJSON\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @instance\n                         * @returns {Object.<string,*>} JSON object\n                         */\n                        VideoSubtitle.prototype.toJSON = function toJSON() {\n                            return this.constructor.toObject(this, $protobuf.util.toJSONOptions);\n                        };\n\n                        /**\n                         * Gets the default type url for VideoSubtitle\n                         * @function getTypeUrl\n                         * @memberof bilibili.community.service.dm.v1.VideoSubtitle\n                         * @static\n                         * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default \"type.googleapis.com\")\n                         * @returns {string} The default type url\n                         */\n                        VideoSubtitle.getTypeUrl = function getTypeUrl(typeUrlPrefix) {\n                            if (typeUrlPrefix === undefined) {\n                                typeUrlPrefix = \"type.googleapis.com\";\n                            }\n                            return typeUrlPrefix + \"/bilibili.community.service.dm.v1.VideoSubtitle\";\n                        };\n\n                        return VideoSubtitle;\n                    })();\n\n                    return v1;\n                })();\n\n                return dm;\n            })();\n\n            return service;\n        })();\n\n        return community;\n    })();\n\n    return bilibili;\n})();\n\nmodule.exports = $root;\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/proto/dm.proto",
    "content": "syntax = \"proto3\";\n\npackage bilibili.community.service.dm.v1;\n\n//弹幕\nservice DM {\n    // 获取分段弹幕\n    rpc DmSegMobile (DmSegMobileReq) returns (DmSegMobileReply);\n    // 客户端弹幕元数据 字幕、分段、防挡蒙版等\n    rpc DmView(DmViewReq) returns (DmViewReply);\n    // 修改弹幕配置\n    rpc DmPlayerConfig (DmPlayerConfigReq) returns (Response);\n    // ott弹幕列表\n    rpc DmSegOtt(DmSegOttReq) returns(DmSegOttReply);\n    // SDK弹幕列表\n    rpc DmSegSDK(DmSegSDKReq) returns(DmSegSDKReply);\n    //\n    rpc DmExpoReport(DmExpoReportReq) returns (DmExpoReportRes);\n}\n\n//\nmessage Avatar {\n    //\n    string id = 1;\n    //\n    string url = 2;\n    //\n    AvatarType avatar_type = 3;\n}\n\n//\nenum AvatarType {\n    AvatarTypeNone = 0; //\n    AvatarTypeNFT  = 1; //\n}\n\n//\nmessage Bubble {\n    //\n    string text = 1;\n    //\n    string url = 2;\n}\n\n//\nenum BubbleType {\n    BubbleTypeNone           = 0; //\n    BubbleTypeClickButton    = 1; //\n    BubbleTypeDmSettingPanel = 2; //\n}\n\n//\nmessage BubbleV2 {\n    //\n    string text = 1;\n    //\n    string url = 2;\n    //\n    BubbleType bubble_type = 3;\n    //\n    bool exposure_once = 4;\n    //\n    ExposureType exposure_type = 5;\n}\n\n//\nmessage Button {\n    //\n    string text = 1;\n    //\n    int32 action = 2;\n}\n\n//\nmessage BuzzwordConfig {\n    //\n    repeated BuzzwordShowConfig keywords = 1;\n}\n\n//\nmessage BuzzwordShowConfig {\n    //\n    string name = 1;\n    //\n    string schema = 2;\n    //\n    int32 source = 3;\n    //\n    int64 id = 4;\n    //\n    int64 buzzword_id = 5;\n    //\n    int32 schema_type = 6;\n}\n\n//\nmessage CheckBox {\n    //\n    string text = 1;\n    //\n    CheckboxType type = 2;\n    //\n    bool default_value = 3;\n    //\n    bool show = 4;\n}\n\n//\nenum CheckboxType {\n    CheckboxTypeNone      = 0; //\n    CheckboxTypeEncourage = 1; //\n    CheckboxTypeColorDM   = 2; //\n}\n\n//\nmessage CheckBoxV2 {\n    //\n    string text = 1;\n    //\n    int32 type = 2;\n    //\n    bool default_value = 3;\n}\n\n//\nmessage ClickButton {\n    //\n    repeated string portrait_text = 1;\n    //\n    repeated string landscape_text = 2;\n    //\n    repeated string portrait_text_focus = 3;\n    //\n    repeated string landscape_text_focus = 4;\n    //\n    RenderType render_type = 5;\n    //\n    bool show = 6;\n    //\n    Bubble bubble = 7;\n}\n\n//\nmessage ClickButtonV2 {\n    //\n    repeated string portrait_text = 1;\n    //\n    repeated string landscape_text = 2;\n    //\n    repeated string portrait_text_focus = 3;\n    //\n    repeated string landscape_text_focus = 4;\n    //\n    int32 render_type = 5;\n    //\n    bool text_input_post = 6;\n    //\n    bool exposure_once = 7;\n    //\n    int32 exposure_type = 8;\n}\n\n// 互动弹幕条目信息\nmessage CommandDm {\n    // 弹幕id\n    int64 id = 1;\n    // 对象视频cid\n    int64 oid = 2;\n    // 发送者mid\n    string mid = 3;\n    // 互动弹幕指令\n    string command = 4;\n    // 互动弹幕正文\n    string content = 5;\n    // 出现时间\n    int32 progress = 6;\n    // 创建时间\n    string ctime = 7;\n    // 发布时间\n    string mtime = 8;\n    // 扩展json数据\n    string extra = 9;\n    // 弹幕id str类型\n    string idStr = 10;\n}\n\n// 弹幕ai云屏蔽列表\nmessage DanmakuAIFlag {\n    // 弹幕ai云屏蔽条目\n    repeated DanmakuFlag dm_flags = 1;\n}\n\n// 弹幕条目\nmessage DanmakuElem {\n    // 弹幕dmid\n    int64 id = 1;\n    // 弹幕出现位置(单位ms)\n    int32 progress = 2;\n    // 弹幕类型 1 2 3:普通弹幕 4:底部弹幕 5:顶部弹幕 6:逆向弹幕 7:高级弹幕 8:代码弹幕 9:BAS弹幕(pool必须为2)\n    int32 mode = 3;\n    // 弹幕字号\n    int32 fontsize = 4;\n    // 弹幕颜色\n    uint32 color = 5;\n    // 发送者mid hash\n    string midHash = 6;\n    // 弹幕正文\n    string content = 7;\n    // 发送时间\n    int64 ctime = 8;\n    // 权重 用于屏蔽等级 区间:[1,10]\n    int32 weight = 9;\n    // 动作\n    string action = 10;\n    // 弹幕池 0:普通池 1:字幕池 2:特殊池(代码/BAS弹幕)\n    int32 pool = 11;\n    // 弹幕dmid str\n    string idStr = 12;\n    // 弹幕属性位(bin求AND)\n    // bit0:保护 bit1:直播 bit2:高赞\n    int32 attr = 13;\n    //\n    string animation = 22;\n    // 大会员专属颜色\n    DmColorfulType colorful = 24;\n}\n\n// 弹幕ai云屏蔽条目\nmessage DanmakuFlag {\n    // 弹幕dmid\n    int64 dmid = 1;\n    // 评分\n    uint32 flag = 2;\n}\n\n// 云屏蔽配置信息\nmessage DanmakuFlagConfig {\n    // 云屏蔽等级\n    int32 rec_flag = 1;\n    // 云屏蔽文案\n    string rec_text = 2;\n    // 云屏蔽开关\n    int32 rec_switch = 3;\n}\n\n// 弹幕默认配置\nmessage DanmuDefaultPlayerConfig {\n    bool player_danmaku_use_default_config                       = 1;  // 是否使用推荐弹幕设置\n    bool player_danmaku_ai_recommended_switch                    = 4;  // 是否开启智能云屏蔽\n    int32 player_danmaku_ai_recommended_level                    = 5;  // 智能云屏蔽等级\n    bool player_danmaku_blocktop                                 = 6;  // 是否屏蔽顶端弹幕\n    bool player_danmaku_blockscroll                              = 7;  // 是否屏蔽滚动弹幕\n    bool player_danmaku_blockbottom                              = 8;  // 是否屏蔽底端弹幕\n    bool player_danmaku_blockcolorful                            = 9;  // 是否屏蔽彩色弹幕\n    bool player_danmaku_blockrepeat                              = 10; // 是否屏蔽重复弹幕\n    bool player_danmaku_blockspecial                             = 11; // 是否屏蔽高级弹幕\n    float player_danmaku_opacity                                 = 12; // 弹幕不透明度\n    float player_danmaku_scalingfactor                           = 13; // 弹幕缩放比例\n    float player_danmaku_domain                                  = 14; // 弹幕显示区域\n    int32 player_danmaku_speed                                   = 15; // 弹幕速度\n    bool inline_player_danmaku_switch                            = 16; // 是否开启弹幕\n    int32 player_danmaku_senior_mode_switch                      = 17; //\n    int32 player_danmaku_ai_recommended_level_v2                 = 18; //\n    map<int32, int32> player_danmaku_ai_recommended_level_v2_map = 19; //\n}\n\n// 弹幕配置\nmessage DanmuPlayerConfig {\n    bool player_danmaku_switch                                   = 1;  // 是否开启弹幕\n    bool player_danmaku_switch_save                              = 2;  // 是否记录弹幕开关设置\n    bool player_danmaku_use_default_config                       = 3;  // 是否使用推荐弹幕设置\n    bool player_danmaku_ai_recommended_switch                    = 4;  // 是否开启智能云屏蔽\n    int32 player_danmaku_ai_recommended_level                    = 5;  // 智能云屏蔽等级\n    bool player_danmaku_blocktop                                 = 6;  // 是否屏蔽顶端弹幕\n    bool player_danmaku_blockscroll                              = 7;  // 是否屏蔽滚动弹幕\n    bool player_danmaku_blockbottom                              = 8;  // 是否屏蔽底端弹幕\n    bool player_danmaku_blockcolorful                            = 9;  // 是否屏蔽彩色弹幕\n    bool player_danmaku_blockrepeat                              = 10; // 是否屏蔽重复弹幕\n    bool player_danmaku_blockspecial                             = 11; // 是否屏蔽高级弹幕\n    float player_danmaku_opacity                                 = 12; // 弹幕不透明度\n    float player_danmaku_scalingfactor                           = 13; // 弹幕缩放比例\n    float player_danmaku_domain                                  = 14; // 弹幕显示区域\n    int32 player_danmaku_speed                                   = 15; // 弹幕速度\n    bool player_danmaku_enableblocklist                          = 16; // 是否开启屏蔽列表\n    bool inline_player_danmaku_switch                            = 17; // 是否开启弹幕\n    int32 inline_player_danmaku_config                           = 18; //\n    int32 player_danmaku_ios_switch_save                         = 19; //\n    int32 player_danmaku_senior_mode_switch                      = 20; //\n    int32 player_danmaku_ai_recommended_level_v2                 = 21; //\n    map<int32, int32> player_danmaku_ai_recommended_level_v2_map = 22; //\n}\n\n//\nmessage DanmuPlayerConfigPanel {\n    //\n    string selection_text = 1;\n}\n\n// 弹幕显示区域自动配置\nmessage DanmuPlayerDynamicConfig {\n    // 时间\n    int32 progress = 1;\n    // 弹幕显示区域\n    float player_danmaku_domain = 14;\n}\n\n// 弹幕配置信息\nmessage DanmuPlayerViewConfig {\n    // 弹幕默认配置\n    DanmuDefaultPlayerConfig danmuku_default_player_config = 1;\n    // 弹幕用户配置\n    DanmuPlayerConfig danmuku_player_config = 2;\n    // 弹幕显示区域自动配置列表\n    repeated DanmuPlayerDynamicConfig danmuku_player_dynamic_config = 3;\n    //\n    DanmuPlayerConfigPanel danmuku_player_config_panel = 4;\n}\n\n// web端用户弹幕配置\nmessage DanmuWebPlayerConfig {\n    bool dm_switch                    = 1;  // 是否开启弹幕\n    bool ai_switch                    = 2;  // 是否开启智能云屏蔽\n    int32 ai_level                    = 3;  // 智能云屏蔽等级\n    bool blocktop                     = 4;  // 是否屏蔽顶端弹幕\n    bool blockscroll                  = 5;  // 是否屏蔽滚动弹幕\n    bool blockbottom                  = 6;  // 是否屏蔽底端弹幕\n    bool blockcolor                   = 7;  // 是否屏蔽彩色弹幕\n    bool blockspecial                 = 8;  // 是否屏蔽重复弹幕\n    bool preventshade                 = 9;  // \n    bool dmask                        = 10; // \n    float opacity                     = 11; // \n    int32 dmarea                      = 12; // \n    float speedplus                   = 13; // \n    float fontsize                    = 14; // 弹幕字号\n    bool screensync                   = 15; // \n    bool speedsync                    = 16; // \n    string fontfamily                 = 17; // \n    bool bold                         = 18; // 是否使用加粗\n    int32 fontborder                  = 19; // \n    string draw_type                  = 20; // 弹幕渲染类型\n    int32 senior_mode_switch          = 21; //\n    int32 ai_level_v2                 = 22; //\n    map<int32, int32> ai_level_v2_map = 23; //\n}\n\n// 弹幕属性位值\nenum DMAttrBit {\n    DMAttrBitProtect  = 0; // 保护弹幕\n    DMAttrBitFromLive = 1; // 直播弹幕\n    DMAttrHighLike    = 2; // 高赞弹幕\n}\n\nmessage DmColorful {\n    DmColorfulType type = 1; // 颜色类型\n    string src          = 2; //\n}\n\nenum DmColorfulType {\n    NoneType        = 0;     // 无\n    VipGradualColor = 60001; // 渐变色\n}\n\n//\nmessage DmExpoReportReq {\n    //\n    string session_id = 1;\n    //\n    int64 oid = 2;\n    //\n    string spmid = 4;\n}\n\n//\nmessage DmExpoReportRes {}\n\n// 修改弹幕配置-请求\nmessage DmPlayerConfigReq {\n    int64 ts                                                  = 1;  //\n    PlayerDanmakuSwitch switch                                = 2;  // 是否开启弹幕\n    PlayerDanmakuSwitchSave switch_save                       = 3;  // 是否记录弹幕开关设置\n    PlayerDanmakuUseDefaultConfig use_default_config          = 4;  // 是否使用推荐弹幕设置\n    PlayerDanmakuAiRecommendedSwitch ai_recommended_switch    = 5;  // 是否开启智能云屏蔽\n    PlayerDanmakuAiRecommendedLevel ai_recommended_level      = 6;  // 智能云屏蔽等级\n    PlayerDanmakuBlocktop blocktop                            = 7;  // 是否屏蔽顶端弹幕\n    PlayerDanmakuBlockscroll blockscroll                      = 8;  // 是否屏蔽滚动弹幕\n    PlayerDanmakuBlockbottom blockbottom                      = 9;  // 是否屏蔽底端弹幕\n    PlayerDanmakuBlockcolorful blockcolorful                  = 10; // 是否屏蔽彩色弹幕\n    PlayerDanmakuBlockrepeat blockrepeat                      = 11; // 是否屏蔽重复弹幕\n    PlayerDanmakuBlockspecial blockspecial                    = 12; // 是否屏蔽高级弹幕\n    PlayerDanmakuOpacity opacity                              = 13; // 弹幕不透明度\n    PlayerDanmakuScalingfactor scalingfactor                  = 14; // 弹幕缩放比例\n    PlayerDanmakuDomain domain                                = 15; // 弹幕显示区域\n    PlayerDanmakuSpeed speed                                  = 16; // 弹幕速度\n    PlayerDanmakuEnableblocklist enableblocklist              = 17; // 是否开启屏蔽列表\n    InlinePlayerDanmakuSwitch inlinePlayerDanmakuSwitch       = 18; // 是否开启弹幕\n    PlayerDanmakuSeniorModeSwitch senior_mode_switch          = 19; //\n    PlayerDanmakuAiRecommendedLevelV2 ai_recommended_level_v2 = 20; //\n}\n\n//\nmessage DmSegConfig {\n    //\n    int64 page_size = 1;\n    //\n    int64 total = 2;\n}\n\n// 获取弹幕-响应\nmessage DmSegMobileReply {\n    // 弹幕列表\n    repeated DanmakuElem elems = 1;\n    // 是否已关闭弹幕\n    // 0:未关闭 1:已关闭\n    int32 state = 2;\n    // 弹幕云屏蔽ai评分值\n    DanmakuAIFlag ai_flag = 3;\n    repeated DmColorful colorfulSrc = 5;\n}\n\n// 获取弹幕-请求\nmessage DmSegMobileReq {\n    // 稿件avid/漫画epid\n    int64 pid = 1;\n    // 视频cid/漫画cid\n    int64 oid = 2;\n    // 弹幕类型\n    // 1:视频 2:漫画\n    int32 type = 3;\n    // 分段(6min)\n    int64 segment_index = 4;\n    // 是否青少年模式\n    int32 teenagers_mode = 5;\n    //\n    int64 ps = 6;\n    //\n    int64 pe = 7;\n    //\n    int32 pull_mode = 8;\n    //\n    int32 from_scene = 9;\n}\n\n// ott弹幕列表-响应\nmessage DmSegOttReply {\n    // 是否已关闭弹幕\n    // 0:未关闭 1:已关闭\n    bool closed = 1;\n    // 弹幕列表\n    repeated DanmakuElem elems = 2;\n}\n\n// ott弹幕列表-请求\nmessage DmSegOttReq {\n    // 稿件avid/漫画epid\n    int64 pid = 1;\n    // 视频cid/漫画cid\n    int64 oid = 2;\n    // 弹幕类型\n    // 1:视频 2:漫画\n    int32 type = 3;\n    // 分段(6min)\n    int64 segment_index = 4;\n}\n\n// 弹幕SDK-响应\nmessage DmSegSDKReply {\n    // 是否已关闭弹幕\n    // 0:未关闭 1:已关闭\n    bool closed = 1;\n    // 弹幕列表\n    repeated DanmakuElem elems = 2;\n}\n\n// 弹幕SDK-请求\nmessage DmSegSDKReq {\n    // 稿件avid/漫画epid\n    int64 pid = 1;\n    // 视频cid/漫画cid\n    int64 oid = 2;\n    // 弹幕类型\n    // 1:视频 2:漫画\n    int32 type = 3;\n    // 分段(6min)\n    int64 segment_index = 4;\n}\n\n// 客户端弹幕元数据-响应\nmessage DmViewReply {\n    // 是否已关闭弹幕\n    // 0:未关闭 1:已关闭\n    bool closed = 1;\n    // 智能防挡弹幕蒙版信息\n    VideoMask mask = 2;\n    // 视频字幕\n    VideoSubtitle subtitle = 3;\n    // 高级弹幕专包url(bfs)\n    repeated string special_dms = 4;\n    // 云屏蔽配置信息\n    DanmakuFlagConfig ai_flag = 5;\n    // 弹幕配置信息\n    DanmuPlayerViewConfig player_config = 6;\n    // 弹幕发送框样式\n    int32 send_box_style = 7;\n    // 是否允许\n    bool allow = 8;\n    // check box 是否展示\n    string check_box = 9;\n    // check box 展示文本\n    string check_box_show_msg = 10;\n    // 展示文案\n    string text_placeholder = 11;\n    // 弹幕输入框文案\n    string input_placeholder = 12;\n    // 用户举报弹幕 cid维度屏蔽的正则规则\n    repeated string report_filter_content = 13;\n    //\n    ExpoReport expo_report = 14;\n    //\n    BuzzwordConfig buzzword_config = 15;\n    //\n    repeated Expressions expressions = 16;\n    //\n    repeated PostPanel post_panel = 17;\n    //\n    repeated string activity_meta = 18;\n    //\n    repeated PostPanelV2 post_panel2 = 19;\n}\n\n// 客户端弹幕元数据-请求\nmessage DmViewReq {\n    // 稿件avid/漫画epid\n    int64 pid = 1;\n    // 视频cid/漫画cid\n    int64 oid = 2;\n    // 弹幕类型\n    // 1:视频 2:漫画\n    int32 type = 3;\n    // 页面spm\n    string spmid = 4;\n    // 是否冷启\n    int32 is_hard_boot = 5;\n}\n\n// web端弹幕元数据-响应\n// https://api.bilibili.com/x/v2/dm/web/view\nmessage DmWebViewReply {\n    // 是否已关闭弹幕\n    // 0:未关闭 1:已关闭\n    int32 state = 1;\n    //\n    string text = 2;\n    //\n    string text_side = 3;\n    // 分段弹幕配置\n    DmSegConfig dm_sge = 4;\n    // 云屏蔽配置信息\n    DanmakuFlagConfig flag = 5;\n    // 高级弹幕专包url(bfs)\n    repeated string special_dms = 6;\n    // check box 是否展示\n    bool check_box = 7;\n    // 弹幕数\n    int64 count = 8;\n    // 互动弹幕\n    repeated CommandDm commandDms = 9;\n    // 用户弹幕配置\n    DanmuWebPlayerConfig player_config = 10;\n    // 用户举报弹幕 cid维度屏蔽\n    repeated string report_filter_content = 11;\n    //\n    repeated Expressions expressions = 12;\n    //\n    repeated PostPanel post_panel = 13;\n    //\n    repeated string activity_meta = 14;\n}\n\n//\nmessage ExpoReport {\n    //\n    bool should_report_at_end = 1;\n}\n\n//\nenum ExposureType {\n    ExposureTypeNone   = 0; //\n    ExposureTypeDMSend = 1; //\n}\n\n//\nmessage Expression {\n    //\n    repeated string keyword = 1;\n    //\n    string url = 2;\n    //\n    repeated Period period = 3;\n}\n\n//\nmessage Expressions {\n    //\n    repeated Expression data = 1;\n}\n\n// 是否开启弹幕\nmessage InlinePlayerDanmakuSwitch {\n    //\n    bool value = 1;\n} \n\n//\nmessage Label {\n    //\n    string title = 1;\n    //\n    repeated string content = 2;\n}\n\n//\nmessage LabelV2 {\n    //\n    string title = 1;\n    //\n    repeated string content = 2;\n    //\n    bool exposure_once = 3;\n    //\n    int32 exposure_type = 4;\n}\n\n//\nmessage Period {\n    //\n    int64 start = 1;\n    //\n    int64 end = 2;\n}\n\nmessage PlayerDanmakuAiRecommendedLevel   {bool  value = 1;} // 智能云屏蔽等级\nmessage PlayerDanmakuAiRecommendedLevelV2 {int32 value = 1;} //\nmessage PlayerDanmakuAiRecommendedSwitch  {bool  value = 1;} // 是否开启智能云屏蔽\nmessage PlayerDanmakuBlockbottom          {bool  value = 1;} // 是否屏蔽底端弹幕\nmessage PlayerDanmakuBlockcolorful        {bool  value = 1;} // 是否屏蔽彩色弹幕\nmessage PlayerDanmakuBlockrepeat          {bool  value = 1;} // 是否屏蔽重复弹幕\nmessage PlayerDanmakuBlockscroll          {bool  value = 1;} // 是否屏蔽滚动弹幕\nmessage PlayerDanmakuBlockspecial         {bool  value = 1;} // 是否屏蔽高级弹幕\nmessage PlayerDanmakuBlocktop             {bool  value = 1;} // 是否屏蔽顶端弹幕\nmessage PlayerDanmakuDomain               {float value = 1;} // 弹幕显示区域\nmessage PlayerDanmakuEnableblocklist      {bool  value = 1;} // 是否开启屏蔽列表\nmessage PlayerDanmakuOpacity              {float value = 1;} // 弹幕不透明度\nmessage PlayerDanmakuScalingfactor        {float value = 1;} // 弹幕缩放比例\nmessage PlayerDanmakuSeniorModeSwitch     {int32 value = 1;} //\nmessage PlayerDanmakuSpeed                {int32 value = 1;} // 弹幕速度\nmessage PlayerDanmakuSwitch               {bool  value = 1; bool can_ignore = 2;} // 是否开启弹幕\nmessage PlayerDanmakuSwitchSave           {bool  value = 1;} // 是否记录弹幕开关设置\nmessage PlayerDanmakuUseDefaultConfig     {bool  value = 1;} // 是否使用推荐弹幕设置\n\n//\nmessage PostPanel {\n    //\n    int64 start = 1;\n    //\n    int64 end = 2;\n    //\n    int64 priority = 3;\n    //\n    int64 biz_id = 4;\n    //\n    PostPanelBizType biz_type = 5;\n    //\n    ClickButton click_button = 6;\n    //\n    TextInput text_input = 7;\n    //\n    CheckBox check_box = 8;\n    //\n    Toast toast = 9;\n}\n\n//\nenum PostPanelBizType {\n    PostPanelBizTypeNone      = 0; //\n    PostPanelBizTypeEncourage = 1; //\n    PostPanelBizTypeColorDM   = 2; //\n    PostPanelBizTypeNFTDM     = 3; //\n    PostPanelBizTypeFragClose = 4; //\n    PostPanelBizTypeRecommend = 5; //\n}\n\n//\nmessage PostPanelV2 {\n    //\n    int64 start = 1;\n    //\n    int64 end = 2;\n    //\n    int32 biz_type = 3;\n    //\n    ClickButtonV2 click_button = 4;\n    //\n    TextInputV2 text_input = 5;\n    //\n    CheckBoxV2 check_box = 6;\n    //\n    ToastV2 toast = 7;\n    //\n    BubbleV2 bubble = 8;\n    //\n    LabelV2 label = 9;\n    //\n    int32 post_status = 10;\n}\n\n//\nenum PostStatus {\n    PostStatusNormal = 0; //\n    PostStatusClosed = 1; //\n}\n\n//\nenum RenderType {\n    RenderTypeNone     = 0; //\n    RenderTypeSingle   = 1; //\n    RenderTypeRotation = 2; //\n}\n\n// 修改弹幕配置-响应\nmessage Response {\n    //\n    int32 code = 1;\n    //\n    string message = 2;\n}\n\n//\nenum SubtitleAiStatus {\n    None     = 0; //\n    Exposure = 1; //\n    Assist   = 2; //\n}\n\n//\nenum SubtitleAiType {\n    Normal    = 0; //\n    Translate = 1; //\n}\n\n// 单个字幕信息\nmessage SubtitleItem {\n    // 字幕id\n    int64 id = 1;\n    // 字幕id str\n    string id_str = 2;\n    // 字幕语言代码\n    string lan = 3;\n    // 字幕语言\n    string lan_doc = 4;\n    // 字幕文件url\n    string subtitle_url = 5;\n    // 字幕作者信息\n    UserInfo author = 6;\n    // 字幕类型\n    SubtitleType type = 7;\n    //\n    string lan_doc_brief = 8;\n    //\n    SubtitleAiType ai_type = 9;\n    //\n    SubtitleAiStatus ai_status = 10;\n}\n\nenum SubtitleType {\n    CC = 0; // CC字幕\n    AI = 1; // AI生成字幕\n}\n\n//\nmessage TextInput {\n    //\n    repeated string portrait_placeholder = 1;\n    //\n    repeated string landscape_placeholder = 2;\n    //\n    RenderType render_type = 3;\n    //\n    bool placeholder_post = 4;\n    //\n    bool show = 5;\n    //\n    repeated Avatar avatar = 6;\n    //\n    PostStatus post_status = 7;\n    //\n    Label label = 8;\n}\n\n//\nmessage TextInputV2 {\n    //\n    repeated string portrait_placeholder = 1;\n    //\n    repeated string landscape_placeholder = 2;\n    //\n    RenderType render_type = 3;\n    //\n    bool placeholder_post = 4;\n    //\n    repeated Avatar avatar = 5;\n    //\n    int32 text_input_limit = 6;\n}\n\n//\nmessage Toast {\n    //\n    string text = 1;\n    //\n    int32 duration = 2;\n    //\n    bool show = 3;\n    //\n    Button button = 4;\n}\n\n//\nmessage ToastButtonV2 {\n    //\n    string text = 1;\n    //\n    int32 action = 2;\n}\n\n//\nenum ToastFunctionType {\n    ToastFunctionTypeNone      = 0; //\n    ToastFunctionTypePostPanel = 1; //\n}\n\n//\nmessage ToastV2 {\n    //\n    string text = 1;\n    //\n    int32 duration = 2;\n    //\n    ToastButtonV2 toast_button_v2 = 3;\n}\n\n// 字幕作者信息\nmessage UserInfo {\n    // 用户mid\n    int64 mid = 1;\n    // 用户昵称\n    string name = 2;\n    // 用户性别\n    string sex = 3;\n    // 用户头像url\n    string face = 4;\n    // 用户签名\n    string sign = 5;\n    // 用户等级\n    int32 rank = 6;\n}\n\n// 智能防挡弹幕蒙版信息\nmessage VideoMask {\n    // 视频cid\n    int64 cid = 1;\n    // 平台\n    // 0:web端 1:客户端\n    int32 plat = 2;\n    // 帧率\n    int32 fps = 3;\n    // 间隔时间\n    int64 time = 4;\n    // 蒙版url\n    string mask_url = 5;\n}\n\n// 视频字幕信息\nmessage VideoSubtitle {\n    // 视频原语言代码\n    string lan = 1;\n    // 视频原语言\n    string lanDoc = 2;\n    // 视频字幕列表\n    repeated SubtitleItem subtitles = 3;\n}"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/utils.ts",
    "content": "import type { Result } from 'neverthrow'\nimport { err, ok } from 'neverthrow'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\n\n/**\n * 转换B站bvid为avid\n * 这种基础函数报错的可能性很小，不做处理\n */\nexport function bv2av(bvid: string): number {\n\tconst XOR_CODE = 23442827791579n\n\tconst MASK_CODE = 2251799813685247n\n\tconst BASE = 58n\n\n\tconst data = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'\n\tconst bvidArr = Array.from(bvid)\n\t;[bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]]\n\t;[bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]]\n\tbvidArr.splice(0, 3)\n\tconst tmp = bvidArr.reduce(\n\t\t(pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar)),\n\t\t0n,\n\t)\n\treturn Number((tmp & MASK_CODE) ^ XOR_CODE)\n}\n\n/**\n * 将 AV 号转换为 BV 号。\n * @param avid\n * @returns bvid\n */\nexport function av2bv(avid: number | bigint): string {\n\tconst XOR_CODE = 23442827791579n\n\tconst MAX_AID = 2251799813685248n\n\tconst BASE = 58n\n\tconst MAGIC_STR = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'\n\n\tlet tempNum = (BigInt(avid) | MAX_AID) ^ XOR_CODE\n\n\tconst resultArray = Array.from('BV1000000000')\n\n\tfor (let i = 11; i >= 3; i--) {\n\t\tresultArray[i] = MAGIC_STR[Number(tempNum % BASE)]\n\t\ttempNum /= BASE\n\t}\n\n\t;[resultArray[3], resultArray[9]] = [resultArray[9], resultArray[3]]\n\t;[resultArray[4], resultArray[7]] = [resultArray[7], resultArray[4]]\n\n\treturn resultArray.join('')\n}\n\nexport function getCsrfToken(): Result<string, BilibiliApiError> {\n\tconst cookieList = useAppStore.getState().bilibiliCookie\n\tif (!cookieList)\n\t\treturn err(\n\t\t\tnew BilibiliApiError({\n\t\t\t\tmessage: '未找到 Cookie',\n\t\t\t\ttype: 'NoCookie',\n\t\t\t}),\n\t\t)\n\tconst csrfToken = cookieList.bili_jct as string | undefined\n\tif (!csrfToken) {\n\t\treturn err(\n\t\t\tnew BilibiliApiError({\n\t\t\t\tmessage: '未找到 CSRF Token',\n\t\t\t\ttype: 'CsrfError',\n\t\t\t}),\n\t\t)\n\t}\n\treturn ok(csrfToken)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/bilibili/wbi.ts",
    "content": "import md5 from 'md5'\nimport { okAsync, type ResultAsync } from 'neverthrow'\n\nimport type { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\nimport log from '@/utils/log'\nimport { storage } from '@/utils/mmkv'\n\nimport { bilibiliApiClient } from './client'\n\nconst logger = log.extend('3Party.Bilibili.Wbi')\n\nconst mixinKeyEncTab = [\n\t46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,\n\t33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,\n\t26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,\n\t20, 34, 44, 52,\n]\n\n// 对 imgKey 和 subKey 进行字符顺序打乱编码\nconst getMixinKey = (orig: string) =>\n\tmixinKeyEncTab\n\t\t.map((n) => orig[n])\n\t\t.join('')\n\t\t.slice(0, 32)\n\n// 为请求参数进行 wbi 签名\nfunction encWbi(\n\tparams: Record<string, string | number | object>,\n\timg_key: string,\n\tsub_key: string,\n) {\n\tconst mixin_key = getMixinKey(img_key + sub_key)\n\tconst curr_time = Math.round(Date.now() / 1000)\n\tconst chr_filter = /[!'()*]/g\n\n\tObject.assign(params, { wts: curr_time }) // 添加 wts 字段\n\t// 按照 key 重排参数\n\tconst query = Object.keys(params)\n\t\t.sort()\n\t\t.map((key) => {\n\t\t\t// 过滤 value 中的 \"!'()*\" 字符\n\t\t\t// oxlint-disable-next-line @typescript-eslint/no-base-to-string\n\t\t\tconst value = params[key].toString().replace(chr_filter, '')\n\t\t\treturn `${encodeURIComponent(key)}=${encodeURIComponent(value)}`\n\t\t})\n\t\t.join('&')\n\n\tconst wbi_sign = md5(query + mixin_key) // 计算 w_rid\n\n\treturn `${query}&w_rid=${wbi_sign}`\n}\n\nfunction isSameDayAsToday(timestamp: number) {\n\tconst dateToCompare = new Date(timestamp)\n\n\tif (Number.isNaN(dateToCompare.getTime())) {\n\t\tlogger.error('提供的时间戳无效:', timestamp)\n\t\treturn false\n\t}\n\n\tconst now = new Date()\n\n\treturn (\n\t\tdateToCompare.getFullYear() === now.getFullYear() &&\n\t\tdateToCompare.getMonth() === now.getMonth() &&\n\t\tdateToCompare.getDate() === now.getDate()\n\t)\n}\n\ninterface WbiKeys {\n\timg_key: string\n\tsub_key: string\n\ttimestamp: number // 获取时间\n}\n\nfunction getWbiKeysFromStorage() {\n\tconst keys = storage.getString('wbi_keys')\n\tif (!keys) return null\n\ttry {\n\t\treturn JSON.parse(keys) as WbiKeys\n\t} catch (error) {\n\t\tlogger.warning('从本地解析 wbi_keys 失败，尝试重新获取:', error)\n\t\treturn null\n\t}\n}\n\n/**\n * 获取最新的 img_key 和 sub_key\n */\nfunction getWbiKeys(): ResultAsync<\n\t{\n\t\timg_key: string\n\t\tsub_key: string\n\t},\n\tBilibiliApiError\n> {\n\tconst localKeys = getWbiKeysFromStorage()\n\tif (localKeys) {\n\t\tif (isSameDayAsToday(localKeys.timestamp)) {\n\t\t\treturn okAsync(localKeys)\n\t\t}\n\t\tlogger.debug('本地 wbi_keys 已过期，重新获取')\n\t}\n\tconst result = bilibiliApiClient.get<{\n\t\twbi_img: { img_url: string; sub_url: string }\n\t}>('/x/web-interface/nav', undefined)\n\treturn result.map(({ wbi_img: { img_url, sub_url } }) => {\n\t\tconst img_key = img_url.slice(\n\t\t\timg_url.lastIndexOf('/') + 1,\n\t\t\timg_url.lastIndexOf('.'),\n\t\t)\n\t\tconst sub_key = sub_url.slice(sub_url.lastIndexOf('/') + 1)\n\t\tstorage.set(\n\t\t\t'wbi_keys',\n\t\t\tJSON.stringify({\n\t\t\t\timg_key: img_key,\n\t\t\t\tsub_key: sub_key,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t}),\n\t\t)\n\t\treturn { img_key: img_key, sub_key: sub_key }\n\t})\n}\n\nexport default function getWbiEncodedParams(\n\tparams: Record<string, string | number | object>,\n) {\n\tconst result = getWbiKeys()\n\treturn result.map(({ img_key, sub_key }) => encWbi(params, img_key, sub_key))\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/kugou/api.ts",
    "content": "import CryptoJS from 'crypto-js'\nimport { errAsync, ResultAsync } from 'neverthrow'\n\nimport type {\n\tKugouLyricDownloadResponse,\n\tKugouLyricSearchResponse,\n\tKugouSearchResponse,\n} from '@/types/apis/kugou'\nimport type {\n\tLyricProviderResponseData,\n\tLyricSearchResult,\n} from '@/types/player/lyrics'\nimport log from '@/utils/log'\n\nconst logger = log.extend('API.Kugou')\n\nexport class KugouApi {\n\tprivate getHeaders() {\n\t\treturn {\n\t\t\t'User-Agent': 'IPhone-8990-searchSong',\n\t\t\t'UNI-UserAgent': 'iOS11.4-Phone8990-1009-0-WiFi',\n\t\t}\n\t}\n\n\tsearch(\n\t\tkeyword: string,\n\t\tlimit = 10,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<LyricSearchResult, Error> {\n\t\tconst params = new URLSearchParams({\n\t\t\tapi_ver: '1',\n\t\t\tarea_code: '1',\n\t\t\tcorrect: '1',\n\t\t\tpagesize: limit.toString(),\n\t\t\tplat: '2',\n\t\t\ttag: '1',\n\t\t\tsver: '5',\n\t\t\tshowtype: '10',\n\t\t\tpage: '1',\n\t\t\tkeyword: keyword,\n\t\t\tversion: '8990',\n\t\t})\n\n\t\tconst url = `http://mobilecdn.kugou.com/api/v3/search/song?${params.toString()}`\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(url, { headers: this.getHeaders(), signal }).then((res) => {\n\t\t\t\tif (!res.ok) {\n\t\t\t\t\tthrow new Error(`Kugou API error: ${res.statusText}`)\n\t\t\t\t}\n\t\t\t\treturn res.json() as Promise<KugouSearchResponse>\n\t\t\t}),\n\t\t\t(e) => new Error('Failed to search Kugou', { cause: e }),\n\t\t).map((res) => {\n\t\t\tif (res.status !== 1 || !res.data?.info) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\treturn res.data.info.map((song) => ({\n\t\t\t\tsource: 'kugou' as const,\n\t\t\t\tduration: song.duration,\n\t\t\t\ttitle: song.songname || song.filename,\n\t\t\t\tartist: song.singername,\n\t\t\t\tremoteId: song.hash,\n\t\t\t}))\n\t\t})\n\t}\n\n\tgetLyrics(id: string, signal?: AbortSignal): ResultAsync<string, Error> {\n\t\t// Step 1: Search for lyric candidate\n\t\tconst searchParams = new URLSearchParams({\n\t\t\tkeyword: '%20-%20',\n\t\t\tver: '1',\n\t\t\thash: id,\n\t\t\tclient: 'mobi',\n\t\t\tman: 'yes',\n\t\t})\n\t\tconst searchUrl = `http://krcs.kugou.com/search?${searchParams.toString()}`\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(searchUrl, { signal }).then(\n\t\t\t\t(res) => res.json() as Promise<KugouLyricSearchResponse>,\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\tnew Error('Failed to search lyric candidate on Kugou', { cause: e }),\n\t\t).andThen((searchRes) => {\n\t\t\tif (!searchRes.candidates || searchRes.candidates.length === 0) {\n\t\t\t\treturn errAsync(new Error('No lyric candidates found on Kugou'))\n\t\t\t}\n\n\t\t\tconst candidate = searchRes.candidates[0]\n\n\t\t\t// Step 2: Download lyric\n\t\t\tconst downloadParams = new URLSearchParams({\n\t\t\t\tcharset: 'utf8',\n\t\t\t\taccesskey: candidate.accesskey,\n\t\t\t\tid: candidate.id,\n\t\t\t\tclient: 'mobi',\n\t\t\t\tfmt: 'lrc',\n\t\t\t\tver: '1',\n\t\t\t})\n\t\t\tconst downloadUrl = `http://lyrics.kugou.com/download?${downloadParams.toString()}`\n\n\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\tfetch(downloadUrl, { signal }).then(\n\t\t\t\t\t(res) => res.json() as Promise<KugouLyricDownloadResponse>,\n\t\t\t\t),\n\t\t\t\t(e) => new Error('Failed to download lyric from Kugou', { cause: e }),\n\t\t\t).map((downloadRes) => {\n\t\t\t\t// Decode Base64 content\n\t\t\t\tconst raw = downloadRes.content\n\t\t\t\tconst word = CryptoJS.enc.Base64.parse(raw)\n\t\t\t\treturn CryptoJS.enc.Utf8.stringify(word)\n\t\t\t})\n\t\t})\n\t}\n\n\tparseLyrics(content: string): LyricProviderResponseData {\n\t\treturn {\n\t\t\tlrc: content,\n\t\t\ttlyric: undefined,\n\t\t\tromalrc: undefined,\n\t\t}\n\t}\n\n\tsearchBestMatchedLyrics(\n\t\tkeyword: string,\n\t\tdurationMs: number,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<LyricProviderResponseData, Error> {\n\t\treturn this.search(keyword, 10, signal).andThen((songs) => {\n\t\t\tif (!songs || songs.length === 0) {\n\t\t\t\treturn errAsync(new Error('No songs found on Kugou'))\n\t\t\t}\n\n\t\t\tconst targetDurationSeconds = Math.round(durationMs / 1000)\n\t\t\tlet bestMatch = songs[0]\n\t\t\tconst MAX_DURATION_DIFF = 3\n\t\t\tconst candidates = songs.slice(0, 5)\n\n\t\t\tconst exactMatch = candidates.find(\n\t\t\t\t(s) =>\n\t\t\t\t\tMath.abs(s.duration - targetDurationSeconds) <= MAX_DURATION_DIFF,\n\t\t\t)\n\n\t\t\tif (exactMatch) {\n\t\t\t\tbestMatch = exactMatch\n\t\t\t} else {\n\t\t\t\tlogger.debug(\n\t\t\t\t\t`No exact duration match found. Using first result: ${bestMatch.title}`,\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn this.getLyrics(bestMatch.remoteId as string, signal).map(\n\t\t\t\t(content) => this.parseLyrics(content),\n\t\t\t)\n\t\t})\n\t}\n}\n\nexport const kugouApi = new KugouApi()\n"
  },
  {
    "path": "apps/mobile/src/lib/api/netease/api.ts",
    "content": "import { parseYrc } from '@bbplayer/splash/src/converter/netease'\nimport { errAsync, okAsync, type ResultAsync } from 'neverthrow'\n\nimport { NeteaseApiError } from '@/lib/errors/thirdparty/netease'\nimport type {\n\tNeteaseLyricResponse,\n\tNeteasePlaylistResponse,\n\tNeteaseSearchResponse,\n} from '@/types/apis/netease'\nimport type {\n\tLyricProviderResponseData,\n\tLyricSearchResult,\n} from '@/types/player/lyrics'\n\nimport type { RequestOptions } from './request'\nimport { createRequest } from './request'\nimport { createOption } from './utils'\n\ninterface SearchParams {\n\tkeywords: string\n\ttype?: number | string\n\tlimit?: number\n\toffset?: number\n}\n\nexport class NeteaseApi {\n\tgetLyrics(\n\t\tid: number,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<NeteaseLyricResponse, NeteaseApiError> {\n\t\tconst data = {\n\t\t\tid: id,\n\t\t\tlv: -1,\n\t\t\ttv: -1,\n\t\t\trv: -1,\n\t\t\tkv: -1,\n\t\t\tyv: -1,\n\t\t\tos: 'ios',\n\t\t\tver: 1,\n\t\t}\n\t\tconst requestOptions: RequestOptions = createOption(\n\t\t\t{\n\t\t\t\tcrypto: 'eapi',\n\t\t\t\tcookie: {\n\t\t\t\t\tos: 'ios',\n\t\t\t\t\tappver: '8.7.01',\n\t\t\t\t\tosver: '16.3',\n\t\t\t\t\tdeviceId: '265B59C3-C5DE-4876-8A33-FD52CD5C2960',\n\t\t\t\t},\n\t\t\t},\n\t\t\t'eapi',\n\t\t)\n\t\tif (signal) {\n\t\t\trequestOptions.signal = signal\n\t\t}\n\t\treturn createRequest<object, NeteaseLyricResponse>(\n\t\t\t'/api/song/lyric/v1',\n\t\t\tdata,\n\t\t\trequestOptions,\n\t\t).map((res) => res.body)\n\t}\n\n\tsearch(\n\t\tparams: SearchParams,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<LyricSearchResult, NeteaseApiError> {\n\t\tconst type = params.type ?? 1\n\t\tconst endpoint =\n\t\t\ttype == '2000' ? '/api/search/voice/get' : '/api/cloudsearch/pc'\n\n\t\tconst data = {\n\t\t\ttype: type,\n\t\t\tlimit: params.limit ?? 30,\n\t\t\toffset: params.offset ?? 0,\n\t\t\t...(type == '2000'\n\t\t\t\t? { keyword: params.keywords }\n\t\t\t\t: { s: params.keywords }),\n\t\t}\n\n\t\tconst requestOptions: RequestOptions = createOption({}, 'weapi')\n\t\tif (signal) {\n\t\t\trequestOptions.signal = signal\n\t\t}\n\t\treturn createRequest<object, NeteaseSearchResponse>(\n\t\t\tendpoint,\n\t\t\tdata,\n\t\t\trequestOptions,\n\t\t).map((res) => {\n\t\t\tif (!res.body.result?.songs) return []\n\t\t\treturn res.body.result.songs.map((song) => ({\n\t\t\t\tsource: 'netease' as const,\n\t\t\t\tduration: song.dt / 1000,\n\t\t\t\ttitle: song.name,\n\t\t\t\tartist: song.ar[0].name,\n\t\t\t\tremoteId: song.id,\n\t\t\t}))\n\t\t})\n\t}\n\n\tpublic parseLyrics(\n\t\tlyricsResponse: NeteaseLyricResponse,\n\t): LyricProviderResponseData {\n\t\tconst haveYrc = !!lyricsResponse.yrc?.lyric\n\t\tconst lrc = haveYrc ? lyricsResponse.yrc!.lyric : lyricsResponse.lrc.lyric\n\t\tconst tlrc = haveYrc\n\t\t\t? lyricsResponse.ytlrc?.lyric\n\t\t\t: lyricsResponse.tlyric?.lyric\n\t\tconst romalrc = haveYrc\n\t\t\t? lyricsResponse.yromalrc?.lyric\n\t\t\t: lyricsResponse.romalrc?.lyric\n\t\tconst lyricData: LyricProviderResponseData = {\n\t\t\t// 一手防御性编程，我们不确定 tlyric 和 romalrc 会不会返回 yrc 格式，但是 parse 一下准没错\n\t\t\tlrc: parseYrc(lrc),\n\t\t\ttlyric: tlrc ? parseYrc(tlrc) : undefined,\n\t\t\tromalrc: romalrc ? parseYrc(romalrc) : undefined,\n\t\t}\n\n\t\treturn lyricData\n\t}\n\n\tpublic searchBestMatchedLyrics(\n\t\tkeyword: string,\n\t\t_targetDurationMs: number,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<LyricProviderResponseData, NeteaseApiError> {\n\t\treturn this.search({ keywords: keyword, limit: 10 }, signal).andThen(\n\t\t\t(searchResult) => {\n\t\t\t\tif (searchResult.length === 0) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew NeteaseApiError({\n\t\t\t\t\t\t\tmessage: '未搜索到相关歌曲\\n\\n搜索关键词：' + keyword,\n\t\t\t\t\t\t\ttype: 'SearchResultNoMatch',\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\t// const bestMatch = this.findBestMatch(songs, keyword, targetDurationMs)\n\t\t\t\t// 相信网易云... 哥们儿写的规则太屎了\n\t\t\t\tconst bestMatch = searchResult[0]\n\n\t\t\t\treturn this.getLyrics(bestMatch.remoteId as number, signal).andThen(\n\t\t\t\t\t(lyricsResponse) => {\n\t\t\t\t\t\tconst lyricData = this.parseLyrics(lyricsResponse)\n\t\t\t\t\t\treturn okAsync(lyricData)\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t},\n\t\t)\n\t}\n\n\tgetPlaylist(\n\t\tid: string,\n\t): ResultAsync<NeteasePlaylistResponse, NeteaseApiError> {\n\t\tconst data = {\n\t\t\ts: '0',\n\t\t\tid: id,\n\t\t\tn: '1000',\n\t\t\tt: '0',\n\t\t}\n\t\tconst requestOptions: RequestOptions = createOption({}, 'eapi')\n\t\treturn createRequest<object, NeteasePlaylistResponse>(\n\t\t\t'/api/v6/playlist/detail',\n\t\t\tdata,\n\t\t\trequestOptions,\n\t\t).map((res) => res.body)\n\t}\n}\n\nexport const neteaseApi = new NeteaseApi()\n"
  },
  {
    "path": "apps/mobile/src/lib/api/netease/crypto.ts",
    "content": "/* oxlint-disable @typescript-eslint/no-unsafe-return */\n/* 这些代码从 https://github.com/nooblong/NeteaseCloudMusicApiBackup/ 抄的，能别动就别动！！！！ */\nimport CryptoJS from 'crypto-js'\nimport forge from 'node-forge'\n\nconst iv = '0102030405060708'\nconst presetKey = '0CoJUm6Qyw8W8jud'\nconst linuxapiKey = 'rFgB&h#%2?^eDg:Q'\nconst base62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\nconst publicKey = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----`\nconst eapiKey = 'e82ckenh8dichen8'\n\nconst aesEncrypt = (\n\ttext: string,\n\tmode: 'cbc' | 'ecb',\n\tkey: string,\n\tiv: string,\n\tformat = 'base64',\n): string => {\n\tconst encrypted = CryptoJS.AES.encrypt(\n\t\tCryptoJS.enc.Utf8.parse(text),\n\t\tCryptoJS.enc.Utf8.parse(key),\n\t\t{\n\t\t\tiv: CryptoJS.enc.Utf8.parse(iv),\n\t\t\tmode: CryptoJS.mode[mode.toUpperCase() as keyof typeof CryptoJS.mode],\n\t\t\tpadding: CryptoJS.pad.Pkcs7,\n\t\t},\n\t)\n\tif (format === 'base64') {\n\t\treturn encrypted.toString()\n\t}\n\treturn encrypted.ciphertext.toString().toUpperCase()\n}\n\nconst rsaEncrypt = (str: string, key: string): string => {\n\tconst forgePublicKey = forge.pki.publicKeyFromPem(key)\n\tconst encrypted = forgePublicKey.encrypt(str, 'NONE')\n\treturn forge.util.bytesToHex(encrypted)\n}\n\nexport const weapi = (\n\tobject: object,\n): { params: string; encSecKey: string } => {\n\tconst text = JSON.stringify(object)\n\tlet secretKey = ''\n\tfor (let i = 0; i < 16; i++) {\n\t\tsecretKey += base62.charAt(Math.round(Math.random() * 61))\n\t}\n\treturn {\n\t\tparams: aesEncrypt(\n\t\t\taesEncrypt(text, 'cbc', presetKey, iv),\n\t\t\t'cbc',\n\t\t\tsecretKey,\n\t\t\tiv,\n\t\t),\n\t\tencSecKey: rsaEncrypt(secretKey.split('').toReversed().join(''), publicKey),\n\t}\n}\n\nexport const linuxapi = (object: object): { eparams: string } => {\n\tconst text = JSON.stringify(object)\n\treturn {\n\t\teparams: aesEncrypt(text, 'ecb', linuxapiKey, '', 'hex'),\n\t}\n}\n\nexport const eapi = (\n\turl: string,\n\tobject: object | string,\n): { params: string } => {\n\tconst text = typeof object === 'object' ? JSON.stringify(object) : object\n\tconst message = `nobody${url}use${text}md5forencrypt`\n\tconst digest = CryptoJS.MD5(message).toString()\n\tconst data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`\n\treturn {\n\t\tparams: aesEncrypt(data, 'ecb', eapiKey, '', 'hex'),\n\t}\n}\n\nconst aesDecrypt = (\n\tciphertext: string,\n\tkey: string,\n\tiv: string,\n\tformat = 'base64',\n): string => {\n\tlet bytes\n\tif (format === 'base64') {\n\t\tbytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), {\n\t\t\tiv: CryptoJS.enc.Utf8.parse(iv),\n\t\t\tmode: CryptoJS.mode.ECB,\n\t\t\tpadding: CryptoJS.pad.Pkcs7,\n\t\t})\n\t} else {\n\t\tbytes = CryptoJS.AES.decrypt(\n\t\t\t// @ts-expect-error 暂时用不上\n\t\t\t{ ciphertext: CryptoJS.enc.Hex.parse(ciphertext) },\n\t\t\tCryptoJS.enc.Utf8.parse(key),\n\t\t\t{\n\t\t\t\tiv: CryptoJS.enc.Utf8.parse(iv),\n\t\t\t\tmode: CryptoJS.mode.ECB,\n\t\t\t\tpadding: CryptoJS.pad.Pkcs7,\n\t\t\t},\n\t\t)\n\t}\n\treturn bytes.toString(CryptoJS.enc.Utf8)\n}\n\nexport const eapiResDecrypt = (encryptedParams: string) => {\n\tconst decryptedData = aesDecrypt(encryptedParams, eapiKey, '', 'hex')\n\ttry {\n\t\treturn JSON.parse(decryptedData)\n\t} catch {\n\t\treturn null\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/netease/request.ts",
    "content": "import { Buffer } from 'buffer'\n\n/* oxlint-disable @typescript-eslint/no-unsafe-assignment */\n/* 这些代码从 https://github.com/nooblong/NeteaseCloudMusicApiBackup/ 抄的，但做了进一步封装和解耦，凑合着用 */\nimport type { Result } from 'neverthrow'\nimport { ResultAsync, err, ok } from 'neverthrow'\nimport * as setCookie from 'set-cookie-parser'\n\nimport { NeteaseApiError } from '@/lib/errors/thirdparty/netease'\n\nimport * as Encrypt from './crypto'\nimport { cookieObjToString, cookieToJson, toBoolean } from './utils'\n\ninterface AppConfig {\n\tdomain: string\n\tapiDomain: string\n\tencryptResponse: boolean\n}\n\nconst APP_CONF: AppConfig = {\n\tdomain: 'https://music.163.com',\n\tapiDomain: 'https://interface3.music.163.com',\n\tencryptResponse: true,\n}\n\nconst chooseUserAgent = (uaType: 'pc' | 'linux' | 'iphone' = 'pc'): string => {\n\tconst userAgentMap: Record<string, string> = {\n\t\tpc: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',\n\t\tlinux:\n\t\t\t'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',\n\t\tiphone: 'NeteaseMusic 9.0.90/5038 (iPhone; iOS 16.2; zh_CN)',\n\t}\n\treturn userAgentMap[uaType] || userAgentMap.pc\n}\n\nexport interface RequestOptions {\n\tcookie?: Record<string, string> | string\n\tua?: string\n\tcrypto?: 'weapi' | 'linuxapi' | 'eapi'\n\theaders?: Record<string, string>\n\te_r?: boolean\n\tsignal?: AbortSignal\n}\n\ninterface RequestPayload {\n\turl: string\n\theaders: Record<string, string>\n\tbody: object\n\te_r: boolean\n\tsignal?: AbortSignal\n}\n\nconst buildRequestPayload = <T extends object>(\n\turi: string,\n\tdata: T,\n\toptions: RequestOptions,\n): RequestPayload => {\n\tconst { ua, crypto = 'weapi', signal } = options\n\tconst cookie =\n\t\ttypeof options.cookie === 'string'\n\t\t\t? cookieToJson(options.cookie)\n\t\t\t: (options.cookie ?? {})\n\n\tconst csrfToken = cookie.__csrf || ''\n\tlet url = ''\n\tconst headers: Record<string, string> = {\n\t\t'User-Agent':\n\t\t\tua ?? chooseUserAgent(crypto === 'linuxapi' ? 'linux' : 'iphone'),\n\t\t'Content-Type': 'application/x-www-form-urlencoded',\n\t\tReferer: APP_CONF.domain,\n\t\t...options.headers,\n\t}\n\tlet body = {}\n\tlet e_r = false\n\n\tswitch (crypto) {\n\t\tcase 'weapi': {\n\t\t\tconst weapiData = { ...data, csrf_token: csrfToken }\n\t\t\tbody = Encrypt.weapi(weapiData)\n\t\t\turl = `${APP_CONF.domain}/weapi/${uri.substring(5)}`\n\t\t\tbreak\n\t\t}\n\t\tcase 'linuxapi': {\n\t\t\tbody = Encrypt.linuxapi({\n\t\t\t\tmethod: 'POST',\n\t\t\t\turl: APP_CONF.domain + uri,\n\t\t\t\tparams: data,\n\t\t\t})\n\t\t\turl = `${APP_CONF.domain}/api/linux/forward`\n\t\t\tbreak\n\t\t}\n\t\tcase 'eapi': {\n\t\t\tconst header = {\n\t\t\t\tosver: cookie.osver || '',\n\t\t\t\tdeviceId: cookie.deviceId || '',\n\t\t\t\tos: cookie.os || 'iphone',\n\t\t\t\tappver: cookie.appver || '9.0.90',\n\t\t\t\t__csrf: csrfToken,\n\t\t\t}\n\t\t\tconst eapiData = {\n\t\t\t\t...data,\n\t\t\t\theader,\n\t\t\t\te_r: toBoolean(options.e_r ?? APP_CONF.encryptResponse),\n\t\t\t}\n\t\t\te_r = eapiData.e_r\n\t\t\tbody = Encrypt.eapi(uri, eapiData)\n\t\t\turl = `${APP_CONF.apiDomain}/eapi/${uri.substring(5)}`\n\t\t\theaders.Cookie = cookieObjToString(header)\n\t\t\tbreak\n\t\t}\n\t\tdefault:\n\t\t// pass\n\t}\n\n\treturn { url, headers, body, e_r, signal }\n}\n\ninterface FetchResult<TReturnBody> {\n\tbody: TReturnBody\n\tcookie: string[]\n}\n\nconst executeFetch = <TReturnBody>(\n\tpayload: RequestPayload,\n): ResultAsync<FetchResult<TReturnBody>, NeteaseApiError> => {\n\tconst { url, headers, body, e_r, signal } = payload\n\tconst settings: RequestInit = {\n\t\tmethod: 'POST',\n\t\theaders,\n\t\tbody: new URLSearchParams(body as Record<string, string>).toString(),\n\t\tsignal: signal,\n\t}\n\n\treturn ResultAsync.fromPromise(\n\t\tfetch(url, settings).then(async (res) => {\n\t\t\tif (!res.ok) {\n\t\t\t\treturn err(\n\t\t\t\t\tnew NeteaseApiError({\n\t\t\t\t\t\tmessage: '请求失败！http 状态码不符合预期！',\n\t\t\t\t\t\ttype: 'ResponseFailed',\n\t\t\t\t\t\tmsgCode: res.status,\n\t\t\t\t\t\trawData: res.statusText,\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tconst responseBody = e_r\n\t\t\t\t? Encrypt.eapiResDecrypt(\n\t\t\t\t\t\tBuffer.from(await res.arrayBuffer())\n\t\t\t\t\t\t\t.toString('hex')\n\t\t\t\t\t\t\t.toUpperCase(),\n\t\t\t\t\t)\n\t\t\t\t: await res.json()\n\n\t\t\tconst parsedCookies = setCookie.parse(res.headers.get('set-cookie') ?? '')\n\t\t\tconst cookies = parsedCookies.map(\n\t\t\t\t(cookie) => `${cookie.name}=${cookie.value}`,\n\t\t\t)\n\n\t\t\treturn ok({\n\t\t\t\tbody: responseBody,\n\t\t\t\tcookie: cookies,\n\t\t\t})\n\t\t}),\n\t\t(e: unknown) =>\n\t\t\t// 按理来说不应该发生\n\t\t\tnew NeteaseApiError({\n\t\t\t\tmessage: '请求失败！',\n\t\t\t\ttype: 'RequestFailed',\n\t\t\t\tmsgCode: 500,\n\t\t\t\tcause: e,\n\t\t\t}),\n\t).andThen((res) => res as Result<FetchResult<TReturnBody>, NeteaseApiError>)\n}\n\nexport const createRequest = <TData extends object, TReturnBody>(\n\turi: string,\n\tdata: TData,\n\toptions: RequestOptions,\n): ResultAsync<FetchResult<TReturnBody>, NeteaseApiError> => {\n\tconst payloadResult = buildRequestPayload(uri, data, options)\n\n\treturn executeFetch(payloadResult)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/netease/utils.ts",
    "content": "/* 这些代码从 https://github.com/nooblong/NeteaseCloudMusicApiBackup/ 抄的，能别动就别动！！！！ */\nexport function cookieToJson(cookie: string): Record<string, string> {\n\tconst cookieArr = cookie.split(';')\n\tconst obj: Record<string, string> = {}\n\tcookieArr.forEach((i) => {\n\t\tconst arr = i.trim().split('=')\n\t\tobj[arr[0]] = arr[1]\n\t})\n\treturn obj\n}\n\nexport function cookieObjToString(\n\tcookie: Record<string, string> | string,\n): string {\n\tif (typeof cookie !== 'object') return cookie\n\treturn Object.entries(cookie)\n\t\t.map(([key, value]) => `${key}=${value}`)\n\t\t.join('; ')\n}\n\nexport function toBoolean(value: unknown): boolean {\n\treturn value === 'true' || value === true\n}\n\nexport interface Query {\n\tcrypto?: 'weapi' | 'linuxapi' | 'eapi'\n\tcookie?: string | Record<string, string>\n\tua?: string\n\tproxy?: string\n\trealIP?: string\n\te_r?: boolean\n}\n\nexport function createOption(\n\tquery: Query,\n\tcrypto: 'weapi' | 'linuxapi' | 'eapi' | '' = '',\n) {\n\treturn {\n\t\tcrypto: (query.crypto ?? crypto) || 'weapi',\n\t\tcookie: query.cookie,\n\t\tua: query.ua,\n\t\tproxy: query.proxy,\n\t\trealIP: query.realIP,\n\t\te_r: query.e_r,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/api/qqmusic/api.ts",
    "content": "import { decode } from 'he'\nimport { errAsync, ResultAsync } from 'neverthrow'\n\nimport type {\n\tQQMusicLyricResponse,\n\tQQMusicPlaylistResponse,\n\tQQMusicSearchResponse,\n} from '@/types/apis/qqmusic'\nimport type {\n\tLyricProviderResponseData,\n\tLyricSearchResult,\n} from '@/types/player/lyrics'\nimport log from '@/utils/log'\n\nconst logger = log.extend('API.QQMusic')\n\nexport class QQMusicApi {\n\t/**\n\t * Search for songs on QQ Music\n\t * @param keyword\n\t * @param limit\n\t * @returns\n\t */\n\tpublic search(\n\t\tkeyword: string,\n\t\tlimit = 10,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<LyricSearchResult, Error> {\n\t\tconst searchType = 0 // 0 for song\n\t\tconst pageNum = 1\n\n\t\tconst body = {\n\t\t\tcomm: {\n\t\t\t\tct: '19',\n\t\t\t\tcv: '1859',\n\t\t\t\tuin: '0',\n\t\t\t},\n\t\t\treq: {\n\t\t\t\tmethod: 'DoSearchForQQMusicDesktop',\n\t\t\t\tmodule: 'music.search.SearchCgiService',\n\t\t\t\tparam: {\n\t\t\t\t\tgrp: 1,\n\t\t\t\t\tnum_per_page: limit,\n\t\t\t\t\tpage_num: pageNum,\n\t\t\t\t\tquery: keyword,\n\t\t\t\t\tsearch_type: searchType,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\theaders: {\n\t\t\t\t\t'User-Agent':\n\t\t\t\t\t\t'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',\n\t\t\t\t\tAccept: 'application/json, text/plain, */*',\n\t\t\t\t\t'Content-Type': 'application/json;charset=utf-8',\n\t\t\t\t\tReferer: 'https://y.qq.com/',\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t}).then((res) => {\n\t\t\t\tif (!res.ok) {\n\t\t\t\t\tthrow new Error(`QQ Music API error: ${res.statusText}`)\n\t\t\t\t}\n\t\t\t\treturn res.json() as Promise<QQMusicSearchResponse>\n\t\t\t}),\n\t\t\t(e) => new Error('Failed to search QQ Music', { cause: e }),\n\t\t).map((res) => {\n\t\t\tconst list = res.req.data.body.song.list\n\t\t\treturn list.map((song) => ({\n\t\t\t\tsource: 'qqmusic' as const,\n\t\t\t\tduration: song.interval,\n\t\t\t\ttitle: song.name,\n\t\t\t\tartist: song.singer[0]?.name ?? 'Unknown',\n\t\t\t\tremoteId: song.mid,\n\t\t\t}))\n\t\t})\n\t}\n\n\t/**\n\t * Get lyrics by songmid\n\t * @param songmid\n\t * @returns\n\t */\n\tpublic getLyrics(\n\t\tsongmid: string,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<QQMusicLyricResponse, Error> {\n\t\tconst url = `https://i.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid=${songmid}&g_tk=5381&format=json&inCharset=utf8&outCharset=utf-8&nobase64=1`\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(url, {\n\t\t\t\theaders: {\n\t\t\t\t\tReferer: 'https://y.qq.com/',\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t}).then((res) => {\n\t\t\t\tif (!res.ok) {\n\t\t\t\t\tthrow new Error(`QQ Music API error: ${res.statusText}`)\n\t\t\t\t}\n\t\t\t\treturn res.json() as Promise<QQMusicLyricResponse>\n\t\t\t}),\n\t\t\t(e) => new Error('Failed to fetch lyrics from QQ Music', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * Parse QQ Music lyrics response\n\t * @param response\n\t * @returns\n\t */\n\tpublic parseLyrics(\n\t\tresponse: QQMusicLyricResponse,\n\t): LyricProviderResponseData {\n\t\tconst rawLyrics = response.lyric ? decode(response.lyric) : undefined\n\t\tconst transLyrics = response.trans ? decode(response.trans) : undefined\n\n\t\treturn {\n\t\t\tlrc: rawLyrics,\n\t\t\ttlyric: transLyrics,\n\t\t\tromalrc: undefined,\n\t\t}\n\t}\n\n\t/**\n\t * Search and find the best matched lyrics\n\t * @param keyword\n\t * @param durationMs\n\t */\n\tpublic searchBestMatchedLyrics(\n\t\tkeyword: string,\n\t\tdurationMs: number,\n\t\tsignal?: AbortSignal,\n\t): ResultAsync<LyricProviderResponseData, Error> {\n\t\treturn this.search(keyword, 10, signal).andThen((songs) => {\n\t\t\tif (!songs || songs.length === 0) {\n\t\t\t\treturn errAsync(new Error('No songs found on QQ Music'))\n\t\t\t}\n\n\t\t\t// Simple matching strategy: prefer exact name match, then duration match\n\t\t\tconst targetDurationSeconds = Math.round(durationMs / 1000)\n\n\t\t\t// Use the first result as default since search relevance is usually good\n\t\t\tlet bestMatch = songs[0]\n\n\t\t\t// Try to find a closer duration match among the top few results\n\t\t\tconst MAX_DURATION_DIFF = 3 // seconds\n\t\t\tconst candidates = songs.slice(0, 5)\n\n\t\t\tconst exactMatch = candidates.find(\n\t\t\t\t(s) =>\n\t\t\t\t\tMath.abs(s.duration - targetDurationSeconds) <= MAX_DURATION_DIFF,\n\t\t\t)\n\n\t\t\tif (exactMatch) {\n\t\t\t\tbestMatch = exactMatch\n\t\t\t} else {\n\t\t\t\tlogger.debug(\n\t\t\t\t\t`No exact duration match found. Using first result: ${bestMatch.title} (${bestMatch.duration}s) vs target ${targetDurationSeconds}s`,\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn this.getLyrics(bestMatch.remoteId as string, signal).map(\n\t\t\t\t(response) => this.parseLyrics(response),\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * Get playlist by id\n\t * @param id\n\t * @returns\n\t */\n\tpublic getPlaylist(id: string): ResultAsync<QQMusicPlaylistResponse, Error> {\n\t\tconst params = new URLSearchParams({\n\t\t\tid,\n\t\t\tformat: 'json',\n\t\t\tnewsong: '1',\n\t\t\tplatform: 'jqspaframe.json',\n\t\t})\n\n\t\tconst url = `https://c.y.qq.com/v8/fcg-bin/fcg_v8_playlist_cp.fcg?${params.toString()}`\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tfetch(url, {\n\t\t\t\theaders: {\n\t\t\t\t\tReferer: 'http://y.qq.com',\n\t\t\t\t\t'User-Agent':\n\t\t\t\t\t\t'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',\n\t\t\t\t},\n\t\t\t}).then((res) => {\n\t\t\t\tif (!res.ok) {\n\t\t\t\t\tthrow new Error(`QQ Music API error: ${res.statusText}`)\n\t\t\t\t}\n\t\t\t\treturn res.json() as Promise<QQMusicPlaylistResponse>\n\t\t\t}),\n\t\t\t(e) => new Error('Failed to fetch playlist from QQ Music', { cause: e }),\n\t\t)\n\t}\n}\n\nexport const qqMusicApi = new QQMusicApi()\n"
  },
  {
    "path": "apps/mobile/src/lib/config/queryClient.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport { QueryCache, QueryClient } from '@tanstack/react-query'\n\nimport { useModalStore } from '@/hooks/stores/useModalStore'\nimport { ThirdPartyError } from '@/lib/errors'\nimport { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport toast from '@/utils/toast'\n\nexport const queryClient = new QueryClient({\n\tdefaultOptions: {\n\t\tqueries: {\n\t\t\tretry: 2,\n\t\t\trefetchOnWindowFocus: true,\n\t\t\trefetchOnMount: true,\n\t\t\trefetchOnReconnect: true,\n\t\t\trefetchInterval: false,\n\t\t},\n\t},\n\tqueryCache: new QueryCache({\n\t\tonError: (error, query) => {\n\t\t\tconst handleOfflineError = async () => {\n\t\t\t\ttry {\n\t\t\t\t\tif (\n\t\t\t\t\t\terror instanceof BilibiliApiError &&\n\t\t\t\t\t\terror.data.msgCode === -101\n\t\t\t\t\t) {\n\t\t\t\t\t\ttoast.error('登录状态失效，请重新登录')\n\t\t\t\t\t\tuseModalStore.getState().open('QRCodeLogin', undefined)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\ttoastAndLogError(\n\t\t\t\t\t\t'查询失败: ' + query.queryKey.toString(),\n\t\t\t\t\t\terror,\n\t\t\t\t\t\t'Query',\n\t\t\t\t\t)\n\t\t\t\t} catch {\n\t\t\t\t\t// Fallback in case Network check throws\n\t\t\t\t\ttoastAndLogError(\n\t\t\t\t\t\t'查询失败: ' + query.queryKey.toString(),\n\t\t\t\t\t\terror,\n\t\t\t\t\t\t'Query',\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvoid handleOfflineError()\n\n\t\t\t// 这个错误属于三方依赖的错误，不应该报告到 Sentry\n\t\t\tif (error instanceof ThirdPartyError) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tSentry.captureException(error, {\n\t\t\t\ttags: {\n\t\t\t\t\tscope: 'QueryCache',\n\t\t\t\t\tqueryKey: JSON.stringify(query.queryKey),\n\t\t\t\t},\n\t\t\t\textra: {\n\t\t\t\t\tqueryHash: query.queryHash,\n\t\t\t\t\tretry: query.options.retry,\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t}),\n})\n"
  },
  {
    "path": "apps/mobile/src/lib/config/sentry.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport { isRunningInExpoGo } from 'expo'\nimport * as Application from 'expo-application'\nimport * as Updates from 'expo-updates'\n\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport log from '@/utils/log'\n\nconst logger = log.extend('Utils.Sentry')\n\nconst manifest = Updates.manifest\nconst metadata = 'metadata' in manifest ? manifest.metadata : undefined\nconst extra = 'extra' in manifest ? manifest.extra : undefined\nconst updateGroup =\n\tmetadata && 'updateGroup' in metadata ? metadata.updateGroup : undefined\n\nconst identifier = Application.applicationId\nconst development = process.env.NODE_ENV === 'development'\n\nconst getEnv = () => {\n\tif (development) {\n\t\treturn 'development'\n\t}\n\t// 这不可能发生，只在 web 端会是 null\n\tif (!identifier) {\n\t\treturn 'development'\n\t}\n\tif (identifier === 'com.roitium.bbplayer.dev') {\n\t\treturn 'development'\n\t} else if (identifier === 'com.roitium.bbplayer.preview') {\n\t\treturn 'preview'\n\t}\n\treturn 'production'\n}\n\nexport const navigationIntegration = Sentry.reactNavigationIntegration({\n\tenableTimeToInitialDisplay: !isRunningInExpoGo(),\n})\n\nlogger.info(\n\t'Sentry 启用状态为：',\n\t!development && useAppStore.getState().settings.enableDataCollection,\n)\n\nexport function initializeSentry() {\n\tSentry.init({\n\t\tdsn: 'https://893ea8eb3743da1e065f56b3aa5e96f9@o4508985265618944.ingest.us.sentry.io/4508985267191808',\n\t\tdebug: false,\n\t\ttracesSampleRate: 0.3,\n\t\tsendDefaultPii: false,\n\t\tintegrations: [navigationIntegration],\n\t\tenableNativeFramesTracking: !isRunningInExpoGo(),\n\t\tenabled:\n\t\t\t!development && useAppStore.getState().settings.enableDataCollection,\n\t\tenableLogs: false,\n\t\tenvironment: getEnv(),\n\t\tignoreErrors: ['ExpoHaptics', 'PlaylistAlreadyExists'],\n\t})\n\n\tconst scope = Sentry.getGlobalScope()\n\n\tscope.setTag('expo-update-id', Updates.updateId)\n\tscope.setTag('expo-is-embedded-update', Updates.isEmbeddedLaunch)\n\n\tif (typeof updateGroup === 'string') {\n\t\tscope.setTag('expo-update-group-id', updateGroup)\n\n\t\tconst owner = extra?.expoClient?.owner ?? '[account]'\n\t\tconst slug = extra?.expoClient?.slug ?? '[project]'\n\t\tscope.setTag(\n\t\t\t'expo-update-debug-url',\n\t\t\t`https://expo.dev/accounts/${owner}/projects/${slug}/updates/${updateGroup}`,\n\t\t)\n\t} else if (Updates.isEmbeddedLaunch) {\n\t\tscope.setTag('expo-update-debug-url', 'not applicable for embedded updates')\n\t}\n\n\t// 设置全局错误处理器，捕获未被处理的 JS 错误\n\tif (!development) {\n\t\t// oxlint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access\n\t\tconst errorUtils = (global as any).ErrorUtils\n\t\tif (errorUtils) {\n\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n\t\t\tconst originalErrorHandler = errorUtils.getGlobalHandler()\n\n\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access\n\t\t\terrorUtils.setGlobalHandler((error: Error, isFatal: boolean) => {\n\t\t\t\tSentry.captureException(error, {\n\t\t\t\t\ttags: {\n\t\t\t\t\t\tscope: 'GlobalErrorHandler',\n\t\t\t\t\t\tisFatal: String(isFatal),\n\t\t\t\t\t},\n\t\t\t\t})\n\n\t\t\t\t// oxlint-disable-next-line @typescript-eslint/no-unsafe-call\n\t\t\t\toriginalErrorHandler(error, isFatal)\n\t\t\t})\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/db/db.ts",
    "content": "import { drizzle } from 'drizzle-orm/expo-sqlite/driver'\nimport * as SQLite from 'expo-sqlite'\n\nimport * as schema from './schema'\n\nexport const expoDb = SQLite.openDatabaseSync('db.db', {\n\tenableChangeListener: true,\n})\n// SQLite 默认不强制外键约束，必须每次连接时手动开启\nexpoDb.execSync('PRAGMA foreign_keys = ON;')\nconst drizzleDb = drizzle<typeof schema>(expoDb, { schema })\n\nexport default drizzleDb\n"
  },
  {
    "path": "apps/mobile/src/lib/db/schema.ts",
    "content": "import { relations, sql } from 'drizzle-orm'\nimport {\n\tcheck,\n\tindex,\n\tinteger,\n\tprimaryKey,\n\tsqliteTable,\n\ttext,\n\tuniqueIndex,\n} from 'drizzle-orm/sqlite-core'\n\nexport const artists = sqliteTable(\n\t'artists',\n\t{\n\t\tid: integer('id').primaryKey({ autoIncrement: true }),\n\t\tname: text('name').notNull(),\n\t\tavatarUrl: text('avatar_url'),\n\t\tsignature: text('signature'),\n\t\tsource: text('source', {\n\t\t\tenum: ['bilibili', 'local'],\n\t\t}).notNull(),\n\t\tremoteId: text('remote_id'), // 比如 bilibili mid\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t\tupdatedAt: integer('updated_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`)\n\t\t\t.$onUpdate(() => new Date()),\n\t},\n\t(table) => [\n\t\tuniqueIndex('source_remote_id_unq')\n\t\t\t.on(table.source, table.remoteId)\n\t\t\t.where(sql`source != 'local'`),\n\t\tuniqueIndex('local_artist_unq')\n\t\t\t.on(table.name)\n\t\t\t.where(sql`source = 'local'`), // 如果是 local artist，就基于 name 唯一索引\n\t\tindex('artists_name_idx').on(table.name),\n\t\tcheck(\n\t\t\t'source_integrity_check',\n\t\t\tsql`\n        (source = 'local' AND remote_id IS NULL) \n        OR \n        (source != 'local' AND remote_id IS NOT NULL)\n      `,\n\t\t),\n\t],\n)\n\nexport const tracks = sqliteTable(\n\t'tracks',\n\t{\n\t\tid: integer('id').primaryKey({ autoIncrement: true }),\n\t\tuniqueKey: text('unique_key').unique().notNull(), // 唯一标识符，用于判断是否已存在，基于 source 和其对应的唯一字段生成\n\t\ttitle: text('title').notNull(),\n\t\tartistId: integer('artist_id').references(() => artists.id, {\n\t\t\tonDelete: 'set null', // 如果作者被删除，歌曲的作者ID设为NULL，歌曲本身不删除\n\t\t}),\n\t\tcoverUrl: text('cover_url'),\n\t\tduration: integer('duration'),\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t\tsource: text('source', {\n\t\t\tenum: ['bilibili', 'local'],\n\t\t}).notNull(),\n\t\tupdatedAt: integer('updated_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`)\n\t\t\t.$onUpdate(() => new Date()),\n\t},\n\t(table) => [\n\t\tindex('tracks_artist_idx').on(table.artistId),\n\t\tindex('tracks_title_idx').on(table.title),\n\t\tindex('tracks_source_idx').on(table.source),\n\t],\n)\n\nexport const playHistory = sqliteTable(\n\t'play_history',\n\t{\n\t\tid: integer('id').primaryKey({ autoIncrement: true }),\n\t\ttrackId: integer('track_id')\n\t\t\t.notNull()\n\t\t\t.references(() => tracks.id, { onDelete: 'cascade' }),\n\t\tstartTime: integer('start_time').notNull(), // 播放开始的时间戳 (ms)\n\t\tdurationPlayed: integer('duration_played').notNull(), // 实际播放的秒数\n\t\tcompleted: integer('completed', { mode: 'boolean' }).notNull(), // 是否完整播放\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t},\n\t(table) => [\n\t\tindex('play_history_track_idx').on(table.trackId),\n\t\tindex('play_history_start_time_idx').on(table.startTime),\n\t],\n)\n\nexport const playlists = sqliteTable(\n\t'playlists',\n\t{\n\t\tid: integer('id').primaryKey({ autoIncrement: true }), // 数据库内的唯一 id\n\t\ttitle: text('title').notNull(),\n\t\tauthorId: integer('author_id').references(() => artists.id, {\n\t\t\tonDelete: 'set null', // 如果作者被删除，播放列表的作者ID设为NULL\n\t\t}),\n\t\tdescription: text('description'),\n\t\tcoverUrl: text('cover_url'),\n\t\titemCount: integer('item_count').notNull().default(0),\n\t\ttype: text('type', {\n\t\t\tenum: ['favorite', 'collection', 'multi_page', 'local', 'dynamic'],\n\t\t}).notNull(),\n\t\tremoteSyncId: integer('remote_sync_id'), // 当存在这个值时，这个 playlist 只能从远程同步，而不能从本地直接修改（或许也可以？因为我们已经实现了大量本地有关收藏夹的操作逻辑，先不管了~）\n\t\tlastSyncedAt: integer('last_synced_at', { mode: 'timestamp_ms' }),\n\t\t// 歌单分享功能字段\n\t\tshareId: text('share_id'), // 对应后端 shared_playlists.id (UUID)，null 表示纯本地歌单\n\t\tshareRole: text('share_role', {\n\t\t\tenum: ['owner', 'editor', 'subscriber'],\n\t\t}), // null 表示不参与任何共享歌单\n\t\tlastShareSyncAt: integer('last_share_sync_at', { mode: 'timestamp_ms' }), // 增量同步游标，存服务端 server_time\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t\tupdatedAt: integer('updated_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`)\n\t\t\t.$onUpdate(() => new Date()),\n\t},\n\t(table) => [\n\t\tindex('playlists_title_idx').on(table.title),\n\t\tindex('playlists_type_idx').on(table.type),\n\t\tindex('playlists_author_idx').on(table.authorId),\n\t\tindex('playlists_share_id_idx').on(table.shareId),\n\t],\n)\n\nexport const dynamicPlaylistSources = sqliteTable(\n\t'dynamic_playlist_sources',\n\t{\n\t\tplaylistId: integer('playlist_id')\n\t\t\t.notNull()\n\t\t\t.references(() => playlists.id, { onDelete: 'cascade' }),\n\t\tsourcePlaylistId: integer('source_playlist_id')\n\t\t\t.notNull()\n\t\t\t.references(() => playlists.id, { onDelete: 'cascade' }),\n\t\tposition: integer('position').notNull(),\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t},\n\t(table) => [\n\t\tprimaryKey({ columns: [table.playlistId, table.sourcePlaylistId] }),\n\t\tindex('dynamic_playlist_sources_playlist_idx').on(table.playlistId),\n\t\tindex('dynamic_playlist_sources_playlist_position_idx').on(\n\t\t\ttable.playlistId,\n\t\t\ttable.position,\n\t\t),\n\t\tindex('dynamic_playlist_sources_source_idx').on(table.sourcePlaylistId),\n\t],\n)\n\nexport const playlistTracks = sqliteTable(\n\t'playlist_tracks',\n\t{\n\t\tplaylistId: integer('playlist_id')\n\t\t\t.notNull()\n\t\t\t.references(() => playlists.id, { onDelete: 'cascade' }), // 级联删除\n\t\ttrackId: integer('track_id')\n\t\t\t.notNull()\n\t\t\t.references(() => tracks.id, { onDelete: 'cascade' }),\n\t\tsortKey: text('sort_key').notNull(), // 歌曲在列表中的顺序，fractional indexing 字符串键\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t},\n\t(table) => [\n\t\tprimaryKey({ columns: [table.playlistId, table.trackId] }),\n\t\tindex('playlist_tracks_track_idx').on(table.trackId),\n\t\tindex('playlist_tracks_sort_key_idx').on(table.playlistId, table.sortKey),\n\t],\n)\n\nexport const bilibiliMetadata = sqliteTable(\n\t'bilibili_metadata',\n\t{\n\t\ttrackId: integer('track_id')\n\t\t\t.primaryKey()\n\t\t\t.references(() => tracks.id, { onDelete: 'cascade' }),\n\t\tbvid: text('bvid').notNull(),\n\t\tcid: integer('cid'),\n\t\tisMultiPage: integer('is_multi_page', { mode: 'boolean' }).notNull(),\n\t\tmainTrackTitle: text('main_track_title'), // 如果是分 p 视频，保存该分 p 所在的主视频标题\n\t\tvideoIsValid: integer('video_is_valid', { mode: 'boolean' })\n\t\t\t.notNull()\n\t\t\t.default(true), // 处理 bilibili 收藏夹中的被删除视频...\n\t},\n\t(table) => [\n\t\tindex('bilibili_metadata_bvid_cid_idx').on(table.bvid, table.cid),\n\t],\n)\n\nexport const localMetadata = sqliteTable('local_metadata', {\n\ttrackId: integer('track_id')\n\t\t.primaryKey()\n\t\t.references(() => tracks.id, { onDelete: 'cascade' }),\n\tlocalPath: text('local_path').notNull(),\n})\n\nexport const playlistSyncQueue = sqliteTable(\n\t'playlist_sync_queue',\n\t{\n\t\tid: integer('id').primaryKey({ autoIncrement: true }),\n\t\tplaylistId: integer('playlist_id')\n\t\t\t.notNull()\n\t\t\t.references(() => playlists.id, { onDelete: 'cascade' }),\n\t\toperation: text('operation', {\n\t\t\tenum: ['add_tracks', 'remove_tracks', 'reorder_track', 'update_metadata'],\n\t\t}).notNull(),\n\t\tpayload: text('payload', { mode: 'json' }).notNull(),\n\t\tstatus: text('status', {\n\t\t\tenum: ['pending', 'syncing', 'done', 'failed'],\n\t\t})\n\t\t\t.notNull()\n\t\t\t.default('pending'),\n\t\t// 用户真正执行操作的时间，入队时立刻记录，不是上传时的时间\n\t\t// 这是 LWW 冲突解决的基准时间戳，防止网络延迟重试时覆盖掉更新的操作\n\t\toperationAt: integer('operation_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t\tcreatedAt: integer('created_at', { mode: 'timestamp_ms' })\n\t\t\t.notNull()\n\t\t\t.default(sql`(unixepoch() * 1000)`),\n\t},\n\t(table) => [\n\t\tindex('playlist_sync_queue_status_idx').on(table.status),\n\t\tindex('playlist_sync_queue_playlist_id_idx').on(table.playlistId),\n\t],\n)\n\n// ##################################\n// RELATIONS\n// ##################################\nexport const artistRelations = relations(artists, ({ many }) => ({\n\ttracks: many(tracks),\n\tauthoredPlaylists: many(playlists),\n}))\n\nexport const trackRelations = relations(tracks, ({ one, many }) => ({\n\tartist: one(artists, {\n\t\tfields: [tracks.artistId],\n\t\treferences: [artists.id],\n\t}),\n\tplaylistLinks: many(playlistTracks),\n\tbilibiliMetadata: one(bilibiliMetadata, {\n\t\tfields: [tracks.id],\n\t\treferences: [bilibiliMetadata.trackId],\n\t}),\n\tlocalMetadata: one(localMetadata, {\n\t\tfields: [tracks.id],\n\t\treferences: [localMetadata.trackId],\n\t}),\n\tplayHistory: many(playHistory),\n}))\n\nexport const playHistoryRelations = relations(playHistory, ({ one }) => ({\n\ttrack: one(tracks, {\n\t\tfields: [playHistory.trackId],\n\t\treferences: [tracks.id],\n\t}),\n}))\n\nexport const playlistRelations = relations(playlists, ({ one, many }) => ({\n\tauthor: one(artists, {\n\t\tfields: [playlists.authorId],\n\t\treferences: [artists.id],\n\t}),\n\ttrackLinks: many(playlistTracks),\n\tdynamicSources: many(dynamicPlaylistSources, {\n\t\trelationName: 'dynamicPlaylist',\n\t}),\n\tdynamicDependents: many(dynamicPlaylistSources, {\n\t\trelationName: 'dynamicSourcePlaylist',\n\t}),\n}))\n\nexport const dynamicPlaylistSourceRelations = relations(\n\tdynamicPlaylistSources,\n\t({ one }) => ({\n\t\tplaylist: one(playlists, {\n\t\t\tfields: [dynamicPlaylistSources.playlistId],\n\t\t\treferences: [playlists.id],\n\t\t\trelationName: 'dynamicPlaylist',\n\t\t}),\n\t\tsourcePlaylist: one(playlists, {\n\t\t\tfields: [dynamicPlaylistSources.sourcePlaylistId],\n\t\t\treferences: [playlists.id],\n\t\t\trelationName: 'dynamicSourcePlaylist',\n\t\t}),\n\t}),\n)\n\nexport const playlistTrackRelations = relations(playlistTracks, ({ one }) => ({\n\tplaylist: one(playlists, {\n\t\tfields: [playlistTracks.playlistId],\n\t\treferences: [playlists.id],\n\t}),\n\ttrack: one(tracks, {\n\t\tfields: [playlistTracks.trackId],\n\t\treferences: [tracks.id],\n\t}),\n}))\n\nexport const bilibiliMetadataRelations = relations(\n\tbilibiliMetadata,\n\t({ one }) => ({\n\t\ttrack: one(tracks, {\n\t\t\tfields: [bilibiliMetadata.trackId],\n\t\t\treferences: [tracks.id],\n\t\t}),\n\t}),\n)\n\nexport const localMetadataRelations = relations(localMetadata, ({ one }) => ({\n\ttrack: one(tracks, {\n\t\tfields: [localMetadata.trackId],\n\t\treferences: [tracks.id],\n\t}),\n}))\n"
  },
  {
    "path": "apps/mobile/src/lib/errors/facade.ts",
    "content": "import { FacadeError as BaseFacadeError } from '.'\n\nexport type FacadeErrorType =\n\t| 'SyncTaskAlreadyRunning'\n\t| 'SyncCollectionFailed'\n\t| 'SyncMultiPageFailed'\n\t| 'SyncFavoriteFailed'\n\t| 'fetchRemotePlaylistMetadataFailed'\n\t| 'PlaylistDuplicateFailed'\n\t| 'UpdateTrackLocalPlaylistsFailed'\n\t| 'BatchAddTracksToLocalPlaylistFailed'\n\t| 'PlaylistCreateFailed'\n\t| 'PlaylistMergeFailed'\n\t| 'SavePlaylistFailed'\n\t| 'SharedPlaylistEnableFailed'\n\t| 'SharedPlaylistSubscribeFailed'\n\t| 'SharedPlaylistRestoreFailed'\n\t| 'SharedPlaylistPullFailed'\n\t| 'SharedPlaylistDeleted'\n\t| 'SharedPlaylistUnsubscribeFailed'\n\t| 'RemoveTracksFromPlaylistFailed'\n\t| 'ReorderPlaylistTrackFailed'\n\t| 'UpdatePlaylistMetadataFailed'\n\t| 'PlaylistPermissionDenied'\n\t| 'PlaylistDeleteFailed'\n\t| 'InviteCodeRotateFailed'\n\t| 'InviteCodeFetchFailed'\n\t| 'SharedPlaylistNotFound'\n\t| 'SharedPlaylistPreviewFailed'\n\nexport class FacadeError extends BaseFacadeError {\n\tconstructor(\n\t\tmessage: string,\n\t\topts?: { type?: FacadeErrorType; data?: unknown; cause?: unknown },\n\t) {\n\t\tsuper(message, { type: opts?.type, data: opts?.data, cause: opts?.cause })\n\t}\n}\n\nexport function createSyncTaskAlreadyRunningError(cause?: unknown) {\n\treturn new FacadeError('同步任务正在进行中，请稍后再试', {\n\t\ttype: 'SyncTaskAlreadyRunning',\n\t\tcause,\n\t})\n}\n\nexport function createFacadeError(\n\ttype: FacadeErrorType,\n\tmessage: string,\n\toptions?: { data?: unknown; cause?: unknown },\n) {\n\treturn new FacadeError(message, {\n\t\ttype,\n\t\tdata: options?.data,\n\t\tcause: options?.cause,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/errors/index.ts",
    "content": "export class CustomError extends Error {\n\treadonly type?: string\n\treadonly data?: unknown\n\tconstructor(\n\t\tmessage: string,\n\t\topts?: { type?: string; data?: unknown; cause?: unknown },\n\t) {\n\t\tsuper(message, { cause: opts?.cause })\n\t\tthis.name = this.constructor.name\n\t\tthis.type = opts?.type\n\t\tthis.data = opts?.data\n\t}\n}\n\nexport class ServiceError extends CustomError {}\n\nexport class FacadeError extends CustomError {}\n\nexport class UIError extends CustomError {}\n\nexport class ThirdPartyError extends CustomError {\n\treadonly vendor?: string\n\treadonly type?: string\n\treadonly data?: unknown\n\tconstructor(\n\t\tmessage: string,\n\t\topts?: { vendor?: string; type?: string; data?: unknown; cause?: unknown },\n\t) {\n\t\tsuper(message, { type: opts?.type, data: opts?.data, cause: opts?.cause })\n\t\tthis.vendor = opts?.vendor\n\t\tthis.type = opts?.type\n\t\tthis.data = opts?.data\n\t}\n}\n\nexport class DatabaseError extends CustomError {}\nexport class DataParsingError extends CustomError {}\nexport class FileSystemError extends CustomError {}\nexport class LrcParseError extends CustomError {\n\tconstructor(message: string) {\n\t\tsuper(message, { type: 'LrcParseError' })\n\t}\n}\n\nexport class LyricNotFoundError extends CustomError {\n\tconstructor(message: string, opts?: { cause?: unknown }) {\n\t\tsuper(message, { type: 'LyricNotFound', cause: opts?.cause })\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/errors/player.ts",
    "content": "import { UIError } from '.'\n\n// export enum PlayerErrorType {\n// \tUnknownSource = 'UnknownSource',\n// \tAudioUrlNotFound = 'AudioUrlNotFound',\n// }\n\nexport type PlayerErrorType = 'UnknownSource' | 'AudioUrlNotFound'\n\nexport class PlayerError extends UIError {\n\tconstructor(\n\t\tmessage: string,\n\t\topts?: { type?: PlayerErrorType; data?: unknown; cause?: unknown },\n\t) {\n\t\tsuper(message, { type: opts?.type, data: opts?.data, cause: opts?.cause })\n\t}\n}\n\nexport function createPlayerError(\n\ttype: PlayerErrorType,\n\tmessage: string,\n\toptions?: { data?: unknown; cause?: unknown },\n) {\n\treturn new PlayerError(message, {\n\t\ttype,\n\t\tdata: options?.data,\n\t\tcause: options?.cause,\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/errors/service.ts",
    "content": "import { ServiceError } from './index'\nexport type ServiceErrorType =\n\t| 'TrackNotFound'\n\t| 'ArtistNotFound'\n\t| 'PlaylistNotFound'\n\t| 'PlaylistAlreadyExists'\n\t| 'TrackNotInPlaylist'\n\t| 'ArtistAlreadyExists'\n\t| 'Validation'\n\t| 'NotImplemented'\n\t| 'FetchDownloadUrlFailed'\n\t| 'DeleteDownloadRecordFailed'\n\nexport function createServiceError(\n\ttype: ServiceErrorType,\n\tmessage: string,\n\toptions?: { data?: unknown; cause?: unknown },\n) {\n\treturn new ServiceError(message, {\n\t\ttype,\n\t\tdata: options?.data,\n\t\tcause: options?.cause,\n\t})\n}\n\nexport function createTrackNotFound(trackId: number | string, cause?: unknown) {\n\treturn createServiceError('TrackNotFound', `未找到 track ${trackId}`, {\n\t\tdata: { trackId },\n\t\tcause,\n\t})\n}\n\nexport function createArtistNotFound(\n\tartistId: number | string,\n\tcause?: unknown,\n) {\n\treturn createServiceError('ArtistNotFound', `未找到 artist ${artistId}`, {\n\t\tdata: { artistId },\n\t\tcause,\n\t})\n}\n\nexport function createPlaylistNotFound(\n\tplaylistId: number | string,\n\tcause?: unknown,\n) {\n\treturn createServiceError(\n\t\t'PlaylistNotFound',\n\t\t`未找到 playlist ${playlistId}`,\n\t\t{ data: { playlistId }, cause },\n\t)\n}\n\nexport function createTrackNotInPlaylist(\n\ttrackId: number | string,\n\tplaylistId: number | string,\n\tcause?: unknown,\n) {\n\treturn createServiceError(\n\t\t'TrackNotInPlaylist',\n\t\t`track ${trackId} 不在 playlist ${playlistId} 中`,\n\t\t{\n\t\t\tdata: { trackId, playlistId },\n\t\t\tcause,\n\t\t},\n\t)\n}\n\nexport function createValidationError(\n\tmessage = '参数校验失败',\n\tcause?: unknown,\n) {\n\treturn createServiceError('Validation', message, { cause })\n}\n\nexport function createNotImplementedError(message = '未实现', cause?: unknown) {\n\treturn createServiceError('NotImplemented', message, { cause })\n}\n\nexport function createPlaylistAlreadyExists(title: string, cause?: unknown) {\n\treturn createServiceError(\n\t\t'PlaylistAlreadyExists',\n\t\t`播放列表 \"${title}\" 已存在`,\n\t\t{\n\t\t\tdata: { title },\n\t\t\tcause,\n\t\t},\n\t)\n}\n\nexport { DatabaseError } from './index'\n"
  },
  {
    "path": "apps/mobile/src/lib/errors/thirdparty/bilibili.ts",
    "content": "import { ThirdPartyError } from '@/lib/errors'\n\nexport type BilibiliApiErrorType =\n\t| 'RequestFailed'\n\t| 'ResponseFailed'\n\t| 'NoCookie'\n\t| 'CsrfError'\n\t| 'AudioStreamError'\n\t| 'RequestAborted'\n\t| 'InvalidArgument'\n\ninterface BilibiliApiErrorDetails {\n\tmessage: string\n\tmsgCode?: number\n\trawData?: unknown\n\ttype?: BilibiliApiErrorType\n\tcause?: unknown\n}\n\ninterface BilibiliErrorData {\n\tmsgCode: number\n\trawData: unknown\n}\n\nexport class BilibiliApiError extends ThirdPartyError {\n\treadonly data: BilibiliErrorData\n\treadonly type?: BilibiliApiErrorType\n\tconstructor({\n\t\tmessage,\n\t\tmsgCode,\n\t\trawData,\n\t\ttype,\n\t\tcause,\n\t}: BilibiliApiErrorDetails) {\n\t\tsuper(message, {\n\t\t\tvendor: 'Bilibili',\n\t\t\ttype,\n\t\t\tdata: {\n\t\t\t\trawData,\n\t\t\t\tmsgCode,\n\t\t\t},\n\t\t\tcause,\n\t\t})\n\t\tthis.data = {\n\t\t\trawData,\n\t\t\tmsgCode: msgCode ?? 0,\n\t\t}\n\t\tthis.type = type\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/errors/thirdparty/netease.ts",
    "content": "import { ThirdPartyError } from '@/lib/errors'\n\nexport type NeteaseApiErrorType =\n\t| 'RequestFailed'\n\t| 'ResponseFailed'\n\t| 'SearchResultNoMatch'\n\ninterface NeteaseApiErrorDetails {\n\tmessage: string\n\tmsgCode?: number\n\trawData?: unknown\n\ttype?: NeteaseApiErrorType\n\tcause?: unknown\n}\n\ninterface NeteaseErrorData {\n\tmsgCode: number\n\trawData: unknown\n}\n\nexport class NeteaseApiError extends ThirdPartyError {\n\treadonly data: NeteaseErrorData\n\treadonly type?: NeteaseApiErrorType\n\tconstructor({\n\t\tmessage,\n\t\tmsgCode,\n\t\trawData,\n\t\ttype,\n\t\tcause,\n\t}: NeteaseApiErrorDetails) {\n\t\tsuper(message, {\n\t\t\tvendor: 'Bilibili',\n\t\t\ttype,\n\t\t\tdata: {\n\t\t\t\trawData,\n\t\t\t\tmsgCode,\n\t\t\t},\n\t\t\tcause,\n\t\t})\n\t\tthis.data = {\n\t\t\trawData,\n\t\t\tmsgCode: msgCode ?? 0,\n\t\t}\n\t\tthis.type = type\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/facades/bilibili.ts",
    "content": "import { err, ok } from 'neverthrow'\n\nimport {\n\tbilibiliApi,\n\ttype bilibiliApi as BilibiliApiService,\n} from '@/lib/api/bilibili/api'\nimport { av2bv } from '@/lib/api/bilibili/utils'\nimport { createFacadeError } from '@/lib/errors/facade'\nimport type { Playlist } from '@/types/core/media'\n\nexport class BilibiliFacade {\n\tconstructor(private readonly bilibiliApi: typeof BilibiliApiService) {}\n\n\tpublic async fetchRemotePlaylistMetadata(\n\t\tremoteId: number,\n\t\ttype: Playlist['type'],\n\t) {\n\t\tswitch (type) {\n\t\t\tcase 'collection': {\n\t\t\t\tconst result = await this.bilibiliApi.getCollectionAllContents(remoteId)\n\t\t\t\tif (result.isErr()) {\n\t\t\t\t\treturn err(\n\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t'fetchRemotePlaylistMetadataFailed',\n\t\t\t\t\t\t\t'获取合集元数据失败',\n\t\t\t\t\t\t\t{ cause: result.error },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst metadata = result.value.info\n\t\t\t\treturn ok({\n\t\t\t\t\ttitle: metadata.title,\n\t\t\t\t\tdescription: metadata.intro,\n\t\t\t\t\tcoverUrl: metadata.cover,\n\t\t\t\t})\n\t\t\t}\n\t\t\tcase 'multi_page': {\n\t\t\t\tconst result = await this.bilibiliApi.getVideoDetails(av2bv(remoteId))\n\t\t\t\tif (result.isErr()) {\n\t\t\t\t\treturn err(\n\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t'fetchRemotePlaylistMetadataFailed',\n\t\t\t\t\t\t\t'获取多集视频元数据失败',\n\t\t\t\t\t\t\t{ cause: result.error },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst metadata = result.value\n\t\t\t\treturn ok({\n\t\t\t\t\ttitle: metadata.title,\n\t\t\t\t\tdescription: metadata.desc,\n\t\t\t\t\tcoverUrl: metadata.pic,\n\t\t\t\t})\n\t\t\t}\n\t\t\tcase 'favorite': {\n\t\t\t\tconst result = await this.bilibiliApi.getFavoriteListContents(\n\t\t\t\t\tremoteId,\n\t\t\t\t\t1,\n\t\t\t\t)\n\t\t\t\tif (result.isErr()) {\n\t\t\t\t\treturn err(\n\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t'fetchRemotePlaylistMetadataFailed',\n\t\t\t\t\t\t\t'获取收藏夹元数据失败',\n\t\t\t\t\t\t\t{ cause: result.error },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst metadata = result.value.info\n\t\t\t\tif (!metadata) {\n\t\t\t\t\treturn err(\n\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t'fetchRemotePlaylistMetadataFailed',\n\t\t\t\t\t\t\t'获取收藏夹元数据失败，数据为空，收藏夹可能不存在',\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn ok({\n\t\t\t\t\ttitle: metadata.title,\n\t\t\t\t\tdescription: metadata.intro,\n\t\t\t\t\tcoverUrl: metadata.cover,\n\t\t\t\t})\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn err(\n\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t'fetchRemotePlaylistMetadataFailed',\n\t\t\t\t\t\t`获取播放列表元数据失败：未知的播放列表类型：${type}`,\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t}\n\t}\n}\n\nexport const bilibiliFacade = new BilibiliFacade(bilibiliApi)\n"
  },
  {
    "path": "apps/mobile/src/lib/facades/playlist.ts",
    "content": "import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { ResultAsync, errAsync } from 'neverthrow'\n\nimport { api as bbplayerApi } from '@/lib/api/bbplayer/client'\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { createFacadeError } from '@/lib/errors/facade'\nimport { createValidationError } from '@/lib/errors/service'\nimport { artistService, type ArtistService } from '@/lib/services/artistService'\nimport {\n\tplaylistService,\n\ttype PlaylistService,\n} from '@/lib/services/playlistService'\nimport { trackService, type TrackService } from '@/lib/services/trackService'\nimport { playlistSyncWorker } from '@/lib/workers/PlaylistSyncWorker'\nimport type { CreateArtistPayload } from '@/types/services/artist'\nimport type {\n\tReorderLocalPlaylistTrackPayload,\n\tUpdatePlaylistPayload,\n} from '@/types/services/playlist'\nimport type { CreateTrackPayload } from '@/types/services/track'\nimport log from '@/utils/log'\n\ntype BbplayerApiClient = typeof bbplayerApi\n\nconst logger = log.extend('Facade')\n\nexport class PlaylistFacade {\n\tconstructor(\n\t\tprivate readonly trackService: TrackService,\n\t\tprivate readonly playlistService: PlaylistService,\n\t\tprivate readonly artistService: ArtistService,\n\t\tprivate readonly db: ExpoSQLiteDatabase<typeof schema>,\n\t\tprivate readonly bbplayerApi: BbplayerApiClient,\n\t) {}\n\n\t/**\n\t * 复制一份 playlist，新复制的 playlist 类型为 local，且 author&remoteSyncId 为 null\n\t * @param playlistId remote playlist 的 ID\n\t * @param name 新的 local playlist 的名称\n\t * @returns 如果成功，则为 local playlist 的 ID\n\t */\n\tpublic async duplicatePlaylist(playlistId: number, name: string) {\n\t\tlogger.info('开始复制播放列表', { playlistId, name })\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\n\t\t\t\tconst playlist = await playlistSvc.getPlaylistById(playlistId)\n\t\t\t\tif (playlist.isErr()) {\n\t\t\t\t\tthrow playlist.error\n\t\t\t\t}\n\t\t\t\tconst playlistMetadata = playlist.value\n\n\t\t\t\tif (!playlistMetadata)\n\t\t\t\t\tthrow createValidationError(`未找到播放列表：${playlistId}`)\n\n\t\t\t\tlogger.debug('step1: 获取播放列表', playlistMetadata.id)\n\n\t\t\t\tconst localPlaylistResult = await playlistSvc.createPlaylist({\n\t\t\t\t\ttitle: name,\n\t\t\t\t\tdescription: playlistMetadata.description,\n\t\t\t\t\tcoverUrl: playlistMetadata.coverUrl,\n\t\t\t\t\tauthorId: null,\n\t\t\t\t\ttype: 'local',\n\t\t\t\t\tremoteSyncId: null,\n\t\t\t\t})\n\t\t\t\tif (localPlaylistResult.isErr()) {\n\t\t\t\t\tthrow localPlaylistResult.error\n\t\t\t\t}\n\t\t\t\tconst localPlaylist = localPlaylistResult.value\n\t\t\t\tlogger.debug('step2: 创建本地播放列表', localPlaylist)\n\t\t\t\tlogger.info('创建本地播放列表成功', {\n\t\t\t\t\tlocalPlaylistId: localPlaylist.id,\n\t\t\t\t})\n\n\t\t\t\tconst tracksMetadata = await playlistSvc.getPlaylistTracks(playlistId)\n\t\t\t\tif (tracksMetadata.isErr()) {\n\t\t\t\t\tthrow tracksMetadata.error\n\t\t\t\t}\n\t\t\t\tconst finalIds = tracksMetadata.value\n\t\t\t\t\t.filter((t) => {\n\t\t\t\t\t\tif (t.source === 'bilibili' && !t.bilibiliMetadata.videoIsValid)\n\t\t\t\t\t\t\treturn false\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t\t\t.map((t) => t.id)\n\t\t\t\tlogger.debug(\n\t\t\t\t\t'step3: 获取播放列表中的所有歌曲并清洗完成（对于 bilibili 音频，去除掉失效视频）',\n\t\t\t\t)\n\n\t\t\t\tconst replaceResult = await playlistSvc.replacePlaylistAllTracks(\n\t\t\t\t\tlocalPlaylist.id,\n\t\t\t\t\tfinalIds,\n\t\t\t\t)\n\t\t\t\tif (replaceResult.isErr()) {\n\t\t\t\t\tthrow replaceResult.error\n\t\t\t\t}\n\t\t\t\tlogger.debug('step4: 替换本地播放列表中的所有歌曲')\n\t\t\t\tlogger.info('复制播放列表成功', {\n\t\t\t\t\tsourcePlaylistId: playlistId,\n\t\t\t\t\ttargetPlaylistId: localPlaylist.id,\n\t\t\t\t\ttrackCount: finalIds.length,\n\t\t\t\t})\n\n\t\t\t\treturn localPlaylist.id\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('PlaylistDuplicateFailed', '复制播放列表失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 创建动态合并歌单，读取时从源歌单实时展开并自动去重。\n\t */\n\tpublic async mergePlaylists(sourcePlaylistIds: number[], title: string) {\n\t\tlogger.info('开始创建动态合并播放列表', { sourcePlaylistIds, title })\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\n\t\t\t\tconst uniqueSourceIds = Array.from(new Set(sourcePlaylistIds))\n\t\t\t\tif (uniqueSourceIds.length < 2) {\n\t\t\t\t\tthrow createValidationError(\n\t\t\t\t\t\t'动态合并歌单需要选择至少两个不同的源歌单',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tfor (const playlistId of uniqueSourceIds) {\n\t\t\t\t\tconst playlist = await playlistSvc.getPlaylistById(playlistId)\n\t\t\t\t\tif (playlist.isErr()) throw playlist.error\n\t\t\t\t\tif (!playlist.value) {\n\t\t\t\t\t\tthrow createValidationError(`未找到播放列表：${playlistId}`)\n\t\t\t\t\t}\n\t\t\t\t\tif (playlist.value.type === 'dynamic') {\n\t\t\t\t\t\tthrow createValidationError('动态合并歌单不能作为源歌单')\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst newPlaylistRes = await playlistSvc.createPlaylist({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription: '动态合并歌单',\n\t\t\t\t\ttype: 'dynamic',\n\t\t\t\t\tauthorId: null,\n\t\t\t\t\tremoteSyncId: null,\n\t\t\t\t})\n\t\t\t\tif (newPlaylistRes.isErr()) throw newPlaylistRes.error\n\t\t\t\tconst newPlaylist = newPlaylistRes.value\n\n\t\t\t\tawait tx.insert(schema.dynamicPlaylistSources).values(\n\t\t\t\t\tuniqueSourceIds.map((sourcePlaylistId, position) => ({\n\t\t\t\t\t\tplaylistId: newPlaylist.id,\n\t\t\t\t\t\tsourcePlaylistId,\n\t\t\t\t\t\tposition,\n\t\t\t\t\t})),\n\t\t\t\t)\n\n\t\t\t\tlogger.info('创建动态合并播放列表成功', {\n\t\t\t\t\tsourcePlaylistIds: uniqueSourceIds,\n\t\t\t\t\tnewPlaylistId: newPlaylist.id,\n\t\t\t\t})\n\n\t\t\t\treturn newPlaylist.id\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('PlaylistMergeFailed', '创建动态合并播放列表失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 更新某个 Track 在本地播放列表中的归属。\n\t * - 如需要会自动创建 Artist，并把其 id 关联到 Track。\n\t * - 若 Track 不存在会自动创建。\n\t * @returns 更新后的 Track 的 ID\n\t */\n\tpublic async updateTrackLocalPlaylists(params: {\n\t\ttoAddPlaylistIds: number[]\n\t\ttoRemovePlaylistIds: number[]\n\t\ttrackPayload: CreateTrackPayload\n\t\tartistPayload?: CreateArtistPayload | null\n\t}) {\n\t\tconst {\n\t\t\ttoAddPlaylistIds,\n\t\t\ttoRemovePlaylistIds,\n\t\t\ttrackPayload,\n\t\t\tartistPayload,\n\t\t} = params\n\n\t\tlogger.info('开始更新 Track 在本地播放列表', {\n\t\t\ttoAdd: toAddPlaylistIds.length,\n\t\t\ttoRemove: toRemovePlaylistIds.length,\n\t\t\tsource: trackPayload.source,\n\t\t\ttitle: trackPayload.title,\n\t\t})\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\t\t\t\tconst trackSvc = this.trackService.withDB(tx)\n\t\t\t\tconst artistSvc = this.artistService.withDB(tx)\n\t\t\t\tconst targetPlaylists = new Map<\n\t\t\t\t\tnumber,\n\t\t\t\t\ttypeof schema.playlists.$inferSelect\n\t\t\t\t>()\n\n\t\t\t\t// step0: 权限校验（所有目标歌单中，共享歌单仅 owner/editor 可写）\n\t\t\t\tconst allTargetIds = [...toAddPlaylistIds, ...toRemovePlaylistIds]\n\t\t\t\tfor (const pid of allTargetIds) {\n\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\tconst res = await playlistSvc.getPlaylistById(pid)\n\t\t\t\t\tif (res.isErr()) throw res.error\n\t\t\t\t\tconst pl = res.value\n\t\t\t\t\tif (!pl)\n\t\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t\t`未找到播放列表：${pid}`,\n\t\t\t\t\t\t)\n\t\t\t\t\tif (\n\t\t\t\t\t\tpl.shareId &&\n\t\t\t\t\t\tpl.shareRole !== 'owner' &&\n\t\t\t\t\t\tpl.shareRole !== 'editor'\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t\t'无权限修改此共享歌单',\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\n\t\t\t\t\ttargetPlaylists.set(pid, pl)\n\t\t\t\t}\n\n\t\t\t\t// step1: 解析/创建 Artist（如需要）\n\t\t\t\tlet finalArtistId: number | undefined =\n\t\t\t\t\ttrackPayload.artistId ?? undefined\n\t\t\t\tif (finalArtistId === undefined && artistPayload) {\n\t\t\t\t\tconst artistIdRes = await artistSvc.findOrCreateArtist(artistPayload)\n\t\t\t\t\tif (artistIdRes.isErr()) throw artistIdRes.error\n\t\t\t\t\tfinalArtistId = artistIdRes.value.id\n\t\t\t\t}\n\t\t\t\tlogger.debug('step1: 解析/创建 Artist 完成', finalArtistId ?? '(无)')\n\n\t\t\t\t// step2: 解析/创建 Track\n\t\t\t\tconst trackRes = await trackSvc.findOrCreateTrack({\n\t\t\t\t\t...trackPayload,\n\t\t\t\t\tartistId: finalArtistId ?? undefined,\n\t\t\t\t})\n\t\t\t\tif (trackRes.isErr()) throw trackRes.error\n\t\t\t\tconst trackId = trackRes.value.id\n\t\t\t\tlogger.debug('step2: 解析/创建 Track 完成', trackId)\n\n\t\t\t\t// step3: 执行增删\n\t\t\t\tfor (const pid of toAddPlaylistIds) {\n\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\tconst r = await playlistSvc.addManyTracksToLocalPlaylist(pid, [\n\t\t\t\t\t\ttrackId,\n\t\t\t\t\t])\n\t\t\t\t\tif (r.isErr()) throw r.error\n\n\t\t\t\t\tconst target = targetPlaylists.get(pid)\n\t\t\t\t\tif (\n\t\t\t\t\t\ttarget?.shareId &&\n\t\t\t\t\t\t(target.shareRole === 'owner' || target.shareRole === 'editor')\n\t\t\t\t\t) {\n\t\t\t\t\t\tawait this.enqueueSync(tx, pid, 'add_tracks', {\n\t\t\t\t\t\t\ttrackIds: [trackId],\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor (const pid of toRemovePlaylistIds) {\n\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\tconst r = await playlistSvc.batchRemoveTracksFromLocalPlaylist(pid, [\n\t\t\t\t\t\ttrackId,\n\t\t\t\t\t])\n\t\t\t\t\tif (r.isErr()) throw r.error\n\n\t\t\t\t\tconst target = targetPlaylists.get(pid)\n\t\t\t\t\tif (\n\t\t\t\t\t\ttarget?.shareId &&\n\t\t\t\t\t\t(target.shareRole === 'owner' || target.shareRole === 'editor') &&\n\t\t\t\t\t\tr.value.removedTrackIds.length > 0\n\t\t\t\t\t) {\n\t\t\t\t\t\tawait this.enqueueSync(tx, pid, 'remove_tracks', {\n\t\t\t\t\t\t\tremovedTrackIds: r.value.removedTrackIds,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlogger.debug('step3: 更新本地播放列表完成', {\n\t\t\t\t\tadded: toAddPlaylistIds,\n\t\t\t\t\tremoved: toRemovePlaylistIds,\n\t\t\t\t})\n\n\t\t\t\tlogger.debug('更新 Track 在本地播放列表成功')\n\t\t\t\tlogger.info('更新 Track 在本地播放列表成功', {\n\t\t\t\t\ttrackId,\n\t\t\t\t\tadded: toAddPlaylistIds.length,\n\t\t\t\t\tremoved: toRemovePlaylistIds.length,\n\t\t\t\t})\n\t\t\t\treturn trackId\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'UpdateTrackLocalPlaylistsFailed',\n\t\t\t\t\t'更新 Track 在本地播放列表失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t).map((res) => {\n\t\t\tplaylistSyncWorker.triggerSync()\n\t\t\treturn res\n\t\t})\n\t}\n\n\t/**\n\t * 批量添加 tracks 到本地播放列表。\n\t * 若歌单参与共享（owner/editor），在同一事务内将操作写入 playlistSyncQueue。\n\t * @param playlistId\n\t * @param payloads 应包含 track 和 artist，**artist 只能为 remote 来源**\n\t */\n\tpublic async batchAddTracksToLocalPlaylist(\n\t\tplaylistId: number,\n\t\tpayloads: { track: CreateTrackPayload; artist: CreateArtistPayload }[],\n\t) {\n\t\tlogger.info('开始批量添加 tracks 到本地播放列表', {\n\t\t\tplaylistId,\n\t\t\tcount: payloads.length,\n\t\t})\n\t\tfor (const payload of payloads) {\n\t\t\tif (payload.artist.source === 'local') {\n\t\t\t\treturn errAsync(\n\t\t\t\t\tcreateValidationError(\n\t\t\t\t\t\t'批量添加 tracks 到本地播放列表时，artist 只能为 remote 来源',\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\t\t\t\tconst trackSvc = this.trackService.withDB(tx)\n\t\t\t\tconst artistSvc = this.artistService.withDB(tx)\n\n\t\t\t\t// step0: 权限校验（共享歌单仅 owner/editor 可写）\n\t\t\t\tconst playlistRes = await playlistSvc.getPlaylistById(playlistId)\n\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\tconst playlist = playlistRes.value\n\t\t\t\tif (!playlist)\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t`未找到播放列表：${playlistId}`,\n\t\t\t\t\t)\n\t\t\t\tconst { shareId, shareRole } = playlist\n\t\t\t\tif (shareId && shareRole !== 'owner' && shareRole !== 'editor') {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t'无权限修改此共享歌单',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst artistResult = await artistSvc.findOrCreateManyRemoteArtists(\n\t\t\t\t\tpayloads.map((p) => p.artist),\n\t\t\t\t)\n\t\t\t\tif (artistResult.isErr()) throw artistResult.error\n\t\t\t\tconst artistMap = artistResult.value\n\t\t\t\tlogger.debug('step1: 批量创建 artist 完成')\n\n\t\t\t\tconst trackResult = await trackSvc.findOrCreateManyTracks(\n\t\t\t\t\tpayloads.map((p) => ({\n\t\t\t\t\t\t...p.track,\n\t\t\t\t\t\tartistId: artistMap.get(p.artist.remoteId!)?.id,\n\t\t\t\t\t})),\n\t\t\t\t\t'bilibili',\n\t\t\t\t)\n\t\t\t\tif (trackResult.isErr()) throw trackResult.error\n\t\t\t\tconst trackIds = Array.from(trackResult.value.values())\n\t\t\t\tlogger.debug('step2: 批量创建 track 完成')\n\n\t\t\t\tconst addResult = await playlistSvc.addManyTracksToLocalPlaylist(\n\t\t\t\t\tplaylistId,\n\t\t\t\t\ttrackIds,\n\t\t\t\t)\n\t\t\t\tif (addResult.isErr()) throw addResult.error\n\t\t\t\tlogger.debug('step3: 批量将 track 添加到本地播放列表完成')\n\n\t\t\t\t// 若为共享歌单（owner/editor），在同一事务内入队\n\t\t\t\tif (shareId && (shareRole === 'owner' || shareRole === 'editor')) {\n\t\t\t\t\tawait this.enqueueSync(tx, playlistId, 'add_tracks', { trackIds })\n\t\t\t\t}\n\n\t\t\t\tlogger.info('批量添加 tracks 到本地播放列表成功', {\n\t\t\t\t\tplaylistId,\n\t\t\t\t\tadded: trackIds.length,\n\t\t\t\t})\n\t\t\t\treturn trackIds\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'BatchAddTracksToLocalPlaylistFailed',\n\t\t\t\t\t'批量添加 tracks 到本地播放列表失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t).map((res) => {\n\t\t\tplaylistSyncWorker.triggerSync()\n\t\t\treturn res\n\t\t})\n\t}\n\n\t/**\n\t * 将播放队列保存为新的播放列表\n\t * @param name 播放列表名称\n\t * @param uniqueKeys 队列中的 track uniqueKeys\n\t */\n\tpublic async saveQueueAsPlaylist(name: string, uniqueKeys: string[]) {\n\t\tlogger.info('开始将队列保存为播放列表', {\n\t\t\tname,\n\t\t\ttrackCount: uniqueKeys.length,\n\t\t})\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\t\t\t\tconst trackSvc = this.trackService.withDB(tx)\n\n\t\t\t\t// 1. 创建播放列表\n\t\t\t\tconst playlistRes = await playlistSvc.createPlaylist({\n\t\t\t\t\ttitle: name,\n\t\t\t\t\ttype: 'local',\n\t\t\t\t\tauthorId: null,\n\t\t\t\t\tremoteSyncId: null,\n\t\t\t\t})\n\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\tconst playlist = playlistRes.value\n\n\t\t\t\t// 2. 验证所有 tracks 在本地存在，并获取 ID\n\t\t\t\tconst distinctKeys = Array.from(new Set(uniqueKeys))\n\n\t\t\t\tconst findRes = await trackSvc.findTrackIdsByUniqueKeys(distinctKeys)\n\t\t\t\tif (findRes.isErr()) throw findRes.error\n\t\t\t\tconst foundMap = findRes.value\n\n\t\t\t\tconst trackIds: number[] = []\n\t\t\t\tfor (const key of distinctKeys) {\n\t\t\t\t\tconst id = foundMap.get(key)\n\t\t\t\t\tif (id === undefined) {\n\t\t\t\t\t\t// 理论上不应该发生，因为进入播放队列的歌曲必须在本地 DB 有记录\n\t\t\t\t\t\tlogger.error(`保存队列时发现缺失的 track: ${key}`)\n\t\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t\t'PlaylistCreateFailed',\n\t\t\t\t\t\t\t`无法保存队列，发现未入库的歌曲 (ID: ${key})，请向开发者反馈`,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\ttrackIds.push(id)\n\t\t\t\t}\n\n\t\t\t\t// 3. 批量添加到播放列表\n\t\t\t\tif (trackIds.length > 0) {\n\t\t\t\t\tconst addRes = await playlistSvc.addManyTracksToLocalPlaylist(\n\t\t\t\t\t\tplaylist.id,\n\t\t\t\t\t\ttrackIds,\n\t\t\t\t\t)\n\t\t\t\t\tif (addRes.isErr()) throw addRes.error\n\t\t\t\t}\n\n\t\t\t\treturn playlist.id\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('PlaylistCreateFailed', '保存队列为播放列表失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 从本地播放列表批量移除曲目。\n\t * 若歌单参与共享（owner/editor），在同一事务内将操作写入 playlistSyncQueue。\n\t */\n\tpublic async removeTracksFromPlaylist(\n\t\tplaylistId: number,\n\t\ttrackIds: number[],\n\t) {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\n\t\t\t\t// 权限校验（共享歌单仅 owner/editor 可写）\n\t\t\t\tconst playlistRes = await playlistSvc.getPlaylistById(playlistId)\n\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\tconst playlist = playlistRes.value\n\t\t\t\tif (!playlist)\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t`未找到播放列表：${playlistId}`,\n\t\t\t\t\t)\n\t\t\t\tconst { shareId, shareRole } = playlist\n\t\t\t\tif (shareId && shareRole !== 'owner' && shareRole !== 'editor') {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t'无权限修改此共享歌单',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst result = await playlistSvc.batchRemoveTracksFromLocalPlaylist(\n\t\t\t\t\tplaylistId,\n\t\t\t\t\ttrackIds,\n\t\t\t\t)\n\t\t\t\tif (result.isErr()) throw result.error\n\n\t\t\t\t// 若为共享歌单（owner/editor），在同一事务内入队\n\t\t\t\tif (shareId && (shareRole === 'owner' || shareRole === 'editor')) {\n\t\t\t\t\tawait this.enqueueSync(tx, playlistId, 'remove_tracks', {\n\t\t\t\t\t\tremovedTrackIds: result.value.removedTrackIds,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn result.value\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'RemoveTracksFromPlaylistFailed',\n\t\t\t\t\t'从播放列表移除曲目失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t).map((res) => {\n\t\t\tplaylistSyncWorker.triggerSync()\n\t\t\treturn res\n\t\t})\n\t}\n\n\t/**\n\t * 调整本地播放列表中单曲的位置。\n\t * 若歌单参与共享（owner/editor），在同一事务内将操作写入 playlistSyncQueue。\n\t */\n\tpublic async reorderLocalPlaylistTrack(\n\t\tplaylistId: number,\n\t\tpayload: ReorderLocalPlaylistTrackPayload,\n\t) {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\n\t\t\t\t// 权限校验（共享歌单仅 owner/editor 可写）\n\t\t\t\tconst playlistRes = await playlistSvc.getPlaylistById(playlistId)\n\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\tconst playlist = playlistRes.value\n\t\t\t\tif (!playlist)\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t`未找到播放列表：${playlistId}`,\n\t\t\t\t\t)\n\t\t\t\tconst { shareId, shareRole } = playlist\n\t\t\t\tif (shareId && shareRole !== 'owner' && shareRole !== 'editor') {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t'无权限修改此共享歌单',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst result = await playlistSvc.reorderSingleLocalPlaylistTrack(\n\t\t\t\t\tplaylistId,\n\t\t\t\t\tpayload,\n\t\t\t\t)\n\t\t\t\tif (result.isErr()) throw result.error\n\n\t\t\t\t// 若为共享歌单（owner/editor），在同一事务内入队\n\t\t\t\tif (shareId && (shareRole === 'owner' || shareRole === 'editor')) {\n\t\t\t\t\tawait this.enqueueSync(tx, playlistId, 'reorder_track', {\n\t\t\t\t\t\ttrackId: payload.trackId,\n\t\t\t\t\t\tprevSortKey: payload.prevSortKey,\n\t\t\t\t\t\tnextSortKey: payload.nextSortKey,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn result.value\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'ReorderPlaylistTrackFailed',\n\t\t\t\t\t'调整播放列表曲目顺序失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t).map((res) => {\n\t\t\tplaylistSyncWorker.triggerSync()\n\t\t\treturn res\n\t\t})\n\t}\n\n\t/**\n\t * 更新播放列表元数据（标题/描述/封面）。\n\t * 若歌单参与共享（owner/editor），在同一事务内将操作写入 playlistSyncQueue。\n\t */\n\tpublic async updatePlaylistMetadata(\n\t\tplaylistId: number,\n\t\tpayload: UpdatePlaylistPayload,\n\t) {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\n\t\t\t\t// 权限校验（共享歌单仅 owner/editor 可写）\n\t\t\t\tconst playlistRes = await playlistSvc.getPlaylistById(playlistId)\n\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\tconst playlist = playlistRes.value\n\t\t\t\tif (!playlist)\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t`未找到播放列表：${playlistId}`,\n\t\t\t\t\t)\n\t\t\t\tconst { shareId, shareRole } = playlist\n\t\t\t\tif (shareId && shareRole !== 'owner' && shareRole !== 'editor') {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'PlaylistPermissionDenied',\n\t\t\t\t\t\t'无权限修改此共享歌单',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst nextTitle = payload.title ?? playlist.title\n\t\t\t\tconst nextDescription =\n\t\t\t\t\tpayload.description ?? playlist.description ?? null\n\t\t\t\tconst nextCoverUrl = payload.coverUrl ?? playlist.coverUrl ?? null\n\n\t\t\t\tconst result = await playlistSvc.updatePlaylistMetadata(\n\t\t\t\t\tplaylistId,\n\t\t\t\t\tpayload,\n\t\t\t\t)\n\t\t\t\tif (result.isErr()) throw result.error\n\n\t\t\t\t// 若为共享歌单（owner/editor），在同一事务内入队\n\t\t\t\tif (shareId && (shareRole === 'owner' || shareRole === 'editor')) {\n\t\t\t\t\tawait this.enqueueSync(tx, playlistId, 'update_metadata', {\n\t\t\t\t\t\ttitle: nextTitle,\n\t\t\t\t\t\tdescription: nextDescription,\n\t\t\t\t\t\tcoverUrl: nextCoverUrl,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn result.value\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'UpdatePlaylistMetadataFailed',\n\t\t\t\t\t'更新播放列表元数据失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t).map((res) => {\n\t\t\tplaylistSyncWorker.triggerSync()\n\t\t\treturn res\n\t\t})\n\t}\n\n\t/**\n\t * 删除播放列表，按 shareRole 路由到不同的删除策略：\n\t * - local（无 shareId）：直接删除本地数据\n\t * - subscriber：服务端解除成员关系 + 删除本地副本\n\t * - editor：服务端解除成员关系 + 清空 shareId/shareRole（保留本地数据转为普通 local 歌单）\n\t * - owner：服务端软删除共享歌单 + 删除本地副本\n\t */\n\tpublic async deletePlaylist(playlistId: number) {\n\t\tconst playlistRes = await this.playlistService.getPlaylistById(playlistId)\n\t\tif (playlistRes.isErr()) {\n\t\t\treturn errAsync(\n\t\t\t\tcreateFacadeError('PlaylistDeleteFailed', '读取播放列表失败', {\n\t\t\t\t\tcause: playlistRes.error,\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\t\tconst playlist = playlistRes.value\n\t\tif (!playlist) {\n\t\t\treturn errAsync(\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'PlaylistDeleteFailed',\n\t\t\t\t\t`未找到播放列表：${playlistId}`,\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\tconst { shareId, shareRole } = playlist\n\n\t\t// local 直接删除本地，无需鉴权或网络请求\n\t\tif (!shareId) {\n\t\t\treturn this.playlistService\n\t\t\t\t.deletePlaylist(playlistId)\n\t\t\t\t.map(() => undefined)\n\t\t\t\t.mapErr((e) =>\n\t\t\t\t\tcreateFacadeError('PlaylistDeleteFailed', '删除播放列表失败', {\n\t\t\t\t\t\tcause: e,\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tif (shareRole === 'owner') {\n\t\t\t\t\t// owner：服务端软删除，然后删除本地副本\n\t\t\t\t\tconst resp = await this.bbplayerApi.playlists[':id'].$delete({\n\t\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t\t})\n\t\t\t\t\tif (!resp.ok && resp.status !== 404 && resp.status !== 403) {\n\t\t\t\t\t\tconst body = await resp.json().catch(() => ({}))\n\t\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t\t'PlaylistDeleteFailed',\n\t\t\t\t\t\t\t`删除共享歌单失败（${resp.status}）`,\n\t\t\t\t\t\t\t{ cause: body },\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tif (resp.status === 404 || resp.status === 403) {\n\t\t\t\t\t\tlogger.warning('远端歌单已不存在或权限丢失，跳过云端删除', {\n\t\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\t\tshareId,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tconst deleteRes =\n\t\t\t\t\t\tawait this.playlistService.deletePlaylist(playlistId)\n\t\t\t\t\tif (deleteRes.isErr()) throw deleteRes.error\n\t\t\t\t} else if (shareRole === 'subscriber' || shareRole === 'editor') {\n\t\t\t\t\t// subscriber/editor：服务端解除成员关系\n\t\t\t\t\tconst resp = await this.bbplayerApi.playlists[\n\t\t\t\t\t\t':id'\n\t\t\t\t\t].members.me.$delete({\n\t\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t\t})\n\t\t\t\t\tif (!resp.ok && resp.status !== 404 && resp.status !== 403) {\n\t\t\t\t\t\tconst body = await resp.json().catch(() => ({}))\n\t\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t\t'PlaylistDeleteFailed',\n\t\t\t\t\t\t\t`解除共享歌单关联失败（${resp.status}）`,\n\t\t\t\t\t\t\t{ cause: body },\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tif (resp.status === 404 || resp.status === 403) {\n\t\t\t\t\t\tlogger.warning(\n\t\t\t\t\t\t\t'远端歌单已被删除或已解除成员关系，继续清理本地关联',\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\t\t\tshareId,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\n\t\t\t\t\tawait this.db.transaction(async (tx) => {\n\t\t\t\t\t\tconst txPlaylist = this.playlistService.withDB(tx)\n\t\t\t\t\t\tif (shareRole === 'subscriber') {\n\t\t\t\t\t\t\t// subscriber：删除本地副本\n\t\t\t\t\t\t\tconst r = await txPlaylist.deletePlaylist(playlistId)\n\t\t\t\t\t\t\tif (r.isErr()) throw r.error\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// editor：保留本地数据，仅断开共享连接\n\t\t\t\t\t\t\tconst r = await txPlaylist.updatePlaylistMetadata(playlistId, {\n\t\t\t\t\t\t\t\tshareId: null,\n\t\t\t\t\t\t\t\tshareRole: null,\n\t\t\t\t\t\t\t\tlastShareSyncAt: null,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif (r.isErr()) throw r.error\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('PlaylistDeleteFailed', '删除播放列表失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 向 playlist_sync_queue 写入一条待同步记录（在调用方的事务内执行）。\n\t * operationAt 记录用户真正执行操作的时间（LWW 基准）。\n\t */\n\tprivate async enqueueSync(\n\t\tdb: ExpoSQLiteDatabase<typeof schema>,\n\t\tplaylistId: number,\n\t\toperation: (typeof schema.playlistSyncQueue.$inferInsert)['operation'],\n\t\tpayload: Record<string, unknown>,\n\t): Promise<void> {\n\t\tawait db.insert(schema.playlistSyncQueue).values({\n\t\t\tplaylistId,\n\t\t\toperation,\n\t\t\tpayload: JSON.stringify(payload),\n\t\t\toperationAt: new Date(Date.now()),\n\t\t})\n\t}\n}\n\nexport const playlistFacade = new PlaylistFacade(\n\ttrackService,\n\tplaylistService,\n\tartistService,\n\tdb,\n\tbbplayerApi,\n)\n"
  },
  {
    "path": "apps/mobile/src/lib/facades/sharedPlaylist.ts",
    "content": "/**\n * SharedPlaylistFacade — 歌单云同步协调层\n *\n * 职责：协调后端 API 调用与本地 SQLite 写入，处理共享歌单生命周期：\n *   - enableSharing      本地歌单 → 共享歌单（上传初始曲目，保存 shareId）\n *   - subscribeToPlaylist 通过分享链接订阅歌单（创建本地副本 + 全量拉取）\n *   - restoreFromCloud   换设备后从云端恢复参与的所有歌单\n *   - pullChanges        增量拉取单个歌单的最新变更并应用到本地 DB\n *   - unsubscribeFromPlaylist 解除订阅/断开共享连接\n */\nimport { and, eq, inArray, sql } from 'drizzle-orm'\nimport type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { ResultAsync } from 'neverthrow'\n\nimport {\n\tsetSharedPlaylistMembers,\n\tclearSharedPlaylistMembers,\n} from '@/hooks/stores/useSharedPlaylistMembersStore'\nimport { api as bbplayerApiClient } from '@/lib/api/bbplayer/client'\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { FacadeError, createFacadeError } from '@/lib/errors/facade'\nimport { createValidationError } from '@/lib/errors/service'\nimport { artistService, type ArtistService } from '@/lib/services/artistService'\nimport {\n\tplaylistService,\n\ttype PlaylistService,\n} from '@/lib/services/playlistService'\nimport { trackService, type TrackService } from '@/lib/services/trackService'\nimport log from '@/utils/log'\n\ntype Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]\n\nconst logger = log.extend('SharedPlaylistFacade')\n\nexport interface SharedPlaylistPreview {\n\tplaylist: {\n\t\tid: string\n\t\ttitle: string\n\t\tdescription: string | null\n\t\tcoverUrl: string | null\n\t\tcreatedAt: Date\n\t\tupdatedAt: Date\n\t\ttrackCount: number\n\t}\n\towner: {\n\t\tmid: number\n\t\tname: string\n\t\tavatarUrl?: string | null\n\t} | null\n\ttracks: Array<{\n\t\tunique_key: string\n\t\ttitle: string\n\t\tartist_name?: string\n\t\tartist_id?: string\n\t\tcover_url?: string\n\t\tduration?: number\n\t\tbilibili_bvid: string\n\t\tbilibili_cid?: string\n\t\tsort_key: string\n\t}>\n\tpreviewLimit: number\n}\n\nexport class SharedPlaylistFacade {\n\tconstructor(\n\t\tprivate readonly db: ExpoSQLiteDatabase<typeof schema>,\n\t\tprivate readonly playlistService: PlaylistService,\n\t\tprivate readonly trackService: TrackService,\n\t\tprivate readonly artistService: ArtistService,\n\t\tprivate readonly api: typeof bbplayerApiClient,\n\t) {}\n\n\t// ---------------------------------------------------------------------------\n\t// enableSharing — 将本地歌单升级为共享歌单\n\t// ---------------------------------------------------------------------------\n\t/**\n\t * 将一个现有的本地歌单发布为共享歌单。\n\t * 步骤：读取本地曲目 → POST /api/playlists → 将 shareId 写回本地。\n\t * @param localPlaylistId 本地歌单 ID\n\t */\n\tpublic enableSharing(\n\t\tlocalPlaylistId: number,\n\t): ResultAsync<{ shareId: string }, ReturnType<typeof createFacadeError>> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 1. 读取本地歌单元数据\n\t\t\t\tconst playlistResult =\n\t\t\t\t\tawait this.playlistService.getPlaylistById(localPlaylistId)\n\t\t\t\tif (playlistResult.isErr()) throw playlistResult.error\n\t\t\t\tconst playlist = playlistResult.value\n\t\t\t\tif (!playlist) {\n\t\t\t\t\tthrow createValidationError(`找不到歌单：${localPlaylistId}`)\n\t\t\t\t}\n\t\t\t\tif (playlist.shareId) {\n\t\t\t\t\t// 已经是共享歌单，直接返回\n\t\t\t\t\treturn { shareId: playlist.shareId }\n\t\t\t\t}\n\n\t\t\t\t// 2. 读取曲目及其 sort_key（直接联表查询，preserving fractional sort_key）\n\t\t\t\tconst trackLinks = await this.db\n\t\t\t\t\t.select({\n\t\t\t\t\t\tsortKey: schema.playlistTracks.sortKey,\n\t\t\t\t\t\tuniqueKey: schema.tracks.uniqueKey,\n\t\t\t\t\t\ttitle: schema.tracks.title,\n\t\t\t\t\t\tcoverUrl: schema.tracks.coverUrl,\n\t\t\t\t\t\tduration: schema.tracks.duration,\n\t\t\t\t\t\tartistName: schema.artists.name,\n\t\t\t\t\t\tartistRemoteId: schema.artists.remoteId,\n\t\t\t\t\t\tbvid: schema.bilibiliMetadata.bvid,\n\t\t\t\t\t\tcid: schema.bilibiliMetadata.cid,\n\t\t\t\t\t})\n\t\t\t\t\t.from(schema.playlistTracks)\n\t\t\t\t\t.innerJoin(\n\t\t\t\t\t\tschema.tracks,\n\t\t\t\t\t\teq(schema.playlistTracks.trackId, schema.tracks.id),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.artists,\n\t\t\t\t\t\teq(schema.tracks.artistId, schema.artists.id),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.bilibiliMetadata,\n\t\t\t\t\t\teq(schema.playlistTracks.trackId, schema.bilibiliMetadata.trackId),\n\t\t\t\t\t)\n\t\t\t\t\t.where(\n\t\t\t\t\t\tand(\n\t\t\t\t\t\t\teq(schema.playlistTracks.playlistId, localPlaylistId),\n\t\t\t\t\t\t\teq(schema.tracks.source, 'bilibili'),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\tlogger.debug('enableSharing: 读取曲目', trackLinks.length)\n\n\t\t\t\t// 3. 构造初始曲目上传格式\n\t\t\t\tconst initialTracks = trackLinks\n\t\t\t\t\t.filter((t) => t.bvid)\n\t\t\t\t\t.map((t) => ({\n\t\t\t\t\t\ttrack: {\n\t\t\t\t\t\t\tunique_key: t.uniqueKey,\n\t\t\t\t\t\t\ttitle: t.title,\n\t\t\t\t\t\t\tcover_url: t.coverUrl ?? undefined,\n\t\t\t\t\t\t\tduration: t.duration ?? undefined,\n\t\t\t\t\t\t\tartist_name: t.artistName ?? undefined,\n\t\t\t\t\t\t\tartist_id: t.artistRemoteId ?? undefined,\n\t\t\t\t\t\t\tbilibili_bvid: t.bvid!,\n\t\t\t\t\t\t\tbilibili_cid: t.cid ? String(t.cid) : undefined,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsort_key: t.sortKey,\n\t\t\t\t\t}))\n\n\t\t\t\t// 4. POST /api/playlists\n\t\t\t\tconst resp = await this.api.playlists.$post({\n\t\t\t\t\tjson: {\n\t\t\t\t\t\ttitle: playlist.title,\n\t\t\t\t\t\tdescription: playlist.description ?? undefined,\n\t\t\t\t\t\tcover_url: playlist.coverUrl ?? undefined,\n\t\t\t\t\t\ttracks: initialTracks,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t\tif (!resp.ok) {\n\t\t\t\t\tconst errBody = await resp.json().catch(() => ({}))\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistEnableFailed',\n\t\t\t\t\t\t`创建共享歌单失败：${resp.status}`,\n\t\t\t\t\t\t{ cause: errBody },\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst { playlist: remotePlaylist } = await resp.json()\n\t\t\t\tconst shareId: string = remotePlaylist.id\n\t\t\t\tconst parsed = new Date(remotePlaylist.updatedAt).getTime()\n\t\t\t\tconst serverTime = Number.isFinite(parsed) ? parsed : Date.now()\n\t\t\t\tawait this.db.transaction(async (tx) => {\n\t\t\t\t\tconst txPlaylist = this.playlistService.withDB(tx)\n\t\t\t\t\tconst updateResult = await txPlaylist.updatePlaylistMetadata(\n\t\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tshareId,\n\t\t\t\t\t\t\tshareRole: 'owner',\n\t\t\t\t\t\t\tlastShareSyncAt: serverTime,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\t\t\t\t\tif (updateResult.isErr()) throw updateResult.error\n\t\t\t\t})\n\n\t\t\t\tlogger.info('enableSharing 完成', { localPlaylistId, shareId })\n\t\t\t\treturn { shareId }\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('SharedPlaylistEnableFailed', '启用共享歌单失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// subscribeToPlaylist — 通过分享链接订阅共享歌单\n\t// ---------------------------------------------------------------------------\n\t/**\n\t * 通过 shareId（分享链接中的 UUID）订阅一个共享歌单。\n\t * 步骤：POST subscribe → 创建本地歌单行 → 全量拉取（since=0）。\n\t * @param shareId 后端共享歌单 UUID\n\t */\n\tpublic subscribeToPlaylist(params: {\n\t\tshareId: string\n\t\tinviteCode?: string\n\t}): ResultAsync<\n\t\t{ localPlaylistId: number },\n\t\tReturnType<typeof createFacadeError>\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst { shareId, inviteCode } = params\n\t\t\t\t// 1. 检查是否已有本地副本\n\t\t\t\tconst existing =\n\t\t\t\t\tawait this.playlistService.findPlaylistByShareId(shareId)\n\t\t\t\tif (existing.isErr()) throw existing.error\n\t\t\t\tif (existing.isOk() && existing.value) {\n\t\t\t\t\tlogger.info('subscribeToPlaylist: 已存在本地副本', {\n\t\t\t\t\t\tshareId,\n\t\t\t\t\t\tid: existing.value.id,\n\t\t\t\t\t})\n\t\t\t\t\treturn { localPlaylistId: existing.value.id }\n\t\t\t\t}\n\n\t\t\t\t// 2. 通知后端订阅\n\t\t\t\tconst subResp = await this.api.playlists[':id'].subscribe.$post({\n\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t\tjson: inviteCode ? { invite_code: inviteCode } : {},\n\t\t\t\t})\n\t\t\t\tif (!subResp.ok && subResp.status !== 201) {\n\t\t\t\t\tconst errBody = await subResp.json().catch(() => ({}))\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistSubscribeFailed',\n\t\t\t\t\t\t`订阅歌单失败：${subResp.status}`,\n\t\t\t\t\t\t{ cause: errBody },\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst subData = await subResp.json()\n\t\t\t\tconst role = (subData as { role: string }).role as\n\t\t\t\t\t| 'owner'\n\t\t\t\t\t| 'editor'\n\t\t\t\t\t| 'subscriber'\n\n\t\t\t\t// 3. 从后端获取元数据（通过 since=0 拿到 metadata 字段）\n\t\t\t\tconst changesResp = await this.api.playlists[':id'].changes.$get({\n\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t\tquery: { since: '0' },\n\t\t\t\t})\n\t\t\t\tif (!changesResp.ok) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistSubscribeFailed',\n\t\t\t\t\t\t`拉取歌单初始数据失败`,\n\t\t\t\t\t\t{ cause: await changesResp.json().catch(() => ({})) },\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst changesData = await changesResp.json()\n\t\t\t\tconst meta = (\n\t\t\t\t\tchangesData as {\n\t\t\t\t\t\tmetadata: {\n\t\t\t\t\t\t\ttitle?: string\n\t\t\t\t\t\t\tdescription?: string\n\t\t\t\t\t\t\tcover_url?: string\n\t\t\t\t\t\t} | null\n\t\t\t\t\t}\n\t\t\t\t).metadata\n\n\t\t\t\tconst serverTime = (changesData as { server_time: number }).server_time\n\n\t\t\t\t// 4. 事务：创建本地歌单行 + 应用初始曲目 + 更新同步游标（原子）\n\t\t\t\tconst localPlaylistId = await this.db.transaction(async (tx) => {\n\t\t\t\t\tconst txPlaylist = this.playlistService.withDB(tx)\n\t\t\t\t\tconst txTrack = this.trackService.withDB(tx)\n\t\t\t\t\tconst txArtist = this.artistService.withDB(tx)\n\n\t\t\t\t\tconst createResult = await txPlaylist.createPlaylist({\n\t\t\t\t\t\ttitle: meta?.title ?? '共享歌单',\n\t\t\t\t\t\tdescription: meta?.description ?? null,\n\t\t\t\t\t\tcoverUrl: meta?.cover_url ?? null,\n\t\t\t\t\t\ttype: 'local',\n\t\t\t\t\t\tshareId,\n\t\t\t\t\t\tshareRole: role,\n\t\t\t\t\t\tlastShareSyncAt: 0,\n\t\t\t\t\t})\n\t\t\t\t\tif (createResult.isErr()) throw createResult.error\n\t\t\t\t\tconst id = createResult.value.id\n\n\t\t\t\t\tawait this._applyPullResponse(id, shareId, changesData, tx, {\n\t\t\t\t\t\tplaylistService: txPlaylist,\n\t\t\t\t\t\ttrackService: txTrack,\n\t\t\t\t\t\tartistService: txArtist,\n\t\t\t\t\t})\n\n\t\t\t\t\tconst syncResult = await txPlaylist.updatePlaylistMetadata(id, {\n\t\t\t\t\t\tlastShareSyncAt: serverTime,\n\t\t\t\t\t})\n\t\t\t\t\tif (syncResult.isErr()) throw syncResult.error\n\n\t\t\t\t\treturn id\n\t\t\t\t})\n\n\t\t\t\tlogger.info('subscribeToPlaylist 完成', { shareId, localPlaylistId })\n\t\t\t\treturn { localPlaylistId }\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('SharedPlaylistSubscribeFailed', '订阅共享歌单失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\tpublic getPreview(\n\t\tshareId: string,\n\t): ResultAsync<SharedPlaylistPreview, ReturnType<typeof createFacadeError>> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst resp = await this.api.playlists[':id'].preview.$get({\n\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t})\n\t\t\t\tif (resp.status === 404) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistNotFound',\n\t\t\t\t\t\t'共享歌单不存在或已删除',\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (!resp.ok) {\n\t\t\t\t\tconst errBody = await resp.json().catch(() => ({}))\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistPreviewFailed',\n\t\t\t\t\t\t`获取共享歌单预览失败：${resp.status}`,\n\t\t\t\t\t\t{ cause: errBody },\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst data = (await resp.json()) as {\n\t\t\t\t\tplaylist: {\n\t\t\t\t\t\tid: string\n\t\t\t\t\t\ttitle: string\n\t\t\t\t\t\tdescription?: string | null\n\t\t\t\t\t\tcover_url?: string | null\n\t\t\t\t\t\tcreated_at: number\n\t\t\t\t\t\tupdated_at: number\n\t\t\t\t\t\ttrack_count: number\n\t\t\t\t\t}\n\t\t\t\t\towner: {\n\t\t\t\t\t\tmid: number\n\t\t\t\t\t\tname: string\n\t\t\t\t\t\tavatar_url?: string | null\n\t\t\t\t\t} | null\n\t\t\t\t\ttracks: SharedPlaylistPreview['tracks']\n\t\t\t\t\tpreview_limit: number\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tplaylist: {\n\t\t\t\t\t\tid: data.playlist.id,\n\t\t\t\t\t\ttitle: data.playlist.title,\n\t\t\t\t\t\tdescription: data.playlist.description ?? null,\n\t\t\t\t\t\tcoverUrl: data.playlist.cover_url ?? null,\n\t\t\t\t\t\tcreatedAt: new Date(data.playlist.created_at),\n\t\t\t\t\t\tupdatedAt: new Date(data.playlist.updated_at),\n\t\t\t\t\t\ttrackCount: Number(data.playlist.track_count ?? 0),\n\t\t\t\t\t},\n\t\t\t\t\towner: data.owner\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\tmid: data.owner.mid,\n\t\t\t\t\t\t\t\tname: data.owner.name,\n\t\t\t\t\t\t\t\tavatarUrl: data.owner.avatar_url ?? null,\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: null,\n\t\t\t\t\ttracks: data.tracks,\n\t\t\t\t\tpreviewLimit: data.preview_limit,\n\t\t\t\t}\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'SharedPlaylistPreviewFailed',\n\t\t\t\t\t'获取共享歌单预览失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t)\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// restoreFromCloud — 换设备后从云端恢复所有参与歌单\n\t// ---------------------------------------------------------------------------\n\t/**\n\t * 登录后调用：拉取用户参与的所有共享歌单，与本地对比，补全缺失的歌单行。\n\t */\n\tpublic restoreFromCloud(): ResultAsync<\n\t\t{ restored: number },\n\t\tReturnType<typeof createFacadeError>\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 1. 获取云端歌单列表\n\t\t\t\tconst resp = await this.api.me.playlists.$get()\n\t\t\t\tif (!resp.ok) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistRestoreFailed',\n\t\t\t\t\t\t`获取云端歌单列表失败：${resp.status}`,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst { playlists: remotePlaylists } = (await resp.json()) as {\n\t\t\t\t\tplaylists: Array<{\n\t\t\t\t\t\tid: string\n\t\t\t\t\t\ttitle: string\n\t\t\t\t\t\tdescription: string | null\n\t\t\t\t\t\tcoverUrl: string | null\n\t\t\t\t\t\trole: 'owner' | 'editor' | 'subscriber'\n\t\t\t\t\t}>\n\t\t\t\t}\n\n\t\t\t\t// 2. 获取本地已存在的 shareId 集合\n\t\t\t\tconst localSharedResult =\n\t\t\t\t\tawait this.playlistService.getSharedPlaylists()\n\t\t\t\tif (localSharedResult.isErr()) throw localSharedResult.error\n\t\t\t\tconst localShareIds = new Set(\n\t\t\t\t\tlocalSharedResult.value.map((p) => p.shareId).filter(Boolean),\n\t\t\t\t)\n\n\t\t\t\t// 3. 差异对比 → 只处理本地缺失的歌单\n\t\t\t\tconst missing = remotePlaylists.filter(\n\t\t\t\t\t(rp) => !localShareIds.has(rp.id),\n\t\t\t\t)\n\t\t\t\tlogger.info('restoreFromCloud: 需恢复的歌单数', missing.length)\n\n\t\t\t\tlet restored = 0\n\t\t\t\tfor (const remote of missing) {\n\t\t\t\t\t// 全量拉取（since=0）— API 调用在事务外\n\t\t\t\t\tconst changesResp = await this.api.playlists[':id'].changes.$get({\n\t\t\t\t\t\tparam: { id: remote.id },\n\t\t\t\t\t\tquery: { since: '0' },\n\t\t\t\t\t})\n\t\t\t\t\tif (!changesResp.ok) {\n\t\t\t\t\t\tlogger.error('恢复歌单：拉取初始数据失败', { shareId: remote.id })\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tlet changesData\n\t\t\t\t\ttry {\n\t\t\t\t\t\tchangesData = await changesResp.json()\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tlogger.error('恢复歌单：解析初始数据失败', {\n\t\t\t\t\t\t\tshareId: remote.id,\n\t\t\t\t\t\t\terror: e,\n\t\t\t\t\t\t})\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tconst serverTime =\n\t\t\t\t\t\t(changesData as { server_time?: number }).server_time ?? Date.now()\n\n\t\t\t\t\t// 事务：创建歌单行 + 应用曲目 + 更新同步游标（原子，单歌单独立回滚）\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait this.db.transaction(async (tx) => {\n\t\t\t\t\t\t\tconst txPlaylist = this.playlistService.withDB(tx)\n\t\t\t\t\t\t\tconst txTrack = this.trackService.withDB(tx)\n\t\t\t\t\t\t\tconst txArtist = this.artistService.withDB(tx)\n\n\t\t\t\t\t\t\tconst createResult = await txPlaylist.createPlaylist({\n\t\t\t\t\t\t\t\ttitle: remote.title,\n\t\t\t\t\t\t\t\tdescription: remote.description,\n\t\t\t\t\t\t\t\tcoverUrl: remote.coverUrl,\n\t\t\t\t\t\t\t\ttype: 'local',\n\t\t\t\t\t\t\t\tshareId: remote.id,\n\t\t\t\t\t\t\t\tshareRole: remote.role,\n\t\t\t\t\t\t\t\tlastShareSyncAt: 0,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif (createResult.isErr()) throw createResult.error\n\t\t\t\t\t\t\tconst localId = createResult.value.id\n\n\t\t\t\t\t\t\tawait this._applyPullResponse(\n\t\t\t\t\t\t\t\tlocalId,\n\t\t\t\t\t\t\t\tremote.id,\n\t\t\t\t\t\t\t\tchangesData as Parameters<typeof this._applyPullResponse>[2],\n\t\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tplaylistService: txPlaylist,\n\t\t\t\t\t\t\t\t\ttrackService: txTrack,\n\t\t\t\t\t\t\t\t\tartistService: txArtist,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t)\n\n\t\t\t\t\t\t\tconst syncResult = await txPlaylist.updatePlaylistMetadata(\n\t\t\t\t\t\t\t\tlocalId,\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tlastShareSyncAt: serverTime,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tif (syncResult.isErr()) throw syncResult.error\n\t\t\t\t\t\t})\n\t\t\t\t\t\trestored++\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tlogger.error('恢复歌单失败', { shareId: remote.id, error: e })\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlogger.info('restoreFromCloud 完成', { restored })\n\t\t\t\treturn { restored }\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('SharedPlaylistRestoreFailed', '从云端恢复歌单失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// pullChanges — 增量拉取单个歌单的最新变更\n\t// ---------------------------------------------------------------------------\n\t/**\n\t * 拉取指定本地歌单的增量变更并应用到本地 DB。\n\t * 以 `playlist.lastShareSyncAt` 作为 `since` 游标，拉取后更新为 `server_time`。\n\t * @param localPlaylistId 本地歌单 ID\n\t */\n\tpublic pullChanges(\n\t\tlocalPlaylistId: number,\n\t): ResultAsync<{ applied: number }, ReturnType<typeof createFacadeError>> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 1. 获取本地歌单的 shareId 和 lastShareSyncAt\n\t\t\t\tconst playlistResult =\n\t\t\t\t\tawait this.playlistService.getPlaylistById(localPlaylistId)\n\t\t\t\tif (playlistResult.isErr()) throw playlistResult.error\n\t\t\t\tconst playlist = playlistResult.value\n\t\t\t\tif (!playlist?.shareId) {\n\t\t\t\t\tthrow createValidationError(\n\t\t\t\t\t\t`歌单 ${localPlaylistId} 没有 shareId，无法拉取`,\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst since = playlist.lastShareSyncAt\n\t\t\t\t\t? playlist.lastShareSyncAt.getTime()\n\t\t\t\t\t: 0\n\n\t\t\t\t// 2. GET /api/playlists/:id/changes?since=<ms>\n\t\t\t\tconst resp = await this.api.playlists[':id'].changes.$get({\n\t\t\t\t\tparam: { id: playlist.shareId },\n\t\t\t\t\tquery: { since: String(since) },\n\t\t\t\t})\n\t\t\t\tif (resp.status === 404 || resp.status === 403) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistDeleted',\n\t\t\t\t\t\t'共享歌单已被删除或无权限访问',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\t\tplaylistId: localPlaylistId,\n\t\t\t\t\t\t\t\tshareId: playlist.shareId,\n\t\t\t\t\t\t\t\tstatus: resp.status,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (!resp.ok) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SharedPlaylistPullFailed',\n\t\t\t\t\t\t`拉取变更失败：${resp.status}`,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst data = await resp.json()\n\n\t\t\t\tconst serverTime = (data as { server_time: number }).server_time\n\n\t\t\t\t// 3. 事务：应用变更 + 更新同步游标（原子）\n\t\t\t\tconst applied = await this.db.transaction(async (tx) => {\n\t\t\t\t\tconst txPlaylist = this.playlistService.withDB(tx)\n\t\t\t\t\tconst txTrack = this.trackService.withDB(tx)\n\t\t\t\t\tconst txArtist = this.artistService.withDB(tx)\n\n\t\t\t\t\tconst n = await this._applyPullResponse(\n\t\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\t\tplaylist.shareId!,\n\t\t\t\t\t\tdata,\n\t\t\t\t\t\ttx,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tplaylistService: txPlaylist,\n\t\t\t\t\t\t\ttrackService: txTrack,\n\t\t\t\t\t\t\tartistService: txArtist,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\n\t\t\t\t\tconst updateResult = await txPlaylist.updatePlaylistMetadata(\n\t\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\t\t{ lastShareSyncAt: serverTime },\n\t\t\t\t\t)\n\t\t\t\t\tif (updateResult.isErr()) throw updateResult.error\n\n\t\t\t\t\treturn n\n\t\t\t\t})\n\n\t\t\t\tlogger.debug('pullChanges 完成', {\n\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\tapplied,\n\t\t\t\t\tserverTime,\n\t\t\t\t})\n\t\t\t\treturn { applied }\n\t\t\t})(),\n\t\t\t(e) => {\n\t\t\t\tif (e instanceof FacadeError && e.type === 'SharedPlaylistDeleted') {\n\t\t\t\t\tthrow e\n\t\t\t\t}\n\t\t\t\treturn createFacadeError(\n\t\t\t\t\t'SharedPlaylistPullFailed',\n\t\t\t\t\t'增量拉取歌单变更失败',\n\t\t\t\t\t{\n\t\t\t\t\t\tcause: e,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t},\n\t\t)\n\t}\n\n\t// ---------------------------------------------------------------------------\n\t// unsubscribeFromPlaylist — 解除订阅 / 断开共享连接\n\t// ---------------------------------------------------------------------------\n\t/**\n\t * 解除本地歌单与共享歌单的关联。\n\t * - subscriber：删除本地歌单副本（连带曲目一起删除）\n\t * - owner/editor：仅清除 shareId/shareRole/lastShareSyncAt，保留本地数据\n\t * @param localPlaylistId 本地歌单 ID\n\t */\n\tpublic unsubscribeFromPlaylist(\n\t\tlocalPlaylistId: number,\n\t): ResultAsync<void, ReturnType<typeof createFacadeError>> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlistResult =\n\t\t\t\t\tawait this.playlistService.getPlaylistById(localPlaylistId)\n\t\t\t\tif (playlistResult.isErr()) throw playlistResult.error\n\t\t\t\tconst playlist = playlistResult.value\n\t\t\t\tif (!playlist) {\n\t\t\t\t\tthrow createValidationError(`找不到歌单：${localPlaylistId}`)\n\t\t\t\t}\n\t\t\t\tif (!playlist.shareId) {\n\t\t\t\t\t// 纯本地歌单，无需操作\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// 事务：删除或断开连接（原子）\n\t\t\t\tawait this.db.transaction(async (tx) => {\n\t\t\t\t\tconst txPlaylist = this.playlistService.withDB(tx)\n\n\t\t\t\t\tif (playlist.shareRole === 'subscriber') {\n\t\t\t\t\t\t// 订阅者：直接删除本地副本\n\t\t\t\t\t\tlogger.info('unsubscribeFromPlaylist: 删除订阅副本', {\n\t\t\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\t\t})\n\t\t\t\t\t\tconst deleteResult =\n\t\t\t\t\t\t\tawait txPlaylist.deletePlaylist(localPlaylistId)\n\t\t\t\t\t\tif (deleteResult.isErr()) throw deleteResult.error\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// owner/editor：断开连接但保留本地数据\n\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t'unsubscribeFromPlaylist: 断开共享连接（保留本地数据）',\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\t\t\t\trole: playlist.shareRole,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\t\t\t\t\t\tconst updateResult = await txPlaylist.updatePlaylistMetadata(\n\t\t\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tshareId: null,\n\t\t\t\t\t\t\t\tshareRole: null,\n\t\t\t\t\t\t\t\tlastShareSyncAt: null,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\t\t\t\t\t\tif (updateResult.isErr()) throw updateResult.error\n\t\t\t\t\t}\n\t\t\t\t})\n\n\t\t\t\tclearSharedPlaylistMembers(playlist.shareId)\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError(\n\t\t\t\t\t'SharedPlaylistUnsubscribeFailed',\n\t\t\t\t\t'解除共享歌单订阅失败',\n\t\t\t\t\t{ cause: e },\n\t\t\t\t),\n\t\t)\n\t}\n\n\tpublic rotateEditorInviteCode(\n\t\tshareId: string,\n\t): ResultAsync<\n\t\t{ editorInviteCode: string },\n\t\tReturnType<typeof createFacadeError>\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst resp = await this.api.playlists[':id'].invite.rotate.$post({\n\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t})\n\t\t\t\tif (!resp.ok) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'InviteCodeRotateFailed',\n\t\t\t\t\t\t`生成编辑者邀请码失败：${resp.status}`,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst data = (await resp.json()) as {\n\t\t\t\t\teditor_invite_code: string\n\t\t\t\t}\n\t\t\t\treturn { editorInviteCode: data.editor_invite_code }\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('InviteCodeRotateFailed', '生成编辑者邀请码失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\tpublic getEditorInviteCode(\n\t\tshareId: string,\n\t): ResultAsync<\n\t\t{ editorInviteCode: string | null },\n\t\tReturnType<typeof createFacadeError>\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst resp = await this.api.playlists[':id'].invite.$get({\n\t\t\t\t\tparam: { id: shareId },\n\t\t\t\t})\n\t\t\t\tif (!resp.ok) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'InviteCodeFetchFailed',\n\t\t\t\t\t\t`获取编辑者邀请码失败：${resp.status}`,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst data = (await resp.json()) as {\n\t\t\t\t\teditor_invite_code?: string | null\n\t\t\t\t}\n\t\t\t\t// null 表示尚未生成邀请码，属于合法状态，不视为错误\n\t\t\t\treturn { editorInviteCode: data.editor_invite_code ?? null }\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tcreateFacadeError('InviteCodeFetchFailed', '获取编辑者邀请码失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\tprivate async _applyPullResponse(\n\t\tlocalPlaylistId: number,\n\t\tshareId: string,\n\t\tdata: {\n\t\t\tmetadata?: {\n\t\t\t\ttitle?: string | null\n\t\t\t\tdescription?: string | null\n\t\t\t\tcover_url?: string | null\n\t\t\t} | null\n\t\t\tmembers?: Array<{\n\t\t\t\tmid: number\n\t\t\t\tname: string\n\t\t\t\tavatar_url?: string | null\n\t\t\t\trole: 'owner' | 'editor' | 'subscriber'\n\t\t\t}>\n\t\t\ttracks: Array<\n\t\t\t\t| {\n\t\t\t\t\t\top: 'upsert'\n\t\t\t\t\t\ttrack: {\n\t\t\t\t\t\t\tunique_key: string\n\t\t\t\t\t\t\ttitle: string\n\t\t\t\t\t\t\tartist_name?: string\n\t\t\t\t\t\t\tartist_id?: string\n\t\t\t\t\t\t\tcover_url?: string\n\t\t\t\t\t\t\tduration?: number\n\t\t\t\t\t\t\tbilibili_bvid: string\n\t\t\t\t\t\t\tbilibili_cid?: string\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsort_key: string\n\t\t\t\t\t\tupdated_at: number\n\t\t\t\t  }\n\t\t\t\t| {\n\t\t\t\t\t\top: 'delete'\n\t\t\t\t\t\ttrack_unique_key: string\n\t\t\t\t\t\tdeleted_at: number\n\t\t\t\t  }\n\t\t\t>\n\t\t\tserver_time?: number\n\t\t},\n\t\tconn: Tx,\n\t\tservices: {\n\t\t\tplaylistService: PlaylistService\n\t\t\ttrackService: TrackService\n\t\t\tartistService: ArtistService\n\t\t},\n\t): Promise<number> {\n\t\tconst { playlistService, trackService, artistService } = services\n\t\tlet applied = 0\n\n\t\t// ---- 应用元数据更新 ----\n\t\tif (data.metadata) {\n\t\t\tconst metaUpdate: Parameters<\n\t\t\t\ttypeof playlistService.updatePlaylistMetadata\n\t\t\t>[1] = {}\n\t\t\tif (data.metadata.title != null) metaUpdate.title = data.metadata.title\n\t\t\tif (data.metadata.description !== undefined)\n\t\t\t\tmetaUpdate.description = data.metadata.description\n\t\t\tif (data.metadata.cover_url !== undefined)\n\t\t\t\tmetaUpdate.coverUrl = data.metadata.cover_url\n\t\t\tif (Object.keys(metaUpdate).length > 0) {\n\t\t\t\tconst metaResult = await playlistService.updatePlaylistMetadata(\n\t\t\t\t\tlocalPlaylistId,\n\t\t\t\t\tmetaUpdate,\n\t\t\t\t)\n\t\t\t\tif (metaResult.isErr()) throw metaResult.error\n\t\t\t}\n\t\t}\n\n\t\tif (Array.isArray(data.members)) {\n\t\t\ttype narrowedMember = Omit<(typeof data.members)[number], 'role'> & {\n\t\t\t\trole: 'owner' | 'editor'\n\t\t\t}\n\t\t\tconst members = data.members\n\t\t\t\t.filter((m) => m.role === 'owner' || m.role === 'editor')\n\t\t\t\t.map((m) => ({\n\t\t\t\t\tmid: Number(m.mid),\n\t\t\t\t\tname: m.name,\n\t\t\t\t\tavatarUrl: m.avatar_url ?? null,\n\t\t\t\t\trole: m.role,\n\t\t\t\t}))\n\t\t\t\t.filter((m) => Number.isFinite(m.mid) && !!m.name) as narrowedMember[]\n\n\t\t\tif (members.length > 0) {\n\t\t\t\tsetSharedPlaylistMembers(shareId, members)\n\t\t\t} else {\n\t\t\t\tclearSharedPlaylistMembers(shareId)\n\t\t\t}\n\t\t}\n\n\t\tif (!data.tracks.length) return applied\n\n\t\tconst upsertChanges = data.tracks.filter(\n\t\t\t(t): t is Extract<(typeof data.tracks)[number], { op: 'upsert' }> =>\n\t\t\t\tt.op === 'upsert',\n\t\t)\n\t\tconst deleteChanges = data.tracks.filter(\n\t\t\t(t): t is Extract<(typeof data.tracks)[number], { op: 'delete' }> =>\n\t\t\t\tt.op === 'delete',\n\t\t)\n\n\t\t// ---- 应用 upsert 变更 ----\n\t\tif (upsertChanges.length > 0) {\n\t\t\t// 批量找或创建 artist\n\t\t\tconst artistMap = new Map<string, number>() // artistId(mid str) → local artist DB id\n\t\t\tconst artistsToCreate = upsertChanges\n\t\t\t\t.filter((c) => c.track.artist_name && c.track.artist_id)\n\t\t\t\t.map((c) => ({\n\t\t\t\t\tname: c.track.artist_name!,\n\t\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\t\tremoteId: c.track.artist_id!,\n\t\t\t\t}))\n\t\t\tif (artistsToCreate.length > 0) {\n\t\t\t\tconst artistResult =\n\t\t\t\t\tawait artistService.findOrCreateManyRemoteArtists(artistsToCreate)\n\t\t\t\tif (artistResult.isOk()) {\n\t\t\t\t\tfor (const [remoteId, artist] of artistResult.value.entries()) {\n\t\t\t\t\t\tartistMap.set(remoteId, artist.id)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tthrow artistResult.error\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 批量找或创建 track\n\t\t\tconst trackPayloads = upsertChanges.map((c) => {\n\t\t\t\tconst cidNum = c.track.bilibili_cid\n\t\t\t\t\t? Number(c.track.bilibili_cid)\n\t\t\t\t\t: undefined\n\t\t\t\tconst isMultiPage = !!c.track.bilibili_cid\n\t\t\t\treturn {\n\t\t\t\t\ttitle: c.track.title,\n\t\t\t\t\tcoverUrl: c.track.cover_url,\n\t\t\t\t\tduration: c.track.duration ?? 0,\n\t\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\t\tartistId: c.track.artist_id\n\t\t\t\t\t\t? artistMap.get(c.track.artist_id)\n\t\t\t\t\t\t: undefined,\n\t\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\t\tbvid: c.track.bilibili_bvid,\n\t\t\t\t\t\tcid: cidNum ?? null,\n\t\t\t\t\t\tisMultiPage,\n\t\t\t\t\t\tvideoIsValid: true,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tconst trackIdsResult = await trackService.findOrCreateManyTracks(\n\t\t\t\ttrackPayloads,\n\t\t\t\t'bilibili',\n\t\t\t)\n\t\t\tif (trackIdsResult.isErr()) {\n\t\t\t\tthrow trackIdsResult.error\n\t\t\t}\n\t\t\t// 用服务端 sort_key 直接 upsert playlist_tracks（conn 已是 tx 作用域）\n\t\t\tconst trackIdMap = trackIdsResult.value\n\t\t\tconst rows = upsertChanges\n\t\t\t\t.map((c) => {\n\t\t\t\t\tconst trackId = trackIdMap.get(c.track.unique_key)\n\t\t\t\t\tif (!trackId) return null\n\t\t\t\t\treturn {\n\t\t\t\t\t\tplaylistId: localPlaylistId,\n\t\t\t\t\t\ttrackId,\n\t\t\t\t\t\tsortKey: c.sort_key,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((r): r is NonNullable<typeof r> => r !== null)\n\n\t\t\tif (rows.length > 0) {\n\t\t\t\tawait conn\n\t\t\t\t\t.insert(schema.playlistTracks)\n\t\t\t\t\t.values(rows)\n\t\t\t\t\t.onConflictDoUpdate({\n\t\t\t\t\t\ttarget: [\n\t\t\t\t\t\t\tschema.playlistTracks.playlistId,\n\t\t\t\t\t\t\tschema.playlistTracks.trackId,\n\t\t\t\t\t\t],\n\t\t\t\t\t\t// 使用服务器下发的最新 sort_key 覆盖本地值，确保重排同步生效\n\t\t\t\t\t\tset: { sortKey: sql`excluded.sort_key` },\n\t\t\t\t\t})\n\t\t\t\tapplied += rows.length\n\t\t\t}\n\t\t}\n\n\t\t// ---- 应用 delete 变更 ----\n\t\tif (deleteChanges.length > 0) {\n\t\t\tconst uniqueKeys = deleteChanges.map((c) => c.track_unique_key)\n\t\t\tconst trackIdsResult =\n\t\t\t\tawait trackService.findTrackIdsByUniqueKeys(uniqueKeys)\n\t\t\tif (trackIdsResult.isErr()) {\n\t\t\t\tthrow trackIdsResult.error\n\t\t\t}\n\t\t\tconst trackIds = Array.from(trackIdsResult.value.values())\n\t\t\tif (trackIds.length > 0) {\n\t\t\t\tawait conn\n\t\t\t\t\t.delete(schema.playlistTracks)\n\t\t\t\t\t.where(\n\t\t\t\t\t\tand(\n\t\t\t\t\t\t\teq(schema.playlistTracks.playlistId, localPlaylistId),\n\t\t\t\t\t\t\tinArray(schema.playlistTracks.trackId, trackIds),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\tapplied += trackIds.length\n\t\t\t}\n\t\t}\n\n\t\t// ---- 重算并更新 itemCount ----\n\t\tif (applied > 0) {\n\t\t\tconst [{ count }] = await conn\n\t\t\t\t.select({ count: sql<number>`count(*)` })\n\t\t\t\t.from(schema.playlistTracks)\n\t\t\t\t.where(eq(schema.playlistTracks.playlistId, localPlaylistId))\n\t\t\tawait conn\n\t\t\t\t.update(schema.playlists)\n\t\t\t\t.set({ itemCount: count })\n\t\t\t\t.where(eq(schema.playlists.id, localPlaylistId))\n\t\t}\n\n\t\treturn applied\n\t}\n}\n\nexport const sharedPlaylistFacade = new SharedPlaylistFacade(\n\tdb,\n\tplaylistService,\n\ttrackService,\n\tartistService,\n\tbbplayerApiClient,\n)\n"
  },
  {
    "path": "apps/mobile/src/lib/facades/syncBilibiliPlaylist.ts",
    "content": "import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow'\n\nimport type { bilibiliApi as BilibiliApiService } from '@/lib/api/bilibili/api'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { av2bv, bv2av } from '@/lib/api/bilibili/utils'\nimport db from '@/lib/db/db'\nimport type * as schema from '@/lib/db/schema'\nimport type { DatabaseError, ServiceError } from '@/lib/errors'\nimport type { FacadeError } from '@/lib/errors/facade'\nimport {\n\tcreateFacadeError,\n\tcreateSyncTaskAlreadyRunningError,\n} from '@/lib/errors/facade'\nimport { createValidationError } from '@/lib/errors/service'\nimport type { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport type { ArtistService } from '@/lib/services/artistService'\nimport { artistService } from '@/lib/services/artistService'\nimport generateUniqueTrackKey from '@/lib/services/genKey'\nimport type { PlaylistService } from '@/lib/services/playlistService'\nimport { playlistService } from '@/lib/services/playlistService'\nimport type { TrackService } from '@/lib/services/trackService'\nimport { trackService } from '@/lib/services/trackService'\nimport type { BilibiliFavoriteListContent } from '@/types/apis/bilibili'\nimport type { BilibiliTrack, Playlist, Track } from '@/types/core/media'\nimport type { CreateArtistPayload } from '@/types/services/artist'\nimport log from '@/utils/log'\nimport { diffSets } from '@/utils/set'\nimport toast from '@/utils/toast'\n\nexport interface FavoriteSyncProgress {\n\tmessage: string\n\tcurrent?: number\n\ttotal?: number\n\tstage:\n\t\t| 'initializing'\n\t\t| 'fetching_metadata'\n\t\t| 'calculating_diff'\n\t\t| 'fetching_details'\n\t\t| 'saving'\n\t\t| 'completed'\n\t\t| 'error'\n}\n\nlet logger = log.extend('Facade')\n\nexport class SyncBilibiliPlaylistFacade {\n\tprivate syncingIds = new Set<string>()\n\tconstructor(\n\t\tprivate readonly trackService: TrackService,\n\t\tprivate readonly bilibiliApi: typeof BilibiliApiService,\n\t\tprivate readonly playlistService: PlaylistService,\n\t\tprivate readonly artistService: ArtistService,\n\t\tprivate readonly db: ExpoSQLiteDatabase<typeof schema>,\n\t) {}\n\n\t/**\n\t * 从 Bilibili API 获取视频信息，并创建一个新的音轨。\n\t * @param bvid\n\t * @param cid 基于 cid 是否存在判断 isMultiPage 的值\n\t * @returns\n\t */\n\tpublic addTrackFromBilibiliApi(\n\t\tbvid: string,\n\t\tcid?: number,\n\t): ResultAsync<Track, BilibiliApiError | DatabaseError | ServiceError> {\n\t\tlogger.info('开始添加 Track（Bilibili）', { bvid, cid })\n\t\tconst apiData = this.bilibiliApi.getVideoDetails(bvid)\n\t\treturn apiData.andThen((data) => {\n\t\t\tconst trackPayload = {\n\t\t\t\ttitle: data.title,\n\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\tbvid,\n\t\t\t\t\tcid: cid,\n\t\t\t\t\tisMultiPage: cid !== undefined,\n\t\t\t\t\tvideoIsValid: true,\n\t\t\t\t},\n\t\t\t\tcoverUrl: data.pic,\n\t\t\t\tduration: data.duration,\n\t\t\t\tartist: {\n\t\t\t\t\tid: data.owner.mid,\n\t\t\t\t\tname: data.owner.name,\n\t\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\t},\n\t\t\t}\n\t\t\treturn this.trackService\n\t\t\t\t.findOrCreateTrack(trackPayload)\n\t\t\t\t.andTee((track) => {\n\t\t\t\t\tlogger.info('添加 Track 成功', {\n\t\t\t\t\t\ttrackId: track.id,\n\t\t\t\t\t\ttitle: track.title,\n\t\t\t\t\t\tsource: track.source,\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 将单一 track 录入到本地数据库\n\t * @param track Track 对象\n\t */\n\tpublic addTrackToLocal(track: Track) {\n\t\tif (!track.artist) return errAsync(createValidationError('artist 不存在'))\n\t\treturn this.artistService\n\t\t\t.findOrCreateArtist({\n\t\t\t\tname: track.artist.name,\n\t\t\t\tsource: track.artist.source,\n\t\t\t\tremoteId: track.artist.remoteId,\n\t\t\t\tavatarUrl: track.artist.avatarUrl,\n\t\t\t\tsignature: track.artist.signature,\n\t\t\t})\n\t\t\t.andThen((artist) => {\n\t\t\t\treturn this.trackService.findOrCreateTrack({\n\t\t\t\t\t...track,\n\t\t\t\t\tartistId: artist.id,\n\t\t\t\t})\n\t\t\t})\n\t}\n\n\t/**\n\t * 同步合集内容\n\t * @param collectionId 合集 id\n\t * @returns ResultAsync<number, FacadeError>\n\t */\n\tpublic syncCollection(\n\t\tcollectionId: number,\n\t): ResultAsync<number, BilibiliApiError | FacadeError> {\n\t\tif (this.syncingIds.has(`collection::${collectionId}`)) {\n\t\t\tlogger.info('已有同步任务在进行，跳过', {\n\t\t\t\ttype: 'collection',\n\t\t\t\tid: collectionId,\n\t\t\t})\n\t\t\treturn errAsync(createSyncTaskAlreadyRunningError())\n\t\t}\n\t\ttry {\n\t\t\tthis.syncingIds.add(`collection::${collectionId}`)\n\t\t\tlogger = log.extend('[Facade/SyncCollection: ' + collectionId + ']')\n\t\t\tlogger.info('开始同步合集', { collectionId })\n\t\t\tlogger.debug('syncCollection', { collectionId })\n\t\t\treturn this.bilibiliApi\n\t\t\t\t.getCollectionAllContents(collectionId)\n\t\t\t\t.andTee(() =>\n\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t'step 1: 调用 bilibiliapi getCollectionAllContents 完成',\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t\t.andThen((contents) => {\n\t\t\t\t\tlogger.info('获取合集详情成功', {\n\t\t\t\t\t\ttitle: contents.info.title,\n\t\t\t\t\t\ttotal: contents.medias?.length ?? 0,\n\t\t\t\t\t})\n\t\t\t\t\tconst medias = contents.medias ?? []\n\t\t\t\t\tif (medias.length === 0) {\n\t\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t\t'SyncCollectionFailed',\n\t\t\t\t\t\t\t\t'同步合集失败，该合集中没有任何 track',\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\t\t\t\t\t\t\tconst trackSvc = this.trackService.withDB(tx)\n\t\t\t\t\t\t\tconst artistSvc = this.artistService.withDB(tx)\n\n\t\t\t\t\t\t\tconst playlistArtistId = await artistSvc.findOrCreateArtist({\n\t\t\t\t\t\t\t\tname: contents.info.upper.name,\n\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\tremoteId: String(contents.info.upper.mid),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif (playlistArtistId.isErr()) throw playlistArtistId.error\n\n\t\t\t\t\t\t\tconst playlistRes = await playlistSvc.findOrCreateRemotePlaylist({\n\t\t\t\t\t\t\t\ttitle: contents.info.title,\n\t\t\t\t\t\t\t\tdescription: contents.info.intro,\n\t\t\t\t\t\t\t\tcoverUrl: contents.info.cover,\n\t\t\t\t\t\t\t\ttype: 'collection',\n\t\t\t\t\t\t\t\tremoteSyncId: collectionId,\n\t\t\t\t\t\t\t\tauthorId: playlistArtistId.value.id,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\t\t\t\tlogger.debug('step 2: 创建 playlist 和其对应的 artist 信息完成', {\n\t\t\t\t\t\t\t\tid: playlistRes.value.id,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\tconst uniqueArtists = new Map<number, { name: string }>()\n\t\t\t\t\t\t\tfor (const media of medias) {\n\t\t\t\t\t\t\t\tif (!uniqueArtists.has(media.upper.mid)) {\n\t\t\t\t\t\t\t\t\tuniqueArtists.set(media.upper.mid, {\n\t\t\t\t\t\t\t\t\t\tname: media.upper.name,\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tconst artistRes = await artistSvc.findOrCreateManyRemoteArtists(\n\t\t\t\t\t\t\t\tArray.from(uniqueArtists, ([remoteId, artistInfo]) => ({\n\t\t\t\t\t\t\t\t\tname: artistInfo.name,\n\t\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\t\tremoteId: String(remoteId),\n\t\t\t\t\t\t\t\t\tavatarUrl: undefined,\n\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tif (artistRes.isErr()) throw artistRes.error\n\t\t\t\t\t\t\tconst localArtistIdMap = artistRes.value\n\t\t\t\t\t\t\tlogger.debug('step 3: 创建 artist 完成', {\n\t\t\t\t\t\t\t\tuniqueCount: uniqueArtists.size,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\tconst tracksCreateResult = await trackSvc.findOrCreateManyTracks(\n\t\t\t\t\t\t\t\tmedias.map((v) => ({\n\t\t\t\t\t\t\t\t\ttitle: v.title,\n\t\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\t\t\t\t\t\tbvid: v.bvid,\n\t\t\t\t\t\t\t\t\t\tisMultiPage: false,\n\t\t\t\t\t\t\t\t\t\tcid: undefined,\n\t\t\t\t\t\t\t\t\t\tvideoIsValid: true,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tcoverUrl: v.cover,\n\t\t\t\t\t\t\t\t\tduration: v.duration,\n\t\t\t\t\t\t\t\t\tartistId: localArtistIdMap.get(String(v.upper.mid))?.id,\n\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t\t'bilibili',\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tif (tracksCreateResult.isErr()) throw tracksCreateResult.error\n\t\t\t\t\t\t\tconst trackIds = Array.from(tracksCreateResult.value.values())\n\t\t\t\t\t\t\tlogger.debug('step 4: 创建 tracks 完成', {\n\t\t\t\t\t\t\t\ttotal: trackIds.length,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\t// 我们不需要去更新 lastSyncedAt 字段，因为在 replacePlaylistAllTracks 中会更新\n\t\t\t\t\t\t\tconst replaceResult = await playlistSvc.replacePlaylistAllTracks(\n\t\t\t\t\t\t\t\tplaylistRes.value.id,\n\t\t\t\t\t\t\t\ttrackIds,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tif (replaceResult.isErr()) {\n\t\t\t\t\t\t\t\tthrow replaceResult.error\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tlogger.debug('step 5: 替换 playlist 中所有 tracks 完成')\n\t\t\t\t\t\t\tlogger.info('同步合集完成', {\n\t\t\t\t\t\t\t\tremoteId: contents.info.id,\n\t\t\t\t\t\t\t\tplaylistId: playlistRes.value.id,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tvoid analyticsService.logPlaylistSync(\n\t\t\t\t\t\t\t\t'sync_bilibili',\n\t\t\t\t\t\t\t\t'collection',\n\t\t\t\t\t\t\t\ttrackIds.length,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\treturn playlistRes.value.id\n\t\t\t\t\t\t}),\n\t\t\t\t\t\t(e) =>\n\t\t\t\t\t\t\tcreateFacadeError('SyncCollectionFailed', '同步合集失败', {\n\t\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t})\n\t\t} finally {\n\t\t\tthis.syncingIds.delete(`collection::${collectionId}`)\n\t\t}\n\t}\n\n\t/**\n\t * 同步多集视频\n\t * @param bvid\n\t */\n\tpublic syncMultiPageVideo(\n\t\tbvid: string,\n\t): ResultAsync<number, BilibiliApiError | FacadeError> {\n\t\tif (this.syncingIds.has(`multiPage::${bvid}`)) {\n\t\t\tlogger.info('已有同步任务在进行，跳过', {\n\t\t\t\ttype: 'multi_page',\n\t\t\t\tbvid,\n\t\t\t})\n\t\t\treturn errAsync(createSyncTaskAlreadyRunningError())\n\t\t}\n\t\ttry {\n\t\t\tthis.syncingIds.add(`multiPage::${bvid}`)\n\t\t\tlogger = log.extend('[Facade/SyncMultiPageVideo: ' + bvid + ']')\n\t\t\tlogger.info('开始同步多集视频', { bvid })\n\t\t\treturn this.bilibiliApi\n\t\t\t\t.getVideoDetails(bvid)\n\t\t\t\t.andTee(() =>\n\t\t\t\t\tlogger.debug('step 1: 调用 bilibiliapi getVideoDetails 完成'),\n\t\t\t\t)\n\t\t\t\t.andThen((data) => {\n\t\t\t\t\tlogger.info('获取多集视频详情成功', {\n\t\t\t\t\t\ttitle: data.title,\n\t\t\t\t\t\tpages: data.pages.length,\n\t\t\t\t\t})\n\t\t\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\t\t\tthis.db.transaction(async () => {\n\t\t\t\t\t\t\tconst playlistSvc = this.playlistService.withDB(this.db)\n\t\t\t\t\t\t\tconst trackSvc = this.trackService.withDB(this.db)\n\t\t\t\t\t\t\tconst artistSvc = this.artistService.withDB(this.db)\n\n\t\t\t\t\t\t\tconst playlistAuthor = await artistSvc.findOrCreateArtist({\n\t\t\t\t\t\t\t\tname: data.owner.name,\n\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\tremoteId: String(data.owner.mid),\n\t\t\t\t\t\t\t\tavatarUrl: data.owner.face,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif (playlistAuthor.isErr()) throw playlistAuthor.error\n\n\t\t\t\t\t\t\tconst playlistRes = await playlistSvc.findOrCreateRemotePlaylist({\n\t\t\t\t\t\t\t\ttitle: data.title,\n\t\t\t\t\t\t\t\tdescription: data.desc,\n\t\t\t\t\t\t\t\tcoverUrl: data.pic,\n\t\t\t\t\t\t\t\ttype: 'multi_page',\n\t\t\t\t\t\t\t\tremoteSyncId: bv2av(bvid),\n\t\t\t\t\t\t\t\tauthorId: playlistAuthor.value.id,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif (playlistRes.isErr()) throw playlistRes.error\n\t\t\t\t\t\t\tlogger.debug('step 2: 创建 playlist 和其对应的 artist 信息完成', {\n\t\t\t\t\t\t\t\tid: playlistRes.value.id,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\tconst trackCreateResult = await trackSvc.findOrCreateManyTracks(\n\t\t\t\t\t\t\t\tdata.pages.map((page) => ({\n\t\t\t\t\t\t\t\t\ttitle: page.part,\n\t\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\t\t\t\t\t\tbvid: bvid,\n\t\t\t\t\t\t\t\t\t\tisMultiPage: true,\n\t\t\t\t\t\t\t\t\t\tcid: page.cid,\n\t\t\t\t\t\t\t\t\t\tvideoIsValid: true,\n\t\t\t\t\t\t\t\t\t\tmainTrackTitle: data.title,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tcoverUrl: data.pic,\n\t\t\t\t\t\t\t\t\tduration: page.duration,\n\t\t\t\t\t\t\t\t\tartistId: playlistAuthor.value.id,\n\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t\t'bilibili',\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tif (trackCreateResult.isErr()) throw trackCreateResult.error\n\t\t\t\t\t\t\tconst trackIds = Array.from(trackCreateResult.value.values())\n\t\t\t\t\t\t\tlogger.debug('step 3: 创建 tracks 完成', {\n\t\t\t\t\t\t\t\ttotal: trackIds.length,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\t// 我们不需要去更新 lastSyncedAt 字段，因为在 replacePlaylistAllTracks 中会更新\n\t\t\t\t\t\t\tconst replaceResult = await playlistSvc.replacePlaylistAllTracks(\n\t\t\t\t\t\t\t\tplaylistRes.value.id,\n\t\t\t\t\t\t\t\ttrackIds,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tif (replaceResult.isErr()) {\n\t\t\t\t\t\t\t\tthrow replaceResult.error\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tlogger.debug('step 4: 替换 playlist 中所有 tracks 完成')\n\t\t\t\t\t\t\tlogger.info('同步合集完成', {\n\t\t\t\t\t\t\t\tremoteId: bv2av(bvid),\n\t\t\t\t\t\t\t\tplaylistId: playlistRes.value.id,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\tvoid analyticsService.logPlaylistSync(\n\t\t\t\t\t\t\t\t'sync_bilibili',\n\t\t\t\t\t\t\t\t'multi_page',\n\t\t\t\t\t\t\t\ttrackIds.length,\n\t\t\t\t\t\t\t)\n\n\t\t\t\t\t\t\treturn playlistRes.value.id\n\t\t\t\t\t\t}),\n\t\t\t\t\t\t(e) =>\n\t\t\t\t\t\t\tcreateFacadeError('SyncMultiPageFailed', '同步多集视频失败', {\n\t\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t})\n\t\t} finally {\n\t\t\tthis.syncingIds.delete(`multiPage::${bvid}`)\n\t\t}\n\t}\n\n\t/**\n\t * 同步收藏夹内容，会对要同步的内容做基础的 diff 处理\n\t * @param favoriteId 收藏夹 ID\n\t * @returns Result 成功时为 playlist ID，undefined 表示远端收藏夹为空，并且本地之前也没有创建过（这种情况前端不应该显示同步按钮）\n\t */\n\tpublic async syncFavorite(\n\t\tfavoriteId: number,\n\t\tonProgress?: (progress: FavoriteSyncProgress) => void,\n\t): Promise<Result<number | undefined, FacadeError | BilibiliApiError>> {\n\t\t// getFavoriteListAllContents 获取到的 bvid 中会包含被 up 隐藏的视频，但这部分视频在 getFavoriteListContents 中是找不到的，也就无法添加到本地数据库。这导致对于包含这种视频的收藏夹，每次同步都会重新「同步」这些视频，但咱们没办法......\n\t\tif (this.syncingIds.has(`favorite::${favoriteId}`)) {\n\t\t\treturn err(createSyncTaskAlreadyRunningError())\n\t\t}\n\t\ttry {\n\t\t\tthis.syncingIds.add(`favorite::${favoriteId}`)\n\t\t\tonProgress?.({\n\t\t\t\tmessage: '初始化同步任务...',\n\t\t\t\tstage: 'initializing',\n\t\t\t})\n\t\t\tlogger = log.extend('[Facade/SyncFavorite: ' + favoriteId + ']')\n\t\t\tlogger.info('开始同步收藏夹', { favoriteId })\n\t\t\tlogger.debug('syncFavorite', { favoriteId })\n\n\t\t\t// 从 bilibili 获取基本元数据和收藏夹所有 bvid\n\t\t\tonProgress?.({\n\t\t\t\tmessage: '正在获取收藏夹元数据...',\n\t\t\t\tstage: 'fetching_metadata',\n\t\t\t})\n\t\t\tconst bilibiliResult = await ResultAsync.combine([\n\t\t\t\tthis.bilibiliApi.getFavoriteListAllContents(favoriteId),\n\t\t\t\tthis.bilibiliApi.getFavoriteListContents(favoriteId, 1),\n\t\t\t])\n\t\t\tif (bilibiliResult.isErr()) {\n\t\t\t\treturn err(bilibiliResult.error)\n\t\t\t}\n\t\t\tconst bilibiliFavoriteListMetadata = bilibiliResult.value[1]\n\t\t\tif (!bilibiliFavoriteListMetadata.info) {\n\t\t\t\treturn err(\n\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t'SyncFavoriteFailed',\n\t\t\t\t\t\t'同步收藏夹失败，数据为空，收藏夹可能不存在',\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\t\t\tconst bilibiliFavoriteListAllBvids = bilibiliResult.value[0].filter(\n\t\t\t\t(item) => item.type === 2, // 过滤非视频稿件 (type 2 is video)\n\t\t\t)\n\t\t\tlogger.debug('step 1: 调用 bilibiliapi getFavoriteListAllContents 完成', {\n\t\t\t\ttotal: bilibiliFavoriteListAllBvids.length,\n\t\t\t})\n\n\t\t\t// 查询本地收藏夹元数据\n\t\t\tconst localPlaylist =\n\t\t\t\tawait this.playlistService.findPlaylistByTypeAndRemoteId(\n\t\t\t\t\t'favorite',\n\t\t\t\t\tfavoriteId,\n\t\t\t\t)\n\t\t\tif (localPlaylist.isErr()) {\n\t\t\t\treturn err(localPlaylist.error)\n\t\t\t}\n\t\t\tlogger.debug('step 2: 查询本地收藏夹元数据完成', {\n\t\t\t\tlocalPlaylistId: localPlaylist.value?.id ?? '不存在',\n\t\t\t})\n\n\t\t\t// 开始计算 diff\n\t\t\tonProgress?.({\n\t\t\t\tmessage: '正在比对本地数据...',\n\t\t\t\tstage: 'calculating_diff',\n\t\t\t})\n\t\t\tlet bvidsToAddSet: Set<string>\n\t\t\tlet bvidsToRemoveSet: Set<string>\n\t\t\tconst afterRemovedHiddenBvidsAllBvids = new Set<string>(\n\t\t\t\tbilibiliFavoriteListAllBvids.map((item) => item.bvid),\n\t\t\t) // 删除被隐藏的视频后的所有 bvid（在元数据请求完成后处理删除逻辑）\n\n\t\t\tif (!localPlaylist.value || localPlaylist.value.itemCount === 0) {\n\t\t\t\t// 本地收藏夹为空或没创建过，则全部添加\n\t\t\t\tbvidsToAddSet = new Set(\n\t\t\t\t\tbilibiliFavoriteListAllBvids.map((item) => item.bvid),\n\t\t\t\t)\n\t\t\t\tbvidsToRemoveSet = new Set()\n\t\t\t} else {\n\t\t\t\tconst existTracks = await this.playlistService.getPlaylistTracks(\n\t\t\t\t\tlocalPlaylist.value.id,\n\t\t\t\t)\n\t\t\t\tif (existTracks.isErr()) {\n\t\t\t\t\treturn err(existTracks.error)\n\t\t\t\t}\n\t\t\t\tif (existTracks.value.find((item) => item.source !== 'bilibili')) {\n\t\t\t\t\treturn err(\n\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t'SyncFavoriteFailed',\n\t\t\t\t\t\t\t'同步收藏夹失败，收藏夹中存在非 Bilibili 的 Track，你的数据库似乎已经坏掉惹。',\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tconst biliTracks = existTracks.value as BilibiliTrack[]\n\t\t\t\tconst diff = diffSets(\n\t\t\t\t\tnew Set(bilibiliFavoriteListAllBvids.map((item) => item.bvid)),\n\t\t\t\t\tnew Set(biliTracks.map((item) => item.bilibiliMetadata.bvid)),\n\t\t\t\t)\n\t\t\t\t// 注意，这里是相反的\n\t\t\t\tbvidsToAddSet = diff.removed\n\t\t\t\tbvidsToRemoveSet = diff.added\n\t\t\t}\n\t\t\tlogger.debug('step 3: 对远程和本地的 tracks 进行 diff 完成', {\n\t\t\t\tadded: bvidsToAddSet.size,\n\t\t\t\tremoved: bvidsToRemoveSet.size,\n\t\t\t})\n\t\t\tlogger.info('收藏夹变更统计', {\n\t\t\t\tadded: bvidsToAddSet.size,\n\t\t\t\tremoved: bvidsToRemoveSet.size,\n\t\t\t})\n\t\t\tif (bvidsToAddSet.size === 0 && bvidsToRemoveSet.size === 0) {\n\t\t\t\tlogger.info('收藏夹为空或与上次相比无变化，无需同步')\n\t\t\t\treturn ok(localPlaylist.value?.id)\n\t\t\t}\n\n\t\t\t// 开始获取收藏夹新增部分 bvid 的详细元数据\n\t\t\t// 从第一页（最新）开始获取，直到所有新增的 bvid 都获取完成\n\t\t\tonProgress?.({\n\t\t\t\tmessage: `准备同步 ${bvidsToAddSet.size} 个新视频...`,\n\t\t\t\tcurrent: 0,\n\t\t\t\ttotal: bvidsToAddSet.size,\n\t\t\t\tstage: 'fetching_details',\n\t\t\t})\n\n\t\t\tconst addedTracksMetadata = new Set<BilibiliFavoriteListContent>()\n\t\t\tlet nowPageNumber = 0\n\t\t\tlet hasMore = true\n\t\t\tconst totalToAdd = bvidsToAddSet.size\n\t\t\tlet fetchedCount = 0\n\n\t\t\twhile (hasMore) {\n\t\t\t\tif (bvidsToAddSet.size === 0) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tnowPageNumber += 1\n\t\t\t\tonProgress?.({\n\t\t\t\t\tmessage: `正在获取第 ${nowPageNumber} 页详情...`,\n\t\t\t\t\tcurrent: fetchedCount,\n\t\t\t\t\ttotal: totalToAdd,\n\t\t\t\t\tstage: 'fetching_details',\n\t\t\t\t})\n\t\t\t\tlogger.debug('开始获取第 ' + nowPageNumber + ' 页收藏夹内容')\n\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\tconst pageResult = await this.bilibiliApi.getFavoriteListContents(\n\t\t\t\t\tfavoriteId,\n\t\t\t\t\tnowPageNumber,\n\t\t\t\t)\n\t\t\t\tif (pageResult.isErr()) {\n\t\t\t\t\treturn errAsync(pageResult.error)\n\t\t\t\t}\n\t\t\t\tconst page = pageResult.value\n\t\t\t\tif (!page.medias) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tcreateFacadeError(\n\t\t\t\t\t\t\t'SyncFavoriteFailed',\n\t\t\t\t\t\t\t'同步收藏夹失败，该收藏夹中没有任何 track',\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tlogger.debug(page.medias.length)\n\t\t\t\thasMore = page.has_more\n\t\t\t\tfor (const item of page.medias) {\n\t\t\t\t\tif (bvidsToAddSet.has(item.bvid)) {\n\t\t\t\t\t\taddedTracksMetadata.add(item)\n\t\t\t\t\t\tbvidsToAddSet.delete(item.bvid)\n\t\t\t\t\t\tfetchedCount++\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tonProgress?.({\n\t\t\t\t\tmessage: `已获取 ${fetchedCount}/${totalToAdd} 个视频详情...`,\n\t\t\t\t\tcurrent: fetchedCount,\n\t\t\t\t\ttotal: totalToAdd,\n\t\t\t\t\tstage: 'fetching_details',\n\t\t\t\t})\n\t\t\t}\n\t\t\tif (bvidsToAddSet.size > 0) {\n\t\t\t\tconst tip = `Bilibili 隐藏了被 up 设置为仅自己可见的稿件，却没有更新索引，所以你会看到同步到的歌曲数量少于收藏夹实际显示的数量，具体隐藏稿件：${[...bvidsToAddSet].join(',')}`\n\t\t\t\tlogger.warning(tip)\n\t\t\t\ttoast.info(tip)\n\t\t\t\t// 在复制的 allBvids Set 中删除隐藏的视频\n\t\t\t\tfor (const bvid of bvidsToAddSet) {\n\t\t\t\t\tafterRemovedHiddenBvidsAllBvids.delete(bvid)\n\t\t\t\t}\n\t\t\t}\n\t\t\tlogger.debug('step 4: 获取要添加的 tracks 元数据完成', {\n\t\t\t\tadded: addedTracksMetadata.size,\n\t\t\t\trequestApiTimes: nowPageNumber,\n\t\t\t})\n\n\t\t\tonProgress?.({\n\t\t\t\tmessage: '正在保存数据到数据库...',\n\t\t\t\tstage: 'saving',\n\t\t\t})\n\t\t\tconst txResult = await ResultAsync.fromPromise(\n\t\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\t\t\t\t\tconst trackSvc = this.trackService.withDB(tx)\n\t\t\t\t\tconst artistSvc = this.artistService.withDB(tx)\n\n\t\t\t\t\tconst playlistAuthor = await artistSvc.findOrCreateArtist({\n\t\t\t\t\t\tname: bilibiliFavoriteListMetadata.info!.upper.name,\n\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\tremoteId: String(bilibiliFavoriteListMetadata.info!.upper.mid),\n\t\t\t\t\t\tavatarUrl: bilibiliFavoriteListMetadata.info!.upper.face,\n\t\t\t\t\t})\n\t\t\t\t\tif (playlistAuthor.isErr()) {\n\t\t\t\t\t\tthrow playlistAuthor.error\n\t\t\t\t\t}\n\n\t\t\t\t\tconst localPlaylist = await playlistSvc.findOrCreateRemotePlaylist({\n\t\t\t\t\t\ttitle: bilibiliFavoriteListMetadata.info!.title,\n\t\t\t\t\t\tdescription: bilibiliFavoriteListMetadata.info!.intro,\n\t\t\t\t\t\tcoverUrl: bilibiliFavoriteListMetadata.info!.cover,\n\t\t\t\t\t\ttype: 'favorite',\n\t\t\t\t\t\tremoteSyncId: favoriteId,\n\t\t\t\t\t\tauthorId: playlistAuthor.value.id,\n\t\t\t\t\t})\n\t\t\t\t\tif (localPlaylist.isErr()) {\n\t\t\t\t\t\tthrow localPlaylist.error\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug('step 5: 创建 playlist 和其对应的 author 信息完成', {\n\t\t\t\t\t\tlocalPlaylistId: localPlaylist.value.id,\n\t\t\t\t\t\tartistId: playlistAuthor.value.id,\n\t\t\t\t\t})\n\n\t\t\t\t\tconst uniqueArtistPayloadsMap = new Map<string, CreateArtistPayload>()\n\t\t\t\t\tfor (const trackMeta of addedTracksMetadata) {\n\t\t\t\t\t\tconst remoteId = String(trackMeta.upper.mid)\n\t\t\t\t\t\tif (!uniqueArtistPayloadsMap.has(remoteId)) {\n\t\t\t\t\t\t\tuniqueArtistPayloadsMap.set(remoteId, {\n\t\t\t\t\t\t\t\tname: trackMeta.upper.name,\n\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\tremoteId: remoteId,\n\t\t\t\t\t\t\t\tavatarUrl: trackMeta.upper.face,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tconst uniqueArtistPayloads = Array.from(\n\t\t\t\t\t\tuniqueArtistPayloadsMap.values(),\n\t\t\t\t\t)\n\t\t\t\t\tconst artistsMap =\n\t\t\t\t\t\tawait artistSvc.findOrCreateManyRemoteArtists(uniqueArtistPayloads)\n\t\t\t\t\tif (artistsMap.isErr()) {\n\t\t\t\t\t\tthrow artistsMap.error\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug('step 6: 创建 artist 完成', {\n\t\t\t\t\t\ttotal: artistsMap.value.size,\n\t\t\t\t\t})\n\n\t\t\t\t\tconst addedTrackPayloads = Array.from(addedTracksMetadata).map(\n\t\t\t\t\t\t(v) => ({\n\t\t\t\t\t\t\ttitle: v.title,\n\t\t\t\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\t\t\t\tbvid: v.bvid,\n\t\t\t\t\t\t\t\tisMultiPage: false,\n\t\t\t\t\t\t\t\tcid: null,\n\t\t\t\t\t\t\t\tvideoIsValid: v.attr === 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tcoverUrl: v.cover,\n\t\t\t\t\t\t\tduration: v.duration,\n\t\t\t\t\t\t\tartistId: artistsMap.value.get(String(v.upper.mid))?.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\n\t\t\t\t\tconst trackPayloadsWithKeysResult = Result.combine(\n\t\t\t\t\t\taddedTrackPayloads.map((p) =>\n\t\t\t\t\t\t\tgenerateUniqueTrackKey(p).map((uniqueKey) => ({\n\t\t\t\t\t\t\t\tpayload: p,\n\t\t\t\t\t\t\t\tuniqueKey,\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\tif (trackPayloadsWithKeysResult.isErr()) {\n\t\t\t\t\t\tthrow trackPayloadsWithKeysResult.error\n\t\t\t\t\t}\n\t\t\t\t\tconst trackPayloadsWithKeys = trackPayloadsWithKeysResult.value\n\n\t\t\t\t\tconst createdTracksMapResult = await trackSvc.findOrCreateManyTracks(\n\t\t\t\t\t\ttrackPayloadsWithKeys.map((p) => p.payload),\n\t\t\t\t\t\t'bilibili',\n\t\t\t\t\t)\n\n\t\t\t\t\tif (createdTracksMapResult.isErr()) {\n\t\t\t\t\t\tthrow createdTracksMapResult.error\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t'step 7: 创建或查找 tracks 并获取 uniqueKey->id 映射完成',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttotal: createdTracksMapResult.value.size,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\n\t\t\t\t\t// 在这里我们使用清洗过后的 afterRemovedHiddenBvidsAllBvids，而非原始的 bilibiliFavoriteListAllBvids\n\t\t\t\t\t// 因为在原始数据中，可能存在隐藏的视频，但是在清洗后，这些视频已经被删除了\n\t\t\t\t\tconst orderedUniqueKeysResult = Result.combine(\n\t\t\t\t\t\tArray.from(afterRemovedHiddenBvidsAllBvids).map((bvid) =>\n\t\t\t\t\t\t\tgenerateUniqueTrackKey({\n\t\t\t\t\t\t\t\tsource: 'bilibili',\n\t\t\t\t\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\t\t\t\t\tbvid: bvid,\n\t\t\t\t\t\t\t\t\tisMultiPage: false,\n\t\t\t\t\t\t\t\t\tvideoIsValid: true,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\tif (orderedUniqueKeysResult.isErr()) {\n\t\t\t\t\t\tthrow orderedUniqueKeysResult.error\n\t\t\t\t\t}\n\t\t\t\t\tconst orderedUniqueKeys = orderedUniqueKeysResult.value\n\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t'step 8: 为远程所有 tracks 生成了其对应的 uniqueKey 顺序列表',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttotal: orderedUniqueKeys.length,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\n\t\t\t\t\tconst uniqueKeyToIdMapResult =\n\t\t\t\t\t\tawait trackSvc.findTrackIdsByUniqueKeys(orderedUniqueKeys)\n\t\t\t\t\tif (uniqueKeyToIdMapResult.isErr()) {\n\t\t\t\t\t\tthrow uniqueKeyToIdMapResult.error\n\t\t\t\t\t}\n\t\t\t\t\tconst uniqueKeyToIdMap = uniqueKeyToIdMapResult.value\n\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t'step 9: 一次性获取所有 uniqueKey 到本地 ID 的映射完成',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttotal: uniqueKeyToIdMap.size,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\n\t\t\t\t\tconst finalOrderedTrackIds = orderedUniqueKeys\n\t\t\t\t\t\t.map((key) => uniqueKeyToIdMap.get(key))\n\t\t\t\t\t\t.filter((id) => {\n\t\t\t\t\t\t\tif (id === undefined)\n\t\t\t\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t\t\t\t'SyncFavoriteFailed',\n\t\t\t\t\t\t\t\t\t'已完成 tracks 创建后，却依然没有找到 uniqueKey 对应的 ID',\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\treturn id !== undefined\n\t\t\t\t\t\t})\n\t\t\t\t\tlogger.debug('step 10: 按 Bilibili 收藏夹顺序重排所有 tracks 完成', {\n\t\t\t\t\t\ttotal: finalOrderedTrackIds.length,\n\t\t\t\t\t})\n\n\t\t\t\t\tconst replaceResult = await playlistSvc.replacePlaylistAllTracks(\n\t\t\t\t\t\tlocalPlaylist.value.id,\n\t\t\t\t\t\tfinalOrderedTrackIds,\n\t\t\t\t\t)\n\t\t\t\t\tif (replaceResult.isErr()) {\n\t\t\t\t\t\tthrow replaceResult.error\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug('step 11: 替换 playlist 中所有 tracks 完成')\n\t\t\t\t\tlogger.info('同步收藏夹完成', {\n\t\t\t\t\t\tremoteId: favoriteId,\n\t\t\t\t\t\tplaylistId: localPlaylist.value.id,\n\t\t\t\t\t})\n\n\t\t\t\t\tvoid analyticsService.logPlaylistSync(\n\t\t\t\t\t\t'sync_bilibili',\n\t\t\t\t\t\t'favorite',\n\t\t\t\t\t\tfinalOrderedTrackIds.length,\n\t\t\t\t\t)\n\n\t\t\t\t\treturn localPlaylist.value.id\n\t\t\t\t}),\n\t\t\t\t(e) =>\n\t\t\t\t\tcreateFacadeError('SyncFavoriteFailed', '同步收藏夹失败', {\n\t\t\t\t\t\tcause: e,\n\t\t\t\t\t}),\n\t\t\t)\n\t\t\tif (txResult.isErr()) {\n\t\t\t\treturn err(txResult.error)\n\t\t\t}\n\t\t\treturn ok(txResult.value)\n\t\t} finally {\n\t\t\tthis.syncingIds.delete(`favorite::${favoriteId}`)\n\t\t}\n\t}\n\n\t/**\n\t * 根据传入的同步 ID 和类型同步播放列表\n\t * @param remoteSyncId 远程同步 ID\n\t * @param type 播放列表类型\n\t * @returns\n\t */\n\tpublic sync(\n\t\tremoteSyncId: number,\n\t\ttype: Playlist['type'],\n\t\tonProgress?: (progress: FavoriteSyncProgress) => void,\n\t) {\n\t\tswitch (type) {\n\t\t\tcase 'favorite': {\n\t\t\t\treturn this.syncFavorite(remoteSyncId, onProgress)\n\t\t\t}\n\t\t\tcase 'collection': {\n\t\t\t\treturn this.syncCollection(remoteSyncId)\n\t\t\t}\n\t\t\tcase 'multi_page': {\n\t\t\t\treturn this.syncMultiPageVideo(av2bv(remoteSyncId))\n\t\t\t}\n\t\t\tcase 'local': {\n\t\t\t\treturn okAsync(undefined)\n\t\t\t}\n\t\t\tcase 'dynamic': {\n\t\t\t\treturn okAsync(undefined)\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic get dbInstance() {\n\t\treturn this.db\n\t}\n}\n\nexport const syncFacade = new SyncBilibiliPlaylistFacade(\n\ttrackService,\n\tbilibiliApi,\n\tplaylistService,\n\tartistService,\n\tdb,\n)\n"
  },
  {
    "path": "apps/mobile/src/lib/facades/syncExternalPlaylist.ts",
    "content": "import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { ResultAsync } from 'neverthrow'\n\nimport db from '@/lib/db/db'\nimport type * as schema from '@/lib/db/schema'\nimport type { DatabaseError, ServiceError } from '@/lib/errors'\nimport type { FacadeError } from '@/lib/errors/facade'\nimport { createFacadeError } from '@/lib/errors/facade'\nimport { analyticsService } from '@/lib/services/analyticsService'\nimport type { ArtistService } from '@/lib/services/artistService'\nimport { artistService } from '@/lib/services/artistService'\nimport type { MatchResult } from '@/lib/services/externalPlaylistService'\nimport generateUniqueTrackKey from '@/lib/services/genKey'\nimport type { PlaylistService } from '@/lib/services/playlistService'\nimport { playlistService } from '@/lib/services/playlistService'\nimport type { TrackService } from '@/lib/services/trackService'\nimport { trackService } from '@/lib/services/trackService'\nimport log from '@/utils/log'\nimport { parseDurationString } from '@/utils/time'\n\nconst logger = log.extend('Facade/syncExternalPlaylist')\n\nexport class SyncExternalPlaylistFacade {\n\tconstructor(\n\t\tprivate readonly trackService: TrackService,\n\t\tprivate readonly playlistService: PlaylistService,\n\t\tprivate readonly artistService: ArtistService,\n\t\tprivate readonly db: ExpoSQLiteDatabase<typeof schema>,\n\t) {}\n\n\t/**\n\t * 保存匹配后的外部歌单到本地\n\t * @param playlistInfo 歌单信息\n\t * @param matchResults 匹配结果\n\t */\n\tpublic saveMatchedPlaylist(\n\t\tplaylistInfo: {\n\t\t\ttitle: string\n\t\t\tcoverUrl: string\n\t\t\tdescription: string\n\t\t},\n\t\tmatchResults: MatchResult[],\n\t): ResultAsync<number, FacadeError | DatabaseError | ServiceError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tthis.db.transaction(async (tx) => {\n\t\t\t\tconst playlistSvc = this.playlistService.withDB(tx)\n\t\t\t\tconst trackSvc = this.trackService.withDB(tx)\n\t\t\t\tconst artistSvc = this.artistService.withDB(tx)\n\n\t\t\t\t// 1. 提取所有需要创建/查找的 Artist\n\t\t\t\tconst uniqueArtistsMap = new Map<\n\t\t\t\t\tstring,\n\t\t\t\t\t{ name: string; remoteId: string; face?: string }\n\t\t\t\t>()\n\n\t\t\t\tconst validMatches = matchResults.filter((r) => r.matchedVideo !== null)\n\t\t\t\tif (validMatches.length === 0) {\n\t\t\t\t\tthrow createFacadeError(\n\t\t\t\t\t\t'SavePlaylistFailed',\n\t\t\t\t\t\t'没有匹配到任何歌曲，无法保存',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tfor (const match of validMatches) {\n\t\t\t\t\tconst video = match.matchedVideo!\n\t\t\t\t\tconst remoteId = String(video.mid)\n\t\t\t\t\tif (!uniqueArtistsMap.has(remoteId)) {\n\t\t\t\t\t\tuniqueArtistsMap.set(remoteId, {\n\t\t\t\t\t\t\tname: video.author,\n\t\t\t\t\t\t\tremoteId: remoteId,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst artistPayloads = Array.from(uniqueArtistsMap.values()).map(\n\t\t\t\t\t(artist) => ({\n\t\t\t\t\t\tname: artist.name,\n\t\t\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\t\t\tremoteId: artist.remoteId,\n\t\t\t\t\t\tavatarUrl: undefined,\n\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tconst artistsMapResult =\n\t\t\t\t\tawait artistSvc.findOrCreateManyRemoteArtists(artistPayloads)\n\t\t\t\tif (artistsMapResult.isErr()) throw artistsMapResult.error\n\t\t\t\tconst artistsMap = artistsMapResult.value\n\n\t\t\t\t// 2. 创建 Tracks\n\t\t\t\tconst trackPayloads = validMatches.map((match) => {\n\t\t\t\t\tconst video = match.matchedVideo!\n\t\t\t\t\tconst artistId = artistsMap.get(String(video.mid))?.id\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\ttitle: video.title.replace(/<em[^>]*>|<\\/em>/g, ''), // 去除高亮标签\n\t\t\t\t\t\tsource: 'bilibili' as const,\n\t\t\t\t\t\tbilibiliMetadata: {\n\t\t\t\t\t\t\tbvid: video.bvid,\n\t\t\t\t\t\t\tisMultiPage: false,\n\t\t\t\t\t\t\tcid: undefined,\n\t\t\t\t\t\t\tvideoIsValid: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tcoverUrl: video.pic.startsWith('//')\n\t\t\t\t\t\t\t? `https:${video.pic}`\n\t\t\t\t\t\t\t: video.pic,\n\t\t\t\t\t\tduration: parseDurationString(video.duration),\n\t\t\t\t\t\tartistId: artistId,\n\t\t\t\t\t}\n\t\t\t\t})\n\n\t\t\t\tconst tracksResult = await trackSvc.findOrCreateManyTracks(\n\t\t\t\t\ttrackPayloads,\n\t\t\t\t\t'bilibili',\n\t\t\t\t)\n\t\t\t\tif (tracksResult.isErr()) throw tracksResult.error\n\t\t\t\tconst trackIdsMap = tracksResult.value\n\n\t\t\t\t// 3. 按照原始 matchResults 的顺序（保持用户看到的顺序）收集 ID\n\t\t\t\tconst orderedTrackIds: number[] = []\n\t\t\t\tfor (const payload of trackPayloads) {\n\t\t\t\t\tconst keyResult = generateUniqueTrackKey(payload)\n\t\t\t\t\tif (keyResult.isOk()) {\n\t\t\t\t\t\tconst id = trackIdsMap.get(keyResult.value)\n\t\t\t\t\t\tif (id) {\n\t\t\t\t\t\t\torderedTrackIds.push(id)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 4. 创建 Playlist\n\t\t\t\tconst playlistResult = await playlistSvc.createPlaylist({\n\t\t\t\t\ttitle: playlistInfo.title,\n\t\t\t\t\tdescription: playlistInfo.description,\n\t\t\t\t\tcoverUrl: playlistInfo.coverUrl,\n\t\t\t\t\ttype: 'local', // 另存为本地歌单\n\t\t\t\t\tauthorId: undefined, // 本地歌单没有 strict author\n\t\t\t\t})\n\t\t\t\tif (playlistResult.isErr()) throw playlistResult.error\n\t\t\t\tconst playlistId = playlistResult.value.id\n\n\t\t\t\t// 5. 添加 Tracks 到 Playlist\n\t\t\t\tconst addTracksResult = await playlistSvc.addManyTracksToLocalPlaylist(\n\t\t\t\t\tplaylistId,\n\t\t\t\t\torderedTrackIds,\n\t\t\t\t)\n\t\t\t\tif (addTracksResult.isErr()) throw addTracksResult.error\n\n\t\t\t\tlogger.info('Save matched playlist success', { playlistId })\n\t\t\t\tvoid analyticsService.logPlaylistSync(\n\t\t\t\t\t'sync_external',\n\t\t\t\t\t'external',\n\t\t\t\t\torderedTrackIds.length,\n\t\t\t\t)\n\t\t\t\treturn playlistId\n\t\t\t}),\n\t\t\t(e) =>\n\t\t\t\te instanceof Error\n\t\t\t\t\t? createFacadeError('SavePlaylistFailed', e.message, { cause: e })\n\t\t\t\t\t: createFacadeError('SavePlaylistFailed', String(e)),\n\t\t)\n\t}\n}\n\nexport const syncExternalPlaylistFacade = new SyncExternalPlaylistFacade(\n\ttrackService,\n\tplaylistService,\n\tartistService,\n\tdb,\n)\n"
  },
  {
    "path": "apps/mobile/src/lib/player/PlayerSideEffects.ts",
    "content": "import {\n\tOrpheus,\n\tregisterOrpheusHeadlessTask,\n\ttype PlaybackErrorEvent,\n} from '@bbplayer/orpheus'\nimport { fetch as NetInfoFetch } from '@react-native-community/netinfo'\n\nimport { lyricsQueryKeys } from '@/hooks/queries/lyrics'\nimport { queryClient } from '@/lib/config/queryClient'\nimport lyricService from '@/lib/services/lyricService'\nimport log, { reportErrorToSentry } from '@/utils/log'\nimport { isActuallyOffline } from '@/utils/network'\nimport { finalizeAndRecordCurrentTrack } from '@/utils/player'\nimport toast from '@/utils/toast'\n\nconst logger = log.extend('Manager.PlayerSideEffects')\n\nclass PlayerSideEffects {\n\tprivate initialized = false\n\n\tpublic initialize() {\n\t\tif (this.initialized) return\n\t\tthis.initialized = true\n\n\t\tlogger.info('Initializing PlayerSideEffects')\n\n\t\t// 预加载功能完全没必要，当初那个鲨臂让我加的？？？？？\n\t\t// Orpheus.addListener('onTrackStarted', () => {\n\t\t// \tlogger.debug('Track started, triggering side effects')\n\t\t// \tvoid lyricService.preloadNextTrackLyrics()\n\t\t// })\n\n\t\t// 注册原生播放器 headless task\n\t\tthis.registerHeadlessTask()\n\n\t\t// 设置播放器错误处理\n\t\tthis.setupErrorHandler()\n\t}\n\n\t/**\n\t * 注册原生播放器 Headless Task\n\t * 处理来自原生层的播放事件（如曲目开始、结束、歌词清空等）\n\t */\n\tprivate registerHeadlessTask() {\n\t\tregisterOrpheusHeadlessTask(async (event) => {\n\t\t\tif (event.eventName === 'onTrackStarted') {\n\t\t\t\tlyricService.pushLyricsToOverlays(event.trackId)\n\t\t\t} else if (event.eventName === 'onTrackFinished') {\n\t\t\t\tvoid finalizeAndRecordCurrentTrack(\n\t\t\t\t\tevent.trackId,\n\t\t\t\t\tevent.duration,\n\t\t\t\t\tevent.finalPosition,\n\t\t\t\t)\n\t\t\t} else if (event.eventName === 'onRequestClearLyrics') {\n\t\t\t\t// 桌面歌词面板「清空歌词」按钮被点击时，标记该曲目跳过歌词\n\t\t\t\tlogger.info('收到清空歌词请求', { trackId: event.trackId })\n\t\t\t\tawait lyricService.skipLyric(event.trackId)\n\t\t\t\t// 使 React Query 缓存失效，让歌词面板立即显示跳过提示\n\t\t\t\tvoid queryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: lyricsQueryKeys.smartFetchLyrics(event.trackId),\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t}\n\n\t/**\n\t * 解析播放器错误信息，返回友好的错误消息和是否需要上报 Sentry\n\t */\n\tprivate async getPlayerErrorInfo(\n\t\tevent: PlaybackErrorEvent,\n\t): Promise<{ message: string; shouldReport: boolean }> {\n\t\t// Android: rootCauseMessage, message, errorCode\n\t\t// iOS: error\n\t\tconst rawMessage =\n\t\t\t('rootCauseMessage' in event ? event.rootCauseMessage : null) ||\n\t\t\t('message' in event ? event.message : null) ||\n\t\t\t''\n\t\tconst code = 'errorCode' in event ? event.errorCode : null\n\n\t\tif (rawMessage.includes('Bilibili API Error')) {\n\t\t\tconst codeMatch = rawMessage.match(/code=(-?\\d+)/)\n\t\t\tconst msgMatch = rawMessage.match(/msg=(.+)/)\n\t\t\tconst code = codeMatch ? codeMatch[1] : 'Unknown'\n\t\t\tconst msg = msgMatch ? msgMatch[1] : 'Unknown Error'\n\n\t\t\tif (code === '-412') {\n\t\t\t\treturn {\n\t\t\t\t\tmessage: 'Bilibili 触发验证码，请尝试重新登录或稍后再试',\n\t\t\t\t\tshouldReport: false,\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (code === '-101') {\n\t\t\t\treturn { message: 'Bilibili 账号未登录', shouldReport: false }\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tmessage: `Bilibili API 错误: ${msg} (${code})`,\n\t\t\t\tshouldReport: false,\n\t\t\t}\n\t\t}\n\n\t\tif (rawMessage.includes('Bilibili API Logic Error')) {\n\t\t\treturn {\n\t\t\t\tmessage: 'Bilibili 数据解析失败，请检查网络或稍后再试',\n\t\t\t\tshouldReport: false,\n\t\t\t}\n\t\t}\n\n\t\tif (rawMessage.includes('AudioStreamError')) {\n\t\t\treturn {\n\t\t\t\tmessage: '无法获取音频流，可能需要大会员或该歌曲已下架',\n\t\t\t\tshouldReport: false,\n\t\t\t}\n\t\t}\n\n\t\tif (rawMessage.includes('Bilibili API Http Error')) {\n\t\t\tconst codeMatch = rawMessage.match(/Http Error: (\\d+)/)\n\t\t\treturn {\n\t\t\t\tmessage: `Bilibili 网络请求失败: ${codeMatch ? codeMatch[1] : 'Unknown'}`,\n\t\t\t\tshouldReport: false,\n\t\t\t}\n\t\t}\n\n\t\tif (event.platform === 'android') {\n\t\t\tconst networkState = await NetInfoFetch()\n\t\t\tconst rootMessage = [\n\t\t\t\tevent.rootCauseClass,\n\t\t\t\tevent.rootCauseMessage,\n\t\t\t\tevent.message,\n\t\t\t\tevent.errorCodeName,\n\t\t\t]\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.join(' ')\n\n\t\t\tconst offlinePlaybackErrorPattern =\n\t\t\t\t/resolve url failed|unknownhost|failed to connect|network is unreachable|unable to resolve host/i\n\n\t\t\t// 2000-2999 是关于 IO 或 NETWORK 的问题。\n\t\t\tif (\n\t\t\t\tisActuallyOffline(networkState) &&\n\t\t\t\tcode &&\n\t\t\t\tcode >= 2000 &&\n\t\t\t\tcode < 3000\n\t\t\t) {\n\t\t\t\treturn {\n\t\t\t\t\tmessage: '当前歌曲未缓存，离线状态下无法播放(或存在其他IO/网络问题)',\n\t\t\t\t\tshouldReport: false,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tisActuallyOffline(networkState) &&\n\t\t\t\tofflinePlaybackErrorPattern.test(rootMessage)\n\t\t\t) {\n\t\t\t\treturn {\n\t\t\t\t\tmessage: '当前歌曲未缓存，离线状态下无法播放',\n\t\t\t\t\tshouldReport: false,\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (\n\t\t\trawMessage.includes('Unable to connect') ||\n\t\t\trawMessage.includes('UnknownHostException') ||\n\t\t\trawMessage.includes('ConnectException') ||\n\t\t\trawMessage.includes('SocketTimeoutException')\n\t\t) {\n\t\t\treturn { message: '网络连接失败，请检查网络设置', shouldReport: false }\n\t\t}\n\n\t\treturn {\n\t\t\tmessage:\n\t\t\t\t('message' in event ? event.message : null) || '播放器发生未知错误',\n\t\t\tshouldReport: true,\n\t\t}\n\t}\n\n\t/**\n\t * 将原生错误事件转换为 Sentry Error 对象\n\t */\n\tprivate toSentryError(event: PlaybackErrorEvent): Error {\n\t\tif (event.platform === 'android') {\n\t\t\treturn new Error(\n\t\t\t\tevent.rootCauseMessage ||\n\t\t\t\t\tevent.message ||\n\t\t\t\t\tevent.errorCodeName ||\n\t\t\t\t\t'Unknown playback error',\n\t\t\t)\n\t\t}\n\t\treturn new Error(String(event.error || 'Unknown playback error'))\n\t}\n\n\t/**\n\t * 设置播放器错误监听处理\n\t */\n\tprivate setupErrorHandler() {\n\t\tOrpheus.addListener('onPlayerError', async (event) => {\n\t\t\tlogger.error('播放器错误事件：', { event })\n\n\t\t\tlet playerErrorInfo = {\n\t\t\t\tmessage:\n\t\t\t\t\t('message' in event ? event.message : null) || '播放器发生未知错误',\n\t\t\t\tshouldReport: true,\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\ttry {\n\t\t\t\t\tplayerErrorInfo = await this.getPlayerErrorInfo(event)\n\t\t\t\t} catch (error) {\n\t\t\t\t\tlogger.error('解析播放器错误失败：', { error, event })\n\t\t\t\t}\n\n\t\t\t\ttoast.error(playerErrorInfo.message, {\n\t\t\t\t\tdescription:\n\t\t\t\t\t\t'errorCode' in event ? String(event.errorCode) : undefined,\n\t\t\t\t})\n\n\t\t\t\tif (playerErrorInfo.shouldReport) {\n\t\t\t\t\treportErrorToSentry(\n\t\t\t\t\t\tthis.toSentryError(event),\n\t\t\t\t\t\t'播放器错误事件',\n\t\t\t\t\t\t'Native.Player',\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('处理播放器错误事件失败：', { error, event })\n\t\t\t}\n\t\t})\n\t}\n}\n\nexport const playerSideEffects = new PlayerSideEffects()\n"
  },
  {
    "path": "apps/mobile/src/lib/player/progressListener.ts",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\n\nimport createStickyEmitter from '@/utils/sticky-mitt'\n\ninterface Events {\n\tprogress: {\n\t\tposition: number\n\t\tduration: number\n\t\tbuffered: number\n\t}\n}\nconst playerProgressEmitter = createStickyEmitter<Events>()\n\nOrpheus.addListener('onPositionUpdate', (e) => {\n\tplayerProgressEmitter.emitSticky('progress', {\n\t\tposition: e.position,\n\t\tduration: e.duration,\n\t\tbuffered: e.buffered,\n\t})\n})\n\nexport default playerProgressEmitter\n"
  },
  {
    "path": "apps/mobile/src/lib/services/analyticsService.ts",
    "content": "import {\n\tgetAnalytics,\n\tlogEvent,\n\tlogScreenView,\n\tsetAnalyticsCollectionEnabled,\n\tsetUserProperty,\n} from '@react-native-firebase/analytics'\n\nimport log from '@/utils/log'\n\nconst logger = log.extend('Service.Analytics')\n\ntype PlayerAction =\n\t| 'play'\n\t| 'pause'\n\t| 'skip_next'\n\t| 'skip_prev'\n\t| 'shuffle'\n\t| 'repeat'\ntype PlayerQueueAction = 'open_queue' | 'play_item'\ntype PlaylistSyncAction = 'sync_bilibili' | 'sync_external'\n\nclass AnalyticsService {\n\tprivate async safeLogEvent(name: string, params?: Record<string, unknown>) {\n\t\ttry {\n\t\t\tawait logEvent(getAnalytics(), name, params)\n\t\t\tlogger.debug(`[Analytics] Logged event: ${name}`, params)\n\t\t} catch (error) {\n\t\t\tlogger.warning(`[Analytics] Failed to log event: ${name}`, { error })\n\t\t}\n\t}\n\n\tpublic async logPlayerAction(\n\t\taction: PlayerAction,\n\t\tparams?: Record<string, unknown>,\n\t) {\n\t\tawait this.safeLogEvent('player_action', {\n\t\t\taction,\n\t\t\t...params,\n\t\t})\n\t}\n\n\tpublic async logPlayerQueueAction(action: PlayerQueueAction) {\n\t\tawait this.safeLogEvent('player_queue_action', {\n\t\t\taction,\n\t\t})\n\t}\n\n\tpublic async logPlaylistSync(\n\t\taction: PlaylistSyncAction,\n\t\ttargetType: 'collection' | 'favorite' | 'multi_page' | 'external',\n\t\titemCount: number,\n\t) {\n\t\tawait this.safeLogEvent('playlist_sync', {\n\t\t\taction,\n\t\t\ttarget_type: targetType,\n\t\t\titem_count: itemCount,\n\t\t})\n\t}\n\n\tpublic async logSearch(type: 'global' | 'fav') {\n\t\tawait this.safeLogEvent('search', {\n\t\t\tsearch_type: type,\n\t\t})\n\t}\n\n\tpublic async logScreenView(\n\t\tscreenName: string,\n\t\tscreenclass: string = screenName,\n\t) {\n\t\ttry {\n\t\t\tawait logScreenView(getAnalytics(), {\n\t\t\t\tscreen_name: screenName,\n\t\t\t\tscreen_class: screenclass,\n\t\t\t})\n\t\t\tlogger.debug(`[Analytics] Logged screen view: ${screenName}`)\n\t\t} catch (error) {\n\t\t\tlogger.warning(`[Analytics] Failed to log screen view: ${screenName}`, {\n\t\t\t\terror,\n\t\t\t})\n\t\t}\n\t}\n\n\tpublic async setUserProperty(name: string, value: string) {\n\t\ttry {\n\t\t\tawait setUserProperty(getAnalytics(), name, value)\n\t\t\tlogger.debug(`[Analytics] Set user property: ${name}=${value}`)\n\t\t} catch (error) {\n\t\t\tlogger.warning(\n\t\t\t\t`[Analytics] Failed to set user property: ${name}=${value}`,\n\t\t\t\t{\n\t\t\t\t\terror,\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\t}\n\n\tpublic async logPlaybackSession(durationSeconds: number) {\n\t\tawait this.safeLogEvent('playback_session', {\n\t\t\tduration_seconds: durationSeconds,\n\t\t})\n\t}\n\n\tpublic async setAnalyticsCollectionEnabled(enabled: boolean) {\n\t\tawait setAnalyticsCollectionEnabled(getAnalytics(), enabled)\n\t\tlogger.debug(`[Analytics] Collection enabled: ${enabled}`)\n\t}\n\n\tpublic async logAppInfo(version: string, buildVersion: string) {\n\t\tawait this.setUserProperty('app_version', version)\n\t\tawait this.setUserProperty('build_version', buildVersion)\n\t}\n}\n\nexport const analyticsService = new AnalyticsService()\n"
  },
  {
    "path": "apps/mobile/src/lib/services/artistService.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport { and, eq, or } from 'drizzle-orm'\nimport { type ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { ResultAsync, errAsync, okAsync } from 'neverthrow'\n\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { ServiceError } from '@/lib/errors'\nimport {\n\tDatabaseError,\n\tcreateArtistNotFound,\n\tcreateValidationError,\n} from '@/lib/errors/service'\nimport type { Track } from '@/types/core/media'\nimport type {\n\tCreateArtistPayload,\n\tUpdateArtistPayload,\n} from '@/types/services/artist'\n\nimport type { TrackService } from './trackService'\nimport { trackService } from './trackService'\n\ntype Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]\ntype DBLike = ExpoSQLiteDatabase<typeof schema> | Tx\n\nexport class ArtistService {\n\tconstructor(\n\t\tprivate readonly db: DBLike,\n\t\tprivate readonly trackService: TrackService,\n\t) {}\n\n\t/**\n\t * 返回一个使用新数据库连接（例如事务）的新实例。\n\t * @param conn - 新的数据库连接或事务。\n\t * @returns 一个新的实例。\n\t */\n\tpublic withDB(conn: DBLike) {\n\t\treturn new ArtistService(conn, this.trackService.withDB(conn))\n\t}\n\n\t/**\n\t * 创建一个新的artist。\n\t * @param payload - 创建artist所需的数据。\n\t * @returns ResultAsync 包含成功创建的 Artist 或一个 DatabaseError。\n\t */\n\tpublic createArtist(\n\t\tpayload: CreateArtistPayload,\n\t): ResultAsync<typeof schema.artists.$inferSelect, DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:insert:artist', op: 'db' }, () =>\n\t\t\t\tthis.db\n\t\t\t\t\t.insert(schema.artists)\n\t\t\t\t\t.values({\n\t\t\t\t\t\tname: payload.name,\n\t\t\t\t\t\tsource: payload.source,\n\t\t\t\t\t\tremoteId: payload.remoteId,\n\t\t\t\t\t\tavatarUrl: payload.avatarUrl,\n\t\t\t\t\t\tsignature: payload.signature,\n\t\t\t\t\t} satisfies CreateArtistPayload)\n\t\t\t\t\t.returning(),\n\t\t\t),\n\t\t\t(e) => new DatabaseError('创建artist失败', { cause: e }),\n\t\t).andThen((result) => {\n\t\t\treturn okAsync(result[0])\n\t\t})\n\t}\n\n\t/**\n\t * 根据 source 和 remoteId 查找或创建一个artist。\n\t * 主要适用于外部源的数据\n\t * @param payload - 用于查找或创建artist的数据，必须包含 source 和 remoteId。\n\t * @returns ResultAsync 包含找到的或新创建的 Artist，或一个错误。\n\t */\n\tpublic findOrCreateArtist(\n\t\tpayload: CreateArtistPayload,\n\t): ResultAsync<\n\t\ttypeof schema.artists.$inferSelect,\n\t\tDatabaseError | ServiceError\n\t> {\n\t\tconst { source, remoteId } = payload\n\t\tif (!source || !remoteId) {\n\t\t\treturn errAsync(\n\t\t\t\tcreateValidationError('source 和 remoteId 在此方法中是必需的'),\n\t\t\t)\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 尝试查找已存在的artist\n\t\t\t\tconst existingArtist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:artist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.artists.findFirst({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.artists.source, source),\n\t\t\t\t\t\t\t\teq(schema.artists.remoteId, remoteId),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tif (existingArtist) {\n\t\t\t\t\treturn existingArtist\n\t\t\t\t}\n\n\t\t\t\t// 如果不存在，则创建新的artist\n\t\t\t\tconst [newArtist] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:insert:artist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.insert(schema.artists)\n\t\t\t\t\t\t\t.values({\n\t\t\t\t\t\t\t\tname: payload.name,\n\t\t\t\t\t\t\t\tsource: payload.source,\n\t\t\t\t\t\t\t\tremoteId: payload.remoteId,\n\t\t\t\t\t\t\t\tavatarUrl: payload.avatarUrl,\n\t\t\t\t\t\t\t\tsignature: payload.signature,\n\t\t\t\t\t\t\t} satisfies CreateArtistPayload)\n\t\t\t\t\t\t\t.returning(),\n\t\t\t\t)\n\n\t\t\t\treturn newArtist\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('查找或创建artist的事务失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 更新一个artist的信息。\n\t * @param artistId - 要更新的artist的 ID。\n\t * @param payload - 更新所需的数据。\n\t * @returns ResultAsync 包含更新后的 Artist 或一个错误。\n\t */\n\tpublic updateArtist(\n\t\tartistId: number,\n\t\tpayload: UpdateArtistPayload,\n\t): ResultAsync<\n\t\ttypeof schema.artists.$inferSelect,\n\t\tDatabaseError | ServiceError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 首先验证artist是否存在\n\t\t\t\tconst existing = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:artist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.artists.findFirst({\n\t\t\t\t\t\t\twhere: eq(schema.artists.id, artistId),\n\t\t\t\t\t\t\tcolumns: { id: true },\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!existing) {\n\t\t\t\t\tthrow createArtistNotFound(artistId)\n\t\t\t\t}\n\n\t\t\t\tconst [updated] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:update:artist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.update(schema.artists)\n\t\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\t\tname: payload.name ?? undefined,\n\t\t\t\t\t\t\t\tavatarUrl: payload.avatarUrl,\n\t\t\t\t\t\t\t\tsignature: payload.signature,\n\t\t\t\t\t\t\t} satisfies UpdateArtistPayload)\n\t\t\t\t\t\t\t.where(eq(schema.artists.id, artistId))\n\t\t\t\t\t\t\t.returning(),\n\t\t\t\t)\n\n\t\t\t\treturn updated\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`更新artist ${artistId} 失败`, {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 删除一个artist（与之关联的 track 的 artistId 会被设为 null）\n\t * @param artistId - 要删除的artist的 ID。\n\t * @returns ResultAsync 包含被删除的 ID 或一个错误。\n\t */\n\tpublic deleteArtist(\n\t\tartistId: number,\n\t): ResultAsync<{ deletedId: number }, DatabaseError | ServiceError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 验证artist是否存在\n\t\t\t\tconst existing = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:artist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.artists.findFirst({\n\t\t\t\t\t\t\twhere: eq(schema.artists.id, artistId),\n\t\t\t\t\t\t\tcolumns: { id: true },\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!existing) {\n\t\t\t\t\tthrow createArtistNotFound(artistId)\n\t\t\t\t}\n\n\t\t\t\tconst [deleted] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:delete:artist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.delete(schema.artists)\n\t\t\t\t\t\t\t.where(eq(schema.artists.id, artistId))\n\t\t\t\t\t\t\t.returning({ deletedId: schema.artists.id }),\n\t\t\t\t)\n\n\t\t\t\treturn deleted\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`删除artist ${artistId} 失败`, {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 获取指定artist创作的所有歌曲。\n\t * @param artistId - artist的 ID。\n\t * @returns ResultAsync 包含一个 Track 数组或一个错误。\n\t */\n\tpublic getArtistTracks(\n\t\tartistId: number,\n\t): ResultAsync<Track[], DatabaseError | ServiceError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:tracks', op: 'db' }, () =>\n\t\t\t\tthis.db.query.tracks.findMany({\n\t\t\t\t\twhere: eq(schema.tracks.artistId, artistId),\n\t\t\t\t\twith: {\n\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\tnew DatabaseError(`获取artist ${artistId} 的歌曲失败`, {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t).andThen((dbTracks) => {\n\t\t\tconst formattedTracks: Track[] = []\n\t\t\tfor (const dbTrack of dbTracks) {\n\t\t\t\tconst formatted = this.trackService.formatTrack(dbTrack)\n\t\t\t\tif (!formatted) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew ServiceError(\n\t\t\t\t\t\t\t`格式化歌曲 ${dbTrack.id} 时发生错误，可能是原数据不存在或 source & metadata 不匹配`,\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tformattedTracks.push(formatted)\n\t\t\t}\n\t\t\treturn okAsync(formattedTracks)\n\t\t})\n\t}\n\n\t/**\n\t * 获取所有artist。\n\t * @returns ResultAsync 包含所有 Artist 的数组或一个 DatabaseError。\n\t */\n\tpublic getAllArtists(): ResultAsync<\n\t\t(typeof schema.artists.$inferSelect)[],\n\t\tDatabaseError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:artists', op: 'db' }, () =>\n\t\t\t\tthis.db.query.artists.findMany(),\n\t\t\t),\n\t\t\t(e) => new DatabaseError('获取所有artist列表失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 根据 ID 获取单个artist的详细信息。\n\t * @param artistId - artist的 ID。\n\t * @returns ResultAsync 包含 Artist 或 undefined (如果未找到)，或一个 DatabaseError。\n\t */\n\tpublic getArtistById(\n\t\tartistId: number,\n\t): ResultAsync<\n\t\ttypeof schema.artists.$inferSelect | undefined,\n\t\tDatabaseError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:artist', op: 'db' }, () =>\n\t\t\t\tthis.db.query.artists.findFirst({\n\t\t\t\t\twhere: eq(schema.artists.id, artistId),\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\tnew DatabaseError(`通过 ID ${artistId} 获取artist失败`, {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 批量查找或创建 remote artist。\n\t * 接收一个 artist 数据数组，返回一个 remoteId -> artist 对象的映射。\n\t *\n\t * @param payloads - 一个包含多个 artist 创建信息的数组。\n\t */\n\tpublic findOrCreateManyRemoteArtists(\n\t\tpayloads: CreateArtistPayload[],\n\t): ResultAsync<\n\t\tMap<string, typeof schema.artists.$inferSelect>,\n\t\tServiceError\n\t> {\n\t\tif (payloads.length === 0) {\n\t\t\treturn okAsync(new Map<string, typeof schema.artists.$inferSelect>())\n\t\t}\n\n\t\tfor (const p of payloads) {\n\t\t\tif (!p.source || !p.remoteId) {\n\t\t\t\treturn errAsync(\n\t\t\t\t\tcreateValidationError(\n\t\t\t\t\t\t'payloads 中存在 source 或 remoteId 为空的对象，该方法仅用于处理 remote artist',\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tif (payloads.length > 0) {\n\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:insert:many:artists', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t\t.insert(schema.artists)\n\t\t\t\t\t\t\t\t.values(\n\t\t\t\t\t\t\t\t\tpayloads.map(\n\t\t\t\t\t\t\t\t\t\t(p) =>\n\t\t\t\t\t\t\t\t\t\t\t({\n\t\t\t\t\t\t\t\t\t\t\t\tname: p.name,\n\t\t\t\t\t\t\t\t\t\t\t\tsource: p.source,\n\t\t\t\t\t\t\t\t\t\t\t\tremoteId: p.remoteId,\n\t\t\t\t\t\t\t\t\t\t\t\tavatarUrl: p.avatarUrl,\n\t\t\t\t\t\t\t\t\t\t\t\tsignature: p.signature,\n\t\t\t\t\t\t\t\t\t\t\t}) satisfies CreateArtistPayload,\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t.onConflictDoNothing(),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst findConditions = payloads.map((p) =>\n\t\t\t\t\tand(\n\t\t\t\t\t\teq(schema.artists.source, p.source),\n\t\t\t\t\t\teq(schema.artists.remoteId, p.remoteId!),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\tconst allArtists = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:many:artists', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.artists.findMany({\n\t\t\t\t\t\t\twhere: or(...findConditions),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tconst fullArtists = payloads.map((p) => {\n\t\t\t\t\tconst existing = allArtists.find(\n\t\t\t\t\t\t(a) =>\n\t\t\t\t\t\t\t`${a.source}::${a.remoteId}` === `${p.source}::${p.remoteId}`,\n\t\t\t\t\t)\n\t\t\t\t\tif (existing) {\n\t\t\t\t\t\treturn existing\n\t\t\t\t\t}\n\t\t\t\t\tthrow new DatabaseError(\n\t\t\t\t\t\t`批量查找或创建 artists 后数据不一致，未找到 artist: ${p.source}::${p.remoteId}`,\n\t\t\t\t\t)\n\t\t\t\t})\n\t\t\t\tif (fullArtists.length !== payloads.length) {\n\t\t\t\t\tthrow new DatabaseError(\n\t\t\t\t\t\t'创建或查找 artists 后数据不一致，部分 artist 未能成功写入或查询。',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst finalResultMap = new Map(\n\t\t\t\t\tfullArtists.map((artist) => [artist.remoteId!, artist]),\n\t\t\t\t)\n\n\t\t\t\treturn finalResultMap\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('批量查找或创建 artist 失败', { cause: e }),\n\t\t)\n\t}\n}\n\nexport const artistService = new ArtistService(db, trackService)\n"
  },
  {
    "path": "apps/mobile/src/lib/services/externalPlaylistService.ts",
    "content": "import { decode } from 'he'\nimport { ResultAsync, errAsync } from 'neverthrow'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { neteaseApi } from '@/lib/api/netease/api'\nimport { qqMusicApi } from '@/lib/api/qqmusic/api'\nimport type { BilibiliSearchVideo } from '@/types/apis/bilibili'\nimport type { GenericPlaylist, GenericTrack } from '@/types/external_playlist'\nimport log from '@/utils/log'\nimport { cleanString, gaussian, lcsScore } from '@/utils/matching'\nimport { parseDurationString } from '@/utils/time'\n\nconst logger = log.extend('Services.ExternalPlaylist')\n\n// 全局配置\nconst MIN_DELAY = 1200 // 防封号延迟 (ms)\nconst BLACKLIST_ZONES = new Set([26, 29, 31, 201, 238]) // 黑名单分区 (音MAD, 现场, 翻唱, 科普, 运动)\nconst PRIORITY_ZONES = new Set([193, 130, 267]) // 优先分区 (MV, 音乐综合, 电台)\n\nconst wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\ninterface MatchCandidate {\n\tvideo: BilibiliSearchVideo\n\tscore: number\n}\n\nexport interface MatchResult {\n\ttrack: GenericTrack\n\tmatchedVideo: BilibiliSearchVideo | null\n}\n\nexport class ExternalPlaylistService {\n\tpublic fetchExternalPlaylist(\n\t\tplaylistId: string,\n\t\tsource: 'netease' | 'qq',\n\t): ResultAsync<{ playlist: GenericPlaylist; tracks: GenericTrack[] }, Error> {\n\t\tif (source === 'netease') {\n\t\t\treturn neteaseApi.getPlaylist(playlistId).map((response) => {\n\t\t\t\tif (!response.playlist) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tplaylist: {\n\t\t\t\t\t\t\tid: playlistId,\n\t\t\t\t\t\t\ttitle: 'Unknown Playlist',\n\t\t\t\t\t\t\tcoverUrl: '',\n\t\t\t\t\t\t\tdescription: '',\n\t\t\t\t\t\t\ttrackCount: 0,\n\t\t\t\t\t\t\tauthor: {\n\t\t\t\t\t\t\t\tname: 'Unknown',\n\t\t\t\t\t\t\t\tid: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\ttracks: [],\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst tracks = (response.playlist.tracks ?? []).map((track) => ({\n\t\t\t\t\ttitle: track.name,\n\t\t\t\t\tartists: track.ar.map((a) => a.name),\n\t\t\t\t\talbum: track.al.name,\n\t\t\t\t\tduration: track.dt,\n\t\t\t\t\tcoverUrl: track.al.picUrl.replace('http://', 'https://'),\n\t\t\t\t\ttranslatedTitle: track.tns?.[0],\n\t\t\t\t}))\n\n\t\t\t\treturn {\n\t\t\t\t\tplaylist: {\n\t\t\t\t\t\tid: response.playlist.id.toString(),\n\t\t\t\t\t\ttitle: response.playlist.name,\n\t\t\t\t\t\tcoverUrl: response.playlist.coverImgUrl,\n\t\t\t\t\t\tdescription: response.playlist.description ?? '',\n\t\t\t\t\t\ttrackCount: response.playlist.trackCount,\n\t\t\t\t\t\tauthor: {\n\t\t\t\t\t\t\tname: response.playlist.creator?.nickname ?? 'Unknown',\n\t\t\t\t\t\t\tid: response.playlist.creator?.userId ?? 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttracks,\n\t\t\t\t}\n\t\t\t})\n\t\t} else if (source === 'qq') {\n\t\t\treturn qqMusicApi.getPlaylist(playlistId).map((response) => {\n\t\t\t\tconst playlist = response.data.cdlist[0]\n\t\t\t\tif (!playlist)\n\t\t\t\t\treturn {\n\t\t\t\t\t\tplaylist: {\n\t\t\t\t\t\t\tid: playlistId,\n\t\t\t\t\t\t\ttitle: 'Unknown',\n\t\t\t\t\t\t\tcoverUrl: '',\n\t\t\t\t\t\t\tdescription: '',\n\t\t\t\t\t\t\ttrackCount: 0,\n\t\t\t\t\t\t\tauthor: { name: 'Unknown' },\n\t\t\t\t\t\t},\n\t\t\t\t\t\ttracks: [],\n\t\t\t\t\t}\n\n\t\t\t\tconst tracks = playlist.songlist.map((track) => ({\n\t\t\t\t\ttitle: track.name,\n\t\t\t\t\tartists: track.singer.map((s) => s.name),\n\t\t\t\t\talbum: track.album.name,\n\t\t\t\t\tduration: track.interval * 1000,\n\t\t\t\t\tcoverUrl: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${track.album.mid}.jpg`,\n\t\t\t\t\ttranslatedTitle: track.subtitle,\n\t\t\t\t}))\n\n\t\t\t\treturn {\n\t\t\t\t\tplaylist: {\n\t\t\t\t\t\tid: playlistId,\n\t\t\t\t\t\ttitle: playlist.dissname,\n\t\t\t\t\t\tcoverUrl: playlist.logo,\n\t\t\t\t\t\tdescription: playlist.desc || '',\n\t\t\t\t\t\ttrackCount: playlist.songnum,\n\t\t\t\t\t\tauthor: {\n\t\t\t\t\t\t\tname: playlist.nickname,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttracks,\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t\treturn errAsync(new Error('Unsupported source: ' + String(source)))\n\t}\n\n\tpublic matchExternalPlaylist(\n\t\ttracks: GenericTrack[],\n\t\tonProgress: (\n\t\t\tcurrent: number,\n\t\t\ttotal: number,\n\t\t\tresult: MatchResult,\n\t\t\ttrackIndex: number,\n\t\t) => void,\n\t\toptions?: {\n\t\t\tsignal?: AbortSignal\n\t\t\tstartIndex?: number\n\t\t\ttrackIndexes?: number[]\n\t\t},\n\t): ResultAsync<MatchResult[], Error> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst results: MatchResult[] = []\n\t\t\t\tconst total = tracks.length\n\t\t\t\tconst startIndex = options?.startIndex ?? 0\n\t\t\t\tconst indexesToProcess =\n\t\t\t\t\toptions?.trackIndexes ??\n\t\t\t\t\tArray.from(\n\t\t\t\t\t\t{ length: Math.max(total - startIndex, 0) },\n\t\t\t\t\t\t(_, index) => startIndex + index,\n\t\t\t\t\t)\n\t\t\t\tconst processingTotal = indexesToProcess.length\n\n\t\t\t\tfor (const [processedCount, trackIndex] of indexesToProcess.entries()) {\n\t\t\t\t\tif (options?.signal?.aborted) {\n\t\t\t\t\t\tthrow new Error('Aborted')\n\t\t\t\t\t}\n\n\t\t\t\t\tconst song = tracks[trackIndex]\n\t\t\t\t\tif (!song) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\tawait wait(MIN_DELAY)\n\n\t\t\t\t\t// Double check after wait\n\t\t\t\t\tif (options?.signal?.aborted) {\n\t\t\t\t\t\tthrow new Error('Aborted')\n\t\t\t\t\t}\n\n\t\t\t\t\tconst artistNames = song.artists.join(' ')\n\t\t\t\t\tconst searchQuery = `${song.title} ${song.translatedTitle ?? ''} - ${artistNames}`\n\n\t\t\t\t\tlet matchedVideo: BilibiliSearchVideo | null = null\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\t\tconst searchResult = await bilibiliApi.searchVideos(\n\t\t\t\t\t\t\tsearchQuery,\n\t\t\t\t\t\t\t1,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// 一点小巧思：带 cookie 调用搜索是会有个性化内容的，但在匹配时我认为个性化内容反而会干扰准确度\n\t\t\t\t\t\t\t\tskipCookie: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\n\t\t\t\t\t\tif (searchResult.isOk()) {\n\t\t\t\t\t\t\tconst decodedResults = searchResult.value.result.map((video) => ({\n\t\t\t\t\t\t\t\t...video,\n\t\t\t\t\t\t\t\ttitle: decode(video.title),\n\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\tmatchedVideo = this.findBestMatchSimple(decodedResults, song)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tlogger.error(\n\t\t\t\t\t\t\t\t`Search failed for ${song.title}:`,\n\t\t\t\t\t\t\t\tsearchResult.error,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tlogger.error(`Error processing ${song.title}:`, e)\n\t\t\t\t\t}\n\n\t\t\t\t\tconst result: MatchResult = {\n\t\t\t\t\t\ttrack: song,\n\t\t\t\t\t\tmatchedVideo: matchedVideo,\n\t\t\t\t\t}\n\t\t\t\t\tresults.push(result)\n\t\t\t\t\tonProgress(processedCount + 1, processingTotal, result, trackIndex)\n\t\t\t\t}\n\n\t\t\t\treturn results\n\t\t\t})(),\n\t\t\t(e) => (e instanceof Error ? e : new Error(String(e))),\n\t\t)\n\t}\n\n\t// 经过测试，反而这种简单的方式准确率更高。。。相信大数据.jpg\n\tprivate findBestMatchSimple(\n\t\tresults: BilibiliSearchVideo[],\n\t\ttargetSong: GenericTrack,\n\t): BilibiliSearchVideo | null {\n\t\tconst targetDurationSec = targetSong.duration / 1000\n\n\t\tfor (const video of results) {\n\t\t\t// 1. 黑名单过滤\n\t\t\tif (BLACKLIST_ZONES.has(video.typeid)) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 2. 时长过滤 (差异 > 20s 排除)\n\t\t\tconst videoDurationSec = parseDurationString(video.duration)\n\t\t\tconst durationDiff = Math.abs(videoDurationSec - targetDurationSec)\n\n\t\t\tif (durationDiff > 20) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 3. 直接返回第一个满足条件的\n\t\t\treturn video\n\t\t}\n\n\t\treturn null\n\t}\n\n\tprivate findBestMatch(\n\t\tresults: BilibiliSearchVideo[],\n\t\ttargetSong: GenericTrack,\n\t): BilibiliSearchVideo | null {\n\t\tconst candidates: MatchCandidate[] = []\n\n\t\tfor (const video of results) {\n\t\t\tconst score = this.rankScore(video, targetSong)\n\t\t\tif (score < 0.4) continue // 阈值过滤\n\n\t\t\tcandidates.push({ video, score })\n\t\t}\n\n\t\tif (candidates.length === 0) return null\n\n\t\tcandidates.sort((a, b) => b.score - a.score)\n\t\treturn candidates[0].video\n\t}\n\n\tprivate rankScore(\n\t\tvideo: BilibiliSearchVideo,\n\t\ttargetSong: GenericTrack,\n\t): number {\n\t\t// 1. 黑名单过滤\n\t\tif (BLACKLIST_ZONES.has(video.typeid)) {\n\t\t\treturn -1\n\t\t}\n\n\t\t// 2. 时长硬性过滤 (差异 > 180s 直接排除)\n\t\tconst targetDurationSec = targetSong.duration / 1000\n\t\tconst videoDurationSec = parseDurationString(video.duration)\n\t\tconst durationDiff = Math.abs(videoDurationSec - targetDurationSec)\n\n\t\tif (durationDiff > 180) {\n\t\t\treturn -1\n\t\t}\n\n\t\t// 3. 计算各维度得分\n\t\t// 时长得分: Gaussian (sigma = 30s)\n\t\tconst durationScore = gaussian(durationDiff, 30)\n\n\t\tconst cleanVideoTitle = cleanString(video.title)\n\t\tconst cleanTargetTitle = cleanString(targetSong.title)\n\t\tconst cleanTargetArtist = cleanString(targetSong.artists.join(''))\n\n\t\t// 标题得分\n\t\tconst titleScore = lcsScore(cleanVideoTitle, cleanTargetTitle)\n\n\t\t// 4. 综合得分\n\t\t// 权重: 标题 0.5, 时长 0.5\n\t\tlet totalScore = titleScore * 0.5 + durationScore * 0.5\n\n\t\t// 额外加分项\n\t\t// 如果是优先分区 (官方/音乐区)，给予 10% 加成\n\t\tif (PRIORITY_ZONES.has(video.typeid)) {\n\t\t\ttotalScore *= 1.1\n\t\t}\n\n\t\t// 歌手匹配加分 (如果能在标题里找到歌手，增加置信度)\n\t\tif (\n\t\t\tcleanTargetArtist.length > 0 &&\n\t\t\tcleanVideoTitle.includes(cleanTargetArtist)\n\t\t) {\n\t\t\ttotalScore += 0.1\n\t\t}\n\n\t\treturn totalScore\n\t}\n}\n\nexport const externalPlaylistService = new ExternalPlaylistService()\n"
  },
  {
    "path": "apps/mobile/src/lib/services/genKey.ts",
    "content": "import type { Result } from 'neverthrow'\nimport { err, ok } from 'neverthrow'\n\nimport type { ServiceError } from '@/lib/errors'\nimport {\n\tcreateNotImplementedError,\n\tcreateValidationError,\n} from '@/lib/errors/service'\nimport type { TrackSourceData } from '@/types/services/track'\n\nexport default function generateUniqueTrackKey(\n\tpayload: TrackSourceData,\n): Result<string, ServiceError> {\n\tswitch (payload.source) {\n\t\tcase 'bilibili': {\n\t\t\tconst biliMeta = payload.bilibiliMetadata\n\t\t\tif (!biliMeta.bvid) {\n\t\t\t\treturn err(createValidationError('bvid 不存在'))\n\t\t\t}\n\t\t\treturn biliMeta.isMultiPage\n\t\t\t\t? ok(`${payload.source}::${biliMeta.bvid}::${biliMeta.cid}`)\n\t\t\t\t: ok(`${payload.source}::${biliMeta.bvid}`)\n\t\t}\n\t\tcase 'local': {\n\t\t\t// const localMeta = payload.localMetadata\n\t\t\t// return ok(`${payload.source}::${localMeta.localPath}`)\n\t\t\t// 基于 localPath 的业务主键太不可靠，考虑基于文件生成 hash\n\t\t\treturn err(\n\t\t\t\tcreateNotImplementedError(`未实现 local source 的 uniqueKey 生成`),\n\t\t\t)\n\t\t}\n\t\tdefault:\n\t\t\treturn err(\n\t\t\t\tcreateValidationError(\n\t\t\t\t\t`未知的 Track source: ${(payload as TrackSourceData).source}}`,\n\t\t\t\t),\n\t\t\t)\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/services/lyricService.ts",
    "content": "import { Orpheus, type LyricConsumer, type LyricsData } from '@bbplayer/orpheus'\nimport { parseAndMergeLyrics } from '@bbplayer/splash'\nimport { fetch as fetchNetInfo } from '@react-native-community/netinfo'\nimport * as Sentry from '@sentry/react-native'\nimport * as FileSystem from 'expo-file-system'\nimport { errAsync, okAsync, Result, ResultAsync } from 'neverthrow'\n\nimport { useAppStore } from '@/hooks/stores/useAppStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { kugouApi, type KugouApi } from '@/lib/api/kugou/api'\nimport { neteaseApi, type NeteaseApi } from '@/lib/api/netease/api'\nimport { qqMusicApi, type QQMusicApi } from '@/lib/api/qqmusic/api'\nimport type { CustomError } from '@/lib/errors'\nimport { FileSystemError, LyricNotFoundError } from '@/lib/errors'\nimport { trackService } from '@/lib/services/trackService'\nimport type { BilibiliTrack, Track } from '@/types/core/media'\nimport type {\n\tLyricFileData,\n\tLyricProviderResponseData,\n\tLyricSearchResult,\n\tParsedLrc,\n} from '@/types/player/lyrics'\nimport { toastAndLogError } from '@/utils/error-handling'\nimport log from '@/utils/log'\nimport { isActuallyOffline } from '@/utils/network'\n\nconst logger = log.extend('Service.Lyric')\ntype oldLyricFileType =\n\t| ParsedLrc\n\t| (Omit<ParsedLrc, 'rawOriginalLyrics' | 'rawTranslatedLyrics'> & {\n\t\t\traw: string\n\t  })\n\nclass LyricService {\n\tconstructor(\n\t\treadonly neteaseApi: NeteaseApi,\n\t\treadonly qqMusicApi: QQMusicApi,\n\t\treadonly kugouApi: KugouApi,\n\t) {}\n\n\tprivate debouncedPushLyricsToOverlays: ReturnType<typeof setTimeout> | null =\n\t\tnull\n\tprivate lastPushLyricsToOverlaysTimestamp: number | null = null\n\n\tprivate cleanKeyword(keyword: string): string {\n\t\tconst priorityRegex = /《(.+?)》|「(.+?)」/\n\t\tconst priorityMatch = priorityRegex.exec(keyword)\n\n\t\tif (priorityMatch) {\n\t\t\tlogger.debug(\n\t\t\t\t'匹配到优先提取的标记，直接返回这段字符串作为 keyword：',\n\t\t\t\tpriorityMatch[1],\n\t\t\t\tpriorityMatch[2],\n\t\t\t)\n\t\t\treturn priorityMatch[1] || priorityMatch[2]\n\t\t}\n\n\t\tconst replacedKeyword = keyword.replace(/【.*?】|“.*?”/g, '').trim()\n\t\tconst result = replacedKeyword.length > 0 ? replacedKeyword : keyword\n\t\tlogger.debug('最终 keyword 清洗后：', result)\n\n\t\treturn result\n\t}\n\n\t/**\n\t * 从多个数据源中获取最佳匹配的歌词\n\t * @param track\n\t * @param preciseKeyword 在提供该项时，将直接使用这个关键词搜索\n\t * @returns\n\t */\n\tpublic getBestMatchedLyrics(\n\t\ttrack: Track,\n\t\tpreciseKeyword?: string,\n\t\tsource?: 'auto' | 'netease' | 'qqmusic' | 'kugou',\n\t) {\n\t\tconst keyword = preciseKeyword ?? this.cleanKeyword(track.title)\n\t\tconst durationMs = track.duration * 1000\n\n\t\t// Keep track of abort controllers for cancellation\n\t\tconst controllers: AbortController[] = []\n\n\t\tconst createProviderPromise = (\n\t\t\tapiCall: (\n\t\t\t\tsignal: AbortSignal,\n\t\t\t) => ResultAsync<LyricProviderResponseData, Error | CustomError>,\n\t\t\tproviderName: string,\n\t\t) => {\n\t\t\tconst controller = new AbortController()\n\t\t\tcontrollers.push(controller)\n\n\t\t\treturn apiCall(controller.signal)\n\t\t\t\t.map((res) => {\n\t\t\t\t\tlogger.debug(`${providerName} returned lyrics`)\n\t\t\t\t\t// If one succeeds, abort others\n\t\t\t\t\tcontrollers.forEach((c) => {\n\t\t\t\t\t\tif (c !== controller) {\n\t\t\t\t\t\t\tc.abort()\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\treturn res\n\t\t\t\t})\n\t\t\t\t.match(\n\t\t\t\t\t(v) => v,\n\t\t\t\t\t(e) => {\n\t\t\t\t\t\tthrow e\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t}\n\n\t\tconst providers: Promise<LyricProviderResponseData>[] = []\n\n\t\tif (source === 'netease' || source === undefined || source === 'auto') {\n\t\t\tproviders.push(\n\t\t\t\tcreateProviderPromise(\n\t\t\t\t\t(signal) =>\n\t\t\t\t\t\tthis.neteaseApi.searchBestMatchedLyrics(\n\t\t\t\t\t\t\tkeyword,\n\t\t\t\t\t\t\tdurationMs,\n\t\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t),\n\t\t\t\t\t'Netease',\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\tif (source === 'qqmusic' || source === undefined || source === 'auto') {\n\t\t\tproviders.push(\n\t\t\t\tcreateProviderPromise(\n\t\t\t\t\t(signal) =>\n\t\t\t\t\t\tthis.qqMusicApi.searchBestMatchedLyrics(\n\t\t\t\t\t\t\tkeyword,\n\t\t\t\t\t\t\tdurationMs,\n\t\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t),\n\t\t\t\t\t'QQMusic',\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\tif (source === 'kugou' || source === undefined || source === 'auto') {\n\t\t\tproviders.push(\n\t\t\t\tcreateProviderPromise(\n\t\t\t\t\t(signal) =>\n\t\t\t\t\t\tthis.kugouApi.searchBestMatchedLyrics(keyword, durationMs, signal),\n\t\t\t\t\t'Kugou',\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(Promise.any(providers), (e) => {\n\t\t\t// All failed\n\t\t\t// e will be an AggregateError if using Promise.any\n\t\t\tconst aggregateError = e as AggregateError\n\t\t\tconst errors = Array.from(aggregateError.errors || [])\n\t\t\tconst errorMessages = errors\n\t\t\t\t.map((err) => {\n\t\t\t\t\treturn err instanceof Error ? err.message : String(err)\n\t\t\t\t})\n\t\t\t\t.join('; ')\n\n\t\t\treturn new LyricNotFoundError(\n\t\t\t\t`All lyric providers failed (${errors.length} providers). ${errorMessages}`,\n\t\t\t\t{ cause: e },\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * 优先从本地缓存中获取歌词，如果没有则从多个数据源并行查找，返回最匹配的歌词并进行缓存。\n\t * @param track\n\t * @returns\n\t */\n\tpublic smartFetchLyrics(\n\t\ttrack: Track,\n\t): ResultAsync<LyricFileData, CustomError> {\n\t\tconst lyricFile = new FileSystem.File(\n\t\t\tFileSystem.Paths.document,\n\t\t\t'lyrics',\n\t\t\t`${track.uniqueKey.replaceAll('::', '--')}.json`,\n\t\t)\n\n\t\tconst fetchFromNetwork = (): ResultAsync<LyricFileData, CustomError> => {\n\t\t\t// Bilibili 特殊处理\n\t\t\tif (\n\t\t\t\ttrack.source === 'bilibili' &&\n\t\t\t\ttrack.bilibiliMetadata.bvid &&\n\t\t\t\ttrack.bilibiliMetadata.cid\n\t\t\t) {\n\t\t\t\treturn ResultAsync.fromSafePromise(\n\t\t\t\t\tthis.getPreciseMusicNameOnBilibiliVideo(track.bilibiliMetadata),\n\t\t\t\t)\n\t\t\t\t\t.andThen((musicName) => {\n\t\t\t\t\t\tconst lyricSource =\n\t\t\t\t\t\t\tuseAppStore.getState().settings.lyricSource ?? 'auto'\n\t\t\t\t\t\treturn this.getBestMatchedLyrics(track, musicName, lyricSource)\n\t\t\t\t\t})\n\t\t\t\t\t.andThen((lyrics) => this.processAndSaveLyrics(lyrics, track))\n\t\t\t}\n\n\t\t\t// 标准源处理\n\t\t\tconst lyricSource = useAppStore.getState().settings.lyricSource ?? 'auto'\n\t\t\treturn this.getBestMatchedLyrics(track, undefined, lyricSource).andThen(\n\t\t\t\t(lyrics) => this.processAndSaveLyrics(lyrics, track),\n\t\t\t)\n\t\t}\n\n\t\t// 先尝试本地获取\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tif (!lyricFile.exists) {\n\t\t\t\t\tthrow new Error('Cache miss')\n\t\t\t\t}\n\n\t\t\t\tconst content = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'io:file:read', op: 'io' },\n\t\t\t\t\t() => lyricFile.text(),\n\t\t\t\t)\n\n\t\t\t\tconst parsedResult = Result.fromThrowable(\n\t\t\t\t\t() => JSON.parse(content) as LyricFileData,\n\t\t\t\t\t(e) => e,\n\t\t\t\t)()\n\n\t\t\t\tif (parsedResult.isErr()) {\n\t\t\t\t\tthrow new Error('JSON parsing failed', { cause: parsedResult.error })\n\t\t\t\t}\n\n\t\t\t\tconst parsed = parsedResult.value\n\n\t\t\t\tif (!parsed) {\n\t\t\t\t\tthrow new Error('Invalid lyric format', {\n\t\t\t\t\t\tcause: new Error('Parsed result is null'),\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// manualSkip 为 true 时直接返回缓存，不走网络\n\t\t\t\tif (parsed.manualSkip) {\n\t\t\t\t\treturn parsed\n\t\t\t\t}\n\n\t\t\t\tif (typeof parsed.lrc !== 'string') {\n\t\t\t\t\tthrow new Error('Invalid lyric format', {\n\t\t\t\t\t\tcause: new Error('lrc property is not a string'),\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn parsed\n\t\t\t})(),\n\t\t\t(e) => {\n\t\t\t\t// 抛出什么错误都无所谓的，因为我们下面会用 orElse 处理它\n\t\t\t\treturn e\n\t\t\t},\n\t\t).orElse(() => {\n\t\t\treturn ResultAsync.fromSafePromise(fetchNetInfo()).andThen(\n\t\t\t\t(networkState) => {\n\t\t\t\t\tif (isActuallyOffline(networkState)) {\n\t\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\t\tnew LyricNotFoundError('当前处于离线状态，无法获取网络歌词'),\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\treturn fetchFromNetwork()\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t}\n\n\t/**\n\t * 标记该曲目的歌词为「已跳过」，阻止自动重新获取。\n\t * 用户可随时通过手动搜索或编辑歌词来覆盖此状态。\n\t */\n\tpublic skipLyric(\n\t\tuniqueKey: string,\n\t): ResultAsync<LyricFileData, FileSystemError> {\n\t\tconst payload: LyricFileData = {\n\t\t\tid: uniqueKey,\n\t\t\tupdateTime: Date.now(),\n\t\t\tmanualSkip: true,\n\t\t}\n\t\tlogger.info('用户跳过歌词获取', { uniqueKey })\n\t\treturn this.saveLyricsToFile(payload, uniqueKey)\n\t}\n\n\t// 统一处理网络返回的歌词并保存\n\tprivate processAndSaveLyrics(\n\t\tlyrics: LyricProviderResponseData,\n\t\ttrack: Track,\n\t): ResultAsync<LyricFileData, CustomError> {\n\t\tconst lyricFileData: LyricFileData = {\n\t\t\t...lyrics,\n\t\t\tid: track.uniqueKey,\n\t\t\tupdateTime: Date.now(),\n\t\t}\n\t\tlogger.info('网络搜索歌词完成，正在写入缓存')\n\t\treturn this.saveLyricsToFile(lyricFileData, track.uniqueKey)\n\t}\n\n\tpublic saveLyricsToFile(\n\t\tlyrics: LyricFileData,\n\t\tuniqueKey: string,\n\t): ResultAsync<LyricFileData, FileSystemError> {\n\t\ttry {\n\t\t\tconst lyricFile = new FileSystem.File(\n\t\t\t\tFileSystem.Paths.document,\n\t\t\t\t'lyrics',\n\t\t\t\t`${uniqueKey.replaceAll('::', '--')}.json`,\n\t\t\t)\n\t\t\tlyricFile.parentDirectory.create({\n\t\t\t\tintermediates: true,\n\t\t\t\tidempotent: true,\n\t\t\t})\n\t\t\t// 当用户主动提供歌词内容时，清除 manualSkip 标记\n\t\t\tconst toWrite: LyricFileData = lyrics.manualSkip\n\t\t\t\t? lyrics\n\t\t\t\t: { ...lyrics, manualSkip: false }\n\t\t\tSentry.startSpan({ name: 'io:file:write', op: 'io' }, () =>\n\t\t\t\tlyricFile.write(JSON.stringify(toWrite)),\n\t\t\t)\n\t\t\t// 自动同步到悬浮窗/状态栏\n\t\t\tthis.pushLyricsToOverlays(uniqueKey)\n\t\t\treturn okAsync(toWrite)\n\t\t} catch (e) {\n\t\t\treturn errAsync(\n\t\t\t\tnew FileSystemError(`保存歌词文件失败`, {\n\t\t\t\t\tcause: e,\n\t\t\t\t\tdata: { uniqueKey },\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\t}\n\n\tpublic fetchLyrics(\n\t\titem: LyricSearchResult[0],\n\t\tuniqueKey: string,\n\t): ResultAsync<LyricFileData, Error> {\n\t\tswitch (item.source) {\n\t\t\tcase 'netease':\n\t\t\t\treturn this.neteaseApi\n\t\t\t\t\t.getLyrics(item.remoteId)\n\t\t\t\t\t.andThen((lyrics) => okAsync(this.neteaseApi.parseLyrics(lyrics)))\n\t\t\t\t\t.andThen((lyrics) => {\n\t\t\t\t\t\treturn okAsync({\n\t\t\t\t\t\t\t...lyrics,\n\t\t\t\t\t\t\tid: uniqueKey,\n\t\t\t\t\t\t\tupdateTime: Date.now(),\n\t\t\t\t\t\t} as LyricFileData)\n\t\t\t\t\t})\n\t\t\t\t\t.andThen((lyrics) => {\n\t\t\t\t\t\treturn this.saveLyricsToFile(lyrics, uniqueKey)\n\t\t\t\t\t})\n\t\t\tcase 'qqmusic':\n\t\t\t\treturn this.qqMusicApi\n\t\t\t\t\t.getLyrics(item.remoteId)\n\t\t\t\t\t.andThen((lyrics) => okAsync(this.qqMusicApi.parseLyrics(lyrics)))\n\t\t\t\t\t.andThen((lyrics) => {\n\t\t\t\t\t\treturn okAsync({\n\t\t\t\t\t\t\t...lyrics,\n\t\t\t\t\t\t\tid: uniqueKey,\n\t\t\t\t\t\t\tupdateTime: Date.now(),\n\t\t\t\t\t\t} as LyricFileData)\n\t\t\t\t\t})\n\t\t\t\t\t.andThen((lyrics) => {\n\t\t\t\t\t\treturn this.saveLyricsToFile(lyrics, uniqueKey)\n\t\t\t\t\t})\n\t\t\tcase 'kugou':\n\t\t\t\treturn this.kugouApi\n\t\t\t\t\t.getLyrics(item.remoteId)\n\t\t\t\t\t.andThen((lyrics) => okAsync(this.kugouApi.parseLyrics(lyrics)))\n\t\t\t\t\t.andThen((lyrics) => {\n\t\t\t\t\t\treturn okAsync({\n\t\t\t\t\t\t\t...lyrics,\n\t\t\t\t\t\t\tid: uniqueKey,\n\t\t\t\t\t\t\tupdateTime: Date.now(),\n\t\t\t\t\t\t} as LyricFileData)\n\t\t\t\t\t})\n\t\t\t\t\t.andThen((lyrics) => {\n\t\t\t\t\t\treturn this.saveLyricsToFile(lyrics, uniqueKey)\n\t\t\t\t\t})\n\t\t\tdefault:\n\t\t\t\treturn errAsync(new Error('未知歌曲源'))\n\t\t}\n\t}\n\n\t/**\n\t * 迁移旧版歌词格式\n\t * 优化：增加标记文件检测，避免每次重启都遍历目录\n\t */\n\tpublic async migrateFromOldFormat() {\n\t\tconst lyricsDir = new FileSystem.Directory(\n\t\t\tFileSystem.Paths.document,\n\t\t\t'lyrics',\n\t\t)\n\t\tconst migrationMarker = new FileSystem.File(lyricsDir, '.migration_v2_done')\n\n\t\ttry {\n\t\t\tif (!lyricsDir.exists) return\n\n\t\t\t// 1. 检查标记文件，如果存在说明已经迁移过了，直接跳过\n\t\t\tif (migrationMarker.exists) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlogger.info('检测到未迁移的歌词缓存，开始执行迁移...')\n\t\t\tconst lyricFiles = lyricsDir.list()\n\n\t\t\tfor (const file of lyricFiles) {\n\t\t\t\tif (file instanceof FileSystem.Directory) continue\n\t\t\t\t// 跳过标记文件本身\n\t\t\t\tif (file.name.startsWith('.')) continue\n\t\t\t\tif (!file.name.endsWith('.json')) continue\n\n\t\t\t\ttry {\n\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\tconst content = await file.text()\n\t\t\t\t\tlet parsed: oldLyricFileType | LyricFileData | ParsedLrc\n\t\t\t\t\ttry {\n\t\t\t\t\t\tparsed = JSON.parse(content) as\n\t\t\t\t\t\t\t| oldLyricFileType\n\t\t\t\t\t\t\t| LyricFileData\n\t\t\t\t\t\t\t| ParsedLrc\n\t\t\t\t\t} catch {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\t// 检查是否已经是新格式 (包含 lrc 字段)\n\t\t\t\t\tif ('lrc' in parsed) continue\n\n\t\t\t\t\t// 还原 ID\n\t\t\t\t\tconst uniqueKey = file.name\n\t\t\t\t\t\t.replace('.json', '')\n\t\t\t\t\t\t.replaceAll('--', '::')\n\n\t\t\t\t\t// 提取数据\n\t\t\t\t\tlet newLrc = ''\n\t\t\t\t\tlet newTlyric: string | undefined\n\t\t\t\t\tlet oldOffset: number | undefined\n\n\t\t\t\t\tif ('raw' in parsed && typeof parsed.raw === 'string') {\n\t\t\t\t\t\tconst parts = parsed.raw.split('\\n\\n')\n\t\t\t\t\t\tnewLrc = parts[0]\n\t\t\t\t\t\tnewTlyric = parts.length > 1 ? parts[1] : undefined\n\t\t\t\t\t\t// 旧的 raw 格式通常没有外层的 offset 字段，或者在 parsed 对象上\n\t\t\t\t\t\toldOffset = parsed.offset\n\t\t\t\t\t} else if ('rawOriginalLyrics' in parsed) {\n\t\t\t\t\t\tnewLrc = parsed.rawOriginalLyrics || ''\n\t\t\t\t\t\tnewTlyric = parsed.rawTranslatedLyrics\n\t\t\t\t\t\toldOffset = parsed.offset\n\t\t\t\t\t}\n\n\t\t\t\t\tif (!newLrc) continue\n\n\t\t\t\t\tconst newLyricData: LyricFileData = {\n\t\t\t\t\t\tid: uniqueKey,\n\t\t\t\t\t\tupdateTime: Date.now(),\n\t\t\t\t\t\tlrc: newLrc,\n\t\t\t\t\t\ttlyric: newTlyric,\n\t\t\t\t\t\tmisc: {\n\t\t\t\t\t\t\t// 迁移用户手动设置的 offset\n\t\t\t\t\t\t\tuserOffset: oldOffset,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\n\t\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\t\tawait this.saveLyricsToFile(newLyricData, uniqueKey)\n\t\t\t\t} catch (e) {\n\t\t\t\t\tlogger.warning(`文件 ${file.name} 迁移失败`, e)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tmigrationMarker.create()\n\t\t\tlogger.info('歌词格式迁移完成')\n\t\t} catch (e) {\n\t\t\ttoastAndLogError('迁移歌词格式失败', e, 'Service.Lyric')\n\t\t}\n\t}\n\n\tpublic async getPreciseMusicNameOnBilibiliVideo(\n\t\tmetadata: BilibiliTrack['bilibiliMetadata'],\n\t) {\n\t\tif (!metadata.cid || !metadata.bvid) return undefined\n\t\tconst result = await bilibiliApi\n\t\t\t.getWebPlayerInfo(metadata.bvid, metadata.cid)\n\t\t\t.andThen((res) => {\n\t\t\t\tif (!res.bgm_info) {\n\t\t\t\t\treturn errAsync(new Error('没有获取到歌曲信息'))\n\t\t\t\t}\n\t\t\t\tconst filteredResult = /《(.+?)》/.exec(res.bgm_info.music_title)\n\t\t\t\tlogger.debug('从 bilibili 获取到的该视频中识别到的歌曲名', {\n\t\t\t\t\tmusic_title: res.bgm_info.music_title,\n\t\t\t\t})\n\t\t\t\tif (filteredResult?.[1]) {\n\t\t\t\t\treturn okAsync(filteredResult[1])\n\t\t\t\t}\n\t\t\t\treturn okAsync(res.bgm_info.music_title)\n\t\t\t})\n\t\tif (result.isErr()) {\n\t\t\treturn undefined\n\t\t}\n\t\treturn result.value\n\t}\n\n\t/**\n\t * 清除所有已缓存的歌词\n\t * @returns\n\t */\n\tpublic clearAllLyrics(): Result<true, unknown> {\n\t\tconst lyricsDir = new FileSystem.Directory(\n\t\t\tFileSystem.Paths.document,\n\t\t\t'lyrics',\n\t\t)\n\n\t\treturn Result.fromThrowable(() => {\n\t\t\tif (!lyricsDir.exists) {\n\t\t\t\tlogger.debug('歌词目录不存在，无需清理')\n\t\t\t\treturn true as const\n\t\t\t}\n\t\t\tlyricsDir.delete()\n\t\t\tlyricsDir.create({\n\t\t\t\tintermediates: true,\n\t\t\t\tidempotent: true,\n\t\t\t})\n\t\t\tlogger.info('歌词缓存已清理')\n\t\t\treturn true as const\n\t\t})()\n\t}\n\n\t/**\n\t * 立即推送指定曲目的歌词到桌面歌词、状态栏和车载歌词\n\t */\n\tpublic pushLyricsToOverlays(trackId: string) {\n\t\tconst wantDesktop = Orpheus.isDesktopLyricsShown\n\t\tconst wantStatusBar = Orpheus.isStatusBarLyricsEnabled\n\t\tconst wantCar = Orpheus.isCarLyricsEnabled\n\t\tif (!wantDesktop && !wantStatusBar && !wantCar) return\n\n\t\tconst currentTimestamp = Date.now()\n\t\tthis.lastPushLyricsToOverlaysTimestamp = currentTimestamp\n\n\t\tif (this.debouncedPushLyricsToOverlays) {\n\t\t\tclearTimeout(this.debouncedPushLyricsToOverlays)\n\t\t}\n\n\t\tconst setIt = async () => {\n\t\t\tif (currentTimestamp !== this.lastPushLyricsToOverlaysTimestamp) return\n\n\t\t\ttry {\n\t\t\t\tconst currentOrpheusTrack = await Orpheus.getCurrentTrack()\n\t\t\t\tif (currentOrpheusTrack && currentOrpheusTrack.id !== trackId) {\n\t\t\t\t\tlogger.debug('pushLyricsToOverlays: trackId 不再是当前曲目，跳过', {\n\t\t\t\t\t\ttrackId,\n\t\t\t\t\t\tcurrentId: currentOrpheusTrack.id,\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif (currentTimestamp !== this.lastPushLyricsToOverlaysTimestamp) return\n\n\t\t\t\tconst trackResult = await trackService.getTrackByUniqueKey(trackId)\n\t\t\t\tif (trackResult.isErr()) throw trackResult.error\n\n\t\t\t\tif (currentTimestamp !== this.lastPushLyricsToOverlaysTimestamp) return\n\t\t\t\tconst lyricsResult = await this.smartFetchLyrics(trackResult.value)\n\t\t\t\tif (lyricsResult.isErr()) throw lyricsResult.error\n\n\t\t\t\tconst lyrics = lyricsResult.value\n\t\t\t\tif (!lyrics.lrc) {\n\t\t\t\t\t// 歌词为空（如 manualSkip 或搜索失败），隐藏所有 overlay\n\t\t\t\t\tawait Orpheus.clearOverlays()\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconst parsedLines = parseAndMergeLyrics({\n\t\t\t\t\tlrc: lyrics.lrc,\n\t\t\t\t\ttlyric: lyrics.tlyric,\n\t\t\t\t\tromalrc: lyrics.romalrc,\n\t\t\t\t})\n\n\t\t\t\tconst orpheusLyrics = parsedLines.map((line) => ({\n\t\t\t\t\ttimestamp: line.startTime / 1000,\n\t\t\t\t\tendTime: line.endTime / 1000,\n\t\t\t\t\ttext: line.content,\n\t\t\t\t\ttranslation: line.translation,\n\t\t\t\t\tromaji: line.romaji,\n\t\t\t\t\tspans: line.isDynamic\n\t\t\t\t\t\t? line.spans.map((span) => ({\n\t\t\t\t\t\t\t\ttext: span.text,\n\t\t\t\t\t\t\t\tstartTime: span.startTime,\n\t\t\t\t\t\t\t\tendTime: span.endTime,\n\t\t\t\t\t\t\t\tduration: span.duration,\n\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t: undefined,\n\t\t\t\t}))\n\n\t\t\t\tif (currentTimestamp !== this.lastPushLyricsToOverlaysTimestamp) return\n\n\t\t\t\tconst payload: LyricsData = {\n\t\t\t\t\tlyrics: orpheusLyrics,\n\t\t\t\t\toffset: lyrics.misc?.userOffset ?? 0,\n\t\t\t\t}\n\n\t\t\t\tconst consumers: LyricConsumer[] = []\n\t\t\t\tif (Orpheus.isDesktopLyricsShown) {\n\t\t\t\t\tconsumers.push('desktop')\n\t\t\t\t}\n\t\t\t\tif (Orpheus.isStatusBarLyricsEnabled) {\n\t\t\t\t\tconsumers.push('statusBar')\n\t\t\t\t}\n\t\t\t\tif (Orpheus.isCarLyricsEnabled) {\n\t\t\t\t\tconsumers.push('car')\n\t\t\t\t}\n\n\t\t\t\tif (consumers.length > 0) {\n\t\t\t\t\tawait Orpheus.setLyrics(payload, consumers)\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tlogger.warning('更新歌词显示失败', e)\n\t\t\t}\n\t\t}\n\n\t\tthis.debouncedPushLyricsToOverlays = setTimeout(() => {\n\t\t\tvoid setIt()\n\t\t\tthis.debouncedPushLyricsToOverlays = null\n\t\t}, 300)\n\t}\n\n\t/**\n\t * 预加载下一首歌曲的歌词\n\t */\n\tpublic async preloadNextTrackLyrics() {\n\t\ttry {\n\t\t\tconst [currentIndex, queue] = await Promise.all([\n\t\t\t\tOrpheus.getCurrentIndex(),\n\t\t\t\tOrpheus.getQueue(),\n\t\t\t])\n\n\t\t\tif (currentIndex !== -1 && currentIndex + 1 < queue.length) {\n\t\t\t\tconst nextOrpheusTrack = queue[currentIndex + 1]\n\t\t\t\tif (nextOrpheusTrack?.id) {\n\t\t\t\t\tconst nextTrackResult = await trackService.getTrackByUniqueKey(\n\t\t\t\t\t\tnextOrpheusTrack.id,\n\t\t\t\t\t)\n\t\t\t\t\tif (nextTrackResult.isOk() && nextTrackResult.value) {\n\t\t\t\t\t\tlogger.debug('预加载下一首歌词', {\n\t\t\t\t\t\t\ttitle: nextTrackResult.value.title,\n\t\t\t\t\t\t})\n\t\t\t\t\t\tvoid this.smartFetchLyrics(nextTrackResult.value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tlogger.warning('预加载歌词失败', e)\n\t\t}\n\t}\n}\n\nconst lyricService = new LyricService(neteaseApi, qqMusicApi, kugouApi)\nexport default lyricService\n"
  },
  {
    "path": "apps/mobile/src/lib/services/playlistService.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport type { SQL } from 'drizzle-orm'\nimport { and, desc, eq, inArray, like, lt, or, sql } from 'drizzle-orm'\nimport { type ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { generateKeyBetween } from 'fractional-indexing'\nimport { ResultAsync, errAsync, okAsync } from 'neverthrow'\n\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { ServiceError } from '@/lib/errors'\nimport {\n\tDatabaseError,\n\tcreatePlaylistAlreadyExists,\n\tcreatePlaylistNotFound,\n\tcreateTrackNotInPlaylist,\n\tcreateValidationError,\n} from '@/lib/errors/service'\nimport type { Playlist, Track } from '@/types/core/media'\nimport type {\n\tCreatePlaylistPayload,\n\tReorderLocalPlaylistTrackPayload,\n\tUpdatePlaylistPayload,\n} from '@/types/services/playlist'\n\nimport type { TrackService } from './trackService'\nimport { trackService } from './trackService'\n\ntype Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]\ntype DBLike = ExpoSQLiteDatabase<typeof schema> | Tx\ntype PlaylistTrackRow = typeof schema.playlistTracks.$inferSelect & {\n\ttrack: typeof schema.tracks.$inferSelect & {\n\t\tartist: typeof schema.artists.$inferSelect | null\n\t\tbilibiliMetadata: typeof schema.bilibiliMetadata.$inferSelect | null\n\t\tlocalMetadata: typeof schema.localMetadata.$inferSelect | null\n\t}\n}\ntype DynamicPlaylistTrackSqlRow = {\n\tsourcePosition: number\n\ttrackId: number\n\tsourceSortKey: string\n\tsortKey: string\n\tcreatedAt: number\n\ttrackUniqueKey: string\n\ttrackTitle: string\n\ttrackArtistId: number | null\n\ttrackCoverUrl: string | null\n\ttrackDuration: number\n\ttrackCreatedAt: number\n\ttrackSource: 'bilibili' | 'local'\n\ttrackUpdatedAt: number\n\tartistId: number | null\n\tartistName: string | null\n\tartistAvatarUrl: string | null\n\tartistSignature: string | null\n\tartistSource: 'bilibili' | 'local' | null\n\tartistRemoteId: string | null\n\tartistCreatedAt: number | null\n\tartistUpdatedAt: number | null\n\tbilibiliTrackId: number | null\n\tbilibiliBvid: string | null\n\tbilibiliCid: number | null\n\tbilibiliIsMultiPage: number | boolean | null\n\tbilibiliMainTrackTitle: string | null\n\tbilibiliVideoIsValid: number | boolean | null\n\tlocalTrackId: number | null\n\tlocalPath: string | null\n}\ntype DynamicPlaylistStats = {\n\titemCount: number\n\tvalidTrackCount: number\n\ttotalDuration: number\n}\n\n/**\n * 对于内部 tracks 的增删改操作只有 local playlist 才可以，注意方法名。\n */\nexport class PlaylistService {\n\tconstructor(\n\t\tprivate readonly db: DBLike,\n\t\tprivate readonly trackService: TrackService,\n\t) {}\n\n\t/**\n\t * 返回一个使用新数据库连接（例如事务）的新实例。\n\t * @param conn - 新的数据库连接或事务。\n\t * @returns 一个新的实例。\n\t */\n\twithDB(conn: DBLike) {\n\t\treturn new PlaylistService(conn, this.trackService.withDB(conn))\n\t}\n\n\tprivate parseDynamicCursor(cursor?: {\n\t\tlastSortKey: string\n\t\tcreatedAt: number\n\t\tlastId: number\n\t}) {\n\t\tif (!cursor) return undefined\n\n\t\tconst separatorIndex = cursor.lastSortKey.indexOf('|')\n\t\tif (separatorIndex < 0) return undefined\n\n\t\tconst sourcePosition = Number(cursor.lastSortKey.slice(0, separatorIndex))\n\t\tconst sourceSortKey = cursor.lastSortKey.slice(separatorIndex + 1)\n\t\tif (!Number.isFinite(sourcePosition) || !sourceSortKey) return undefined\n\n\t\treturn {\n\t\t\tsourcePosition,\n\t\t\tsourceSortKey,\n\t\t\tcreatedAt: cursor.createdAt,\n\t\t\tlastId: cursor.lastId,\n\t\t}\n\t}\n\n\tprivate mapDynamicPlaylistTrackRow(\n\t\trow: DynamicPlaylistTrackSqlRow,\n\t): PlaylistTrackRow {\n\t\treturn {\n\t\t\tplaylistId: 0,\n\t\t\ttrackId: row.trackId,\n\t\t\tsortKey: row.sortKey,\n\t\t\tcreatedAt: new Date(row.createdAt),\n\t\t\ttrack: {\n\t\t\t\tid: row.trackId,\n\t\t\t\tuniqueKey: row.trackUniqueKey,\n\t\t\t\ttitle: row.trackTitle,\n\t\t\t\tartistId: row.trackArtistId,\n\t\t\t\tcoverUrl: row.trackCoverUrl,\n\t\t\t\tduration: row.trackDuration,\n\t\t\t\tcreatedAt: new Date(row.trackCreatedAt),\n\t\t\t\tsource: row.trackSource,\n\t\t\t\tupdatedAt: new Date(row.trackUpdatedAt),\n\t\t\t\tartist:\n\t\t\t\t\trow.artistId === null || row.artistName === null\n\t\t\t\t\t\t? null\n\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\tid: row.artistId,\n\t\t\t\t\t\t\t\tname: row.artistName,\n\t\t\t\t\t\t\t\tavatarUrl: row.artistAvatarUrl,\n\t\t\t\t\t\t\t\tsignature: row.artistSignature,\n\t\t\t\t\t\t\t\tsource: row.artistSource ?? 'local',\n\t\t\t\t\t\t\t\tremoteId: row.artistRemoteId,\n\t\t\t\t\t\t\t\tcreatedAt: new Date(row.artistCreatedAt ?? 0),\n\t\t\t\t\t\t\t\tupdatedAt: new Date(row.artistUpdatedAt ?? 0),\n\t\t\t\t\t\t\t},\n\t\t\t\tbilibiliMetadata:\n\t\t\t\t\trow.bilibiliTrackId === null || row.bilibiliBvid === null\n\t\t\t\t\t\t? null\n\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\ttrackId: row.bilibiliTrackId,\n\t\t\t\t\t\t\t\tbvid: row.bilibiliBvid,\n\t\t\t\t\t\t\t\tcid: row.bilibiliCid,\n\t\t\t\t\t\t\t\tisMultiPage: Boolean(row.bilibiliIsMultiPage),\n\t\t\t\t\t\t\t\tmainTrackTitle: row.bilibiliMainTrackTitle,\n\t\t\t\t\t\t\t\tvideoIsValid: Boolean(row.bilibiliVideoIsValid),\n\t\t\t\t\t\t\t},\n\t\t\t\tlocalMetadata:\n\t\t\t\t\trow.localTrackId === null || row.localPath === null\n\t\t\t\t\t\t? null\n\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\ttrackId: row.localTrackId,\n\t\t\t\t\t\t\t\tlocalPath: row.localPath,\n\t\t\t\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\tprivate dynamicPlaylistRowsCte(playlistId: number) {\n\t\treturn sql`\n\t\t\tWITH ranked_tracks AS (\n\t\t\t\tSELECT\n\t\t\t\t\tpt.track_id,\n\t\t\t\t\tdps.position AS source_position,\n\t\t\t\t\tpt.sort_key AS source_sort_key,\n\t\t\t\t\t(dps.position || '|' || pt.sort_key) AS sort_key,\n\t\t\t\t\tpt.created_at,\n\t\t\t\t\tROW_NUMBER() OVER (\n\t\t\t\t\t\tPARTITION BY pt.track_id\n\t\t\t\t\t\tORDER BY dps.position ASC, pt.sort_key DESC, pt.created_at DESC, pt.track_id DESC\n\t\t\t\t\t) AS row_number\n\t\t\t\tFROM ${schema.dynamicPlaylistSources} AS dps\n\t\t\t\tJOIN ${schema.playlistTracks} AS pt\n\t\t\t\t\tON pt.playlist_id = dps.source_playlist_id\n\t\t\t\tWHERE dps.playlist_id = ${playlistId}\n\t\t\t),\n\t\t\tdynamic_tracks AS (\n\t\t\t\tSELECT *\n\t\t\t\tFROM ranked_tracks\n\t\t\t\tWHERE row_number = 1\n\t\t\t)\n\t\t`\n\t}\n\n\tprivate async queryDynamicPlaylistTrackRows({\n\t\tplaylistId,\n\t\tquery,\n\t\tlimit,\n\t\tcursor,\n\t}: {\n\t\tplaylistId: number\n\t\tquery?: string\n\t\tlimit?: number\n\t\tcursor?: {\n\t\t\tlastSortKey: string\n\t\t\tcreatedAt: number\n\t\t\tlastId: number\n\t\t}\n\t}): Promise<PlaylistTrackRow[]> {\n\t\tconst trimmed = query?.trim().toLowerCase()\n\t\tconst likeQuery = trimmed ? `%${trimmed}%` : undefined\n\t\tconst parsedCursor = this.parseDynamicCursor(cursor)\n\t\tconst rows = this.db.all<DynamicPlaylistTrackSqlRow>(sql`\n\t\t\t${this.dynamicPlaylistRowsCte(playlistId)}\n\t\t\tSELECT\n\t\t\t\tdt.source_position AS sourcePosition,\n\t\t\t\tdt.track_id AS trackId,\n\t\t\t\tdt.source_sort_key AS sourceSortKey,\n\t\t\t\tdt.sort_key AS sortKey,\n\t\t\t\tdt.created_at AS createdAt,\n\t\t\t\tt.unique_key AS trackUniqueKey,\n\t\t\t\tt.title AS trackTitle,\n\t\t\t\tt.artist_id AS trackArtistId,\n\t\t\t\tt.cover_url AS trackCoverUrl,\n\t\t\t\tt.duration AS trackDuration,\n\t\t\t\tt.created_at AS trackCreatedAt,\n\t\t\t\tt.source AS trackSource,\n\t\t\t\tt.updated_at AS trackUpdatedAt,\n\t\t\t\ta.id AS artistId,\n\t\t\t\ta.name AS artistName,\n\t\t\t\ta.avatar_url AS artistAvatarUrl,\n\t\t\t\ta.signature AS artistSignature,\n\t\t\t\ta.source AS artistSource,\n\t\t\t\ta.remote_id AS artistRemoteId,\n\t\t\t\ta.created_at AS artistCreatedAt,\n\t\t\t\ta.updated_at AS artistUpdatedAt,\n\t\t\t\tbm.track_id AS bilibiliTrackId,\n\t\t\t\tbm.bvid AS bilibiliBvid,\n\t\t\t\tbm.cid AS bilibiliCid,\n\t\t\t\tbm.is_multi_page AS bilibiliIsMultiPage,\n\t\t\t\tbm.main_track_title AS bilibiliMainTrackTitle,\n\t\t\t\tbm.video_is_valid AS bilibiliVideoIsValid,\n\t\t\t\tlm.track_id AS localTrackId,\n\t\t\t\tlm.local_path AS localPath\n\t\t\tFROM dynamic_tracks AS dt\n\t\t\tJOIN ${schema.tracks} AS t\n\t\t\t\tON t.id = dt.track_id\n\t\t\tLEFT JOIN ${schema.artists} AS a\n\t\t\t\tON a.id = t.artist_id\n\t\t\tLEFT JOIN ${schema.bilibiliMetadata} AS bm\n\t\t\t\tON bm.track_id = t.id\n\t\t\tLEFT JOIN ${schema.localMetadata} AS lm\n\t\t\t\tON lm.track_id = t.id\n\t\t\tWHERE\n\t\t\t\t${likeQuery === undefined ? sql`1 = 1` : sql`lower(t.title) LIKE ${likeQuery}`}\n\t\t\t\tAND ${\n\t\t\t\t\tparsedCursor === undefined\n\t\t\t\t\t\t? sql`1 = 1`\n\t\t\t\t\t\t: sql`(\n\t\t\t\t\t\t\t\tdt.source_position > ${parsedCursor.sourcePosition}\n\t\t\t\t\t\t\t\tOR (\n\t\t\t\t\t\t\t\t\tdt.source_position = ${parsedCursor.sourcePosition}\n\t\t\t\t\t\t\t\t\tAND (\n\t\t\t\t\t\t\t\t\t\tdt.source_sort_key < ${parsedCursor.sourceSortKey}\n\t\t\t\t\t\t\t\t\t\tOR (\n\t\t\t\t\t\t\t\t\t\t\tdt.source_sort_key = ${parsedCursor.sourceSortKey}\n\t\t\t\t\t\t\t\t\t\t\tAND dt.created_at < ${parsedCursor.createdAt}\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\tOR (\n\t\t\t\t\t\t\t\t\t\t\tdt.source_sort_key = ${parsedCursor.sourceSortKey}\n\t\t\t\t\t\t\t\t\t\t\tAND dt.created_at = ${parsedCursor.createdAt}\n\t\t\t\t\t\t\t\t\t\t\tAND dt.track_id < ${parsedCursor.lastId}\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t)`\n\t\t\t\t}\n\t\t\tORDER BY\n\t\t\t\tdt.source_position ASC,\n\t\t\t\tdt.source_sort_key DESC,\n\t\t\t\tdt.created_at DESC,\n\t\t\t\tdt.track_id DESC\n\t\t\t${limit === undefined ? sql`` : sql`LIMIT ${limit}`}\n\t\t`)\n\n\t\treturn rows.map((row) => this.mapDynamicPlaylistTrackRow(row))\n\t}\n\n\tprivate async getDynamicPlaylistStats(\n\t\tplaylistId: number,\n\t): Promise<DynamicPlaylistStats> {\n\t\tconst row = this.db.get<DynamicPlaylistStats>(sql`\n\t\t\t${this.dynamicPlaylistRowsCte(playlistId)}\n\t\t\tSELECT\n\t\t\t\tCOUNT(dt.track_id) AS itemCount,\n\t\t\t\tCOUNT(\n\t\t\t\t\tCASE\n\t\t\t\t\t\tWHEN bm.video_is_valid IS NOT false THEN dt.track_id\n\t\t\t\t\tEND\n\t\t\t\t) AS validTrackCount,\n\t\t\t\tCOALESCE(SUM(\n\t\t\t\t\tCASE\n\t\t\t\t\t\tWHEN bm.video_is_valid IS NOT false THEN t.duration\n\t\t\t\t\t\tELSE 0\n\t\t\t\t\tEND\n\t\t\t\t), 0) AS totalDuration\n\t\t\tFROM dynamic_tracks AS dt\n\t\t\tJOIN ${schema.tracks} AS t\n\t\t\t\tON t.id = dt.track_id\n\t\t\tLEFT JOIN ${schema.bilibiliMetadata} AS bm\n\t\t\t\tON bm.track_id = t.id\n\t\t`)\n\n\t\treturn {\n\t\t\titemCount: Number(row?.itemCount ?? 0),\n\t\t\tvalidTrackCount: Number(row?.validTrackCount ?? 0),\n\t\t\ttotalDuration: Number(row?.totalDuration ?? 0),\n\t\t}\n\t}\n\n\tprivate async getDynamicPlaylistCounts(playlistIds: number[]) {\n\t\tconst uniqueIds = Array.from(new Set(playlistIds))\n\t\tif (uniqueIds.length === 0) return new Map<number, number>()\n\n\t\tconst rows = this.db.all<{ playlistId: number; itemCount: number }>(\n\t\t\tsql`\n\t\t\t\tWITH ranked_tracks AS (\n\t\t\t\t\tSELECT\n\t\t\t\t\t\tdps.playlist_id,\n\t\t\t\t\t\tpt.track_id,\n\t\t\t\t\t\tROW_NUMBER() OVER (\n\t\t\t\t\t\t\tPARTITION BY dps.playlist_id, pt.track_id\n\t\t\t\t\t\t\tORDER BY dps.position ASC, pt.sort_key DESC, pt.created_at DESC, pt.track_id DESC\n\t\t\t\t\t\t) AS row_number\n\t\t\t\t\tFROM ${schema.dynamicPlaylistSources} AS dps\n\t\t\t\t\tJOIN ${schema.playlistTracks} AS pt\n\t\t\t\t\t\tON pt.playlist_id = dps.source_playlist_id\n\t\t\t\t\tWHERE dps.playlist_id IN (${sql.join(\n\t\t\t\t\t\tuniqueIds.map((id) => sql`${id}`),\n\t\t\t\t\t\tsql`, `,\n\t\t\t\t\t)})\n\t\t\t\t)\n\t\t\t\tSELECT playlist_id AS playlistId, COUNT(track_id) AS itemCount\n\t\t\t\tFROM ranked_tracks\n\t\t\t\tWHERE row_number = 1\n\t\t\t\tGROUP BY playlist_id\n\t\t\t`,\n\t\t)\n\n\t\treturn new Map(\n\t\t\tuniqueIds.map((id) => [\n\t\t\t\tid,\n\t\t\t\tNumber(rows.find((row) => row.playlistId === id)?.itemCount ?? 0),\n\t\t\t]),\n\t\t)\n\t}\n\n\t/**\n\t * 创建一个新的播放列表。\n\t * @param payload - 创建播放列表所需的数据。\n\t * @returns ResultAsync 包含成功创建的 Playlist 或一个错误。\n\t */\n\tpublic createPlaylist(\n\t\tpayload: CreatePlaylistPayload,\n\t): ResultAsync<\n\t\ttypeof schema.playlists.$inferSelect,\n\t\tDatabaseError | ServiceError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst insertValues: typeof schema.playlists.$inferInsert = {\n\t\t\t\t\ttitle: payload.title,\n\t\t\t\t\tauthorId: payload.authorId ?? null,\n\t\t\t\t\tdescription: payload.description ?? null,\n\t\t\t\t\tcoverUrl: payload.coverUrl ?? null,\n\t\t\t\t\ttype: payload.type,\n\t\t\t\t\tremoteSyncId: payload.remoteSyncId ?? null,\n\t\t\t\t\tshareId: payload.shareId ?? null,\n\t\t\t\t\tshareRole: payload.shareRole ?? null,\n\t\t\t\t\tlastShareSyncAt:\n\t\t\t\t\t\tpayload.lastShareSyncAt === undefined\n\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t: payload.lastShareSyncAt === null\n\t\t\t\t\t\t\t\t? null\n\t\t\t\t\t\t\t\t: new Date(payload.lastShareSyncAt),\n\t\t\t\t}\n\n\t\t\t\tconst [result] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:insert:playlist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.insert(schema.playlists).values(insertValues).returning(),\n\t\t\t\t)\n\t\t\t\treturn result\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('创建播放列表失败', { cause: e }),\n\t\t).andThen((result) => {\n\t\t\treturn okAsync(result)\n\t\t})\n\t}\n\n\t/**\n\t * 更新一个播放列表元数据。\n\t * @param playlistId - 要更新的播放列表的 ID。\n\t * @param payload - 更新所需的数据。\n\t * @returns ResultAsync 包含更新后的 Playlist 或一个错误。\n\t */\n\tpublic updatePlaylistMetadata(\n\t\tplaylistId: number,\n\t\tpayload: UpdatePlaylistPayload,\n\t): ResultAsync<\n\t\ttypeof schema.playlists.$inferSelect,\n\t\tDatabaseError | ServiceError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 验证播放列表是否存在\n\t\t\t\tconst existing = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t\t\t// eq(schema.playlists.type, 'local'),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!existing) {\n\t\t\t\t\tthrow createPlaylistNotFound(playlistId)\n\t\t\t\t}\n\n\t\t\t\tif (payload.title) {\n\t\t\t\t\tconst duplicate = await Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:query:playlist:duplicate', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\t\teq(schema.playlists.title, payload.title!),\n\t\t\t\t\t\t\t\t\t// 排除自己\n\t\t\t\t\t\t\t\t\tsql`${schema.playlists.id} != ${playlistId}`,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\tcolumns: { id: true },\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t\tif (duplicate) {\n\t\t\t\t\t\tthrow createPlaylistAlreadyExists(payload.title)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst [updated] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:update:playlist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.update(schema.playlists)\n\t\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\t\ttitle: payload.title ?? undefined,\n\t\t\t\t\t\t\t\tdescription: payload.description,\n\t\t\t\t\t\t\t\tcoverUrl: payload.coverUrl,\n\t\t\t\t\t\t\t\tshareId: payload.shareId,\n\t\t\t\t\t\t\t\tshareRole: payload.shareRole,\n\t\t\t\t\t\t\t\tlastShareSyncAt: payload.lastShareSyncAt\n\t\t\t\t\t\t\t\t\t? new Date(payload.lastShareSyncAt)\n\t\t\t\t\t\t\t\t\t: payload.lastShareSyncAt === null\n\t\t\t\t\t\t\t\t\t\t? null\n\t\t\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.where(eq(schema.playlists.id, playlistId))\n\t\t\t\t\t\t\t.returning(),\n\t\t\t\t)\n\n\t\t\t\treturn updated\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`更新播放列表 ${playlistId} 失败`, {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 删除一个播放列表。\n\t * @param playlistId - 要删除的播放列表的 ID。\n\t * @returns ResultAsync 包含删除的 ID 或一个错误。\n\t */\n\tpublic deletePlaylist(\n\t\tplaylistId: number,\n\t): ResultAsync<{ deletedId: number }, DatabaseError | ServiceError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 验证播放列表是否存在\n\t\t\t\tconst existing = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: and(eq(schema.playlists.id, playlistId)),\n\t\t\t\t\t\t\tcolumns: { id: true },\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!existing) {\n\t\t\t\t\tthrow createPlaylistNotFound(playlistId)\n\t\t\t\t}\n\n\t\t\t\tconst [deleted] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:delete:playlist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.delete(schema.playlists)\n\t\t\t\t\t\t\t.where(eq(schema.playlists.id, playlistId))\n\t\t\t\t\t\t\t.returning({ deletedId: schema.playlists.id }),\n\t\t\t\t)\n\n\t\t\t\treturn deleted\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`删除播放列表 ${playlistId} 失败`, {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 批量添加 tracks 到本地播放列表。\n\t * 新 track 总是追加到末尾（sort_key 最大值）。\n\t */\n\tpublic addManyTracksToLocalPlaylist(\n\t\tplaylistId: number,\n\t\ttrackIds: number[],\n\t): ResultAsync<\n\t\t(typeof schema.playlistTracks.$inferSelect)[],\n\t\tDatabaseError | ServiceError\n\t> {\n\t\tif (trackIds.length === 0) {\n\t\t\treturn okAsync([])\n\t\t}\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 验证播放列表是否存在且为 local\n\t\t\t\tconst playlist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t\t\teq(schema.playlists.type, 'local'),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcolumns: { id: true, itemCount: true },\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!playlist) {\n\t\t\t\t\tthrow createPlaylistNotFound(playlistId)\n\t\t\t\t}\n\n\t\t\t\t// 获取当前最大 sort_key（DESC 排序下，最大值对应最新加入的歌曲）\n\t\t\t\tconst maxKeyResult = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:max_sort_key', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.select({\n\t\t\t\t\t\t\t\tmaxKey: sql<\n\t\t\t\t\t\t\t\t\tstring | null\n\t\t\t\t\t\t\t\t>`MAX(${schema.playlistTracks.sortKey})`,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.from(schema.playlistTracks)\n\t\t\t\t\t\t\t.where(eq(schema.playlistTracks.playlistId, playlistId)),\n\t\t\t\t)\n\t\t\t\tlet prevKey: string | null = maxKeyResult[0].maxKey ?? null\n\n\t\t\t\t// 构造批量插入的行，每条用 generateKeyBetween(prevKey, null) 追加到末端\n\t\t\t\tconst values = trackIds.map((tid) => {\n\t\t\t\t\tconst sortKey = generateKeyBetween(prevKey, null)\n\t\t\t\t\tprevKey = sortKey\n\t\t\t\t\treturn {\n\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\ttrackId: tid,\n\t\t\t\t\t\tsortKey,\n\t\t\t\t\t}\n\t\t\t\t})\n\n\t\t\t\t// 批量插入（忽略已存在的）\n\t\t\t\tconst inserted = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:insert:playlistTracks', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.insert(schema.playlistTracks)\n\t\t\t\t\t\t\t.values(values)\n\t\t\t\t\t\t\t.onConflictDoNothing({\n\t\t\t\t\t\t\t\ttarget: [\n\t\t\t\t\t\t\t\t\tschema.playlistTracks.playlistId,\n\t\t\t\t\t\t\t\t\tschema.playlistTracks.trackId,\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.returning(),\n\t\t\t\t)\n\n\t\t\t\t// 更新播放列表的 itemCount（+ 成功插入的数量）\n\t\t\t\tif (inserted.length > 0) {\n\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:update:playlist:itemCount', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t\t.update(schema.playlists)\n\t\t\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\t\t\titemCount: sql`${schema.playlists.itemCount} + ${inserted.length}`,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.where(eq(schema.playlists.id, playlistId)),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\treturn inserted\n\t\t\t})(),\n\t\t\t(e) => new DatabaseError('批量添加歌曲到播放列表失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 从本地播放列表批量移除歌曲\n\t * @param playlistId - 目标播放列表的 ID。\n\t * @param trackIdList - 要移除的歌曲的 ID 们\n\t * @returns [removedTrackIds, missingTrackIds] 分别为被移除的 ID 和不在播放列表中的 ID\n\t */\n\tpublic batchRemoveTracksFromLocalPlaylist(\n\t\tplaylistId: number,\n\t\ttrackIdList: number[],\n\t): ResultAsync<\n\t\t{ removedTrackIds: number[]; missingTrackIds: number[] },\n\t\tDatabaseError | ServiceError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tif (trackIdList.length === 0) {\n\t\t\t\t\treturn { removedTrackIds: [], missingTrackIds: [] }\n\t\t\t\t}\n\n\t\t\t\t// 验证播放列表是否存在且为 'local'\n\t\t\t\tconst playlist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t\t\teq(schema.playlists.type, 'local'),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcolumns: { id: true },\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!playlist) {\n\t\t\t\t\tthrow createPlaylistNotFound(playlistId)\n\t\t\t\t}\n\n\t\t\t\t// 2) 批量删除关联记录，并拿到实际删除的 trackId\n\t\t\t\tconst deletedLinks = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:delete:playlistTracks', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.delete(schema.playlistTracks)\n\t\t\t\t\t\t\t.where(\n\t\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\t\teq(schema.playlistTracks.playlistId, playlistId),\n\t\t\t\t\t\t\t\t\tinArray(schema.playlistTracks.trackId, trackIdList),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.returning({ trackId: schema.playlistTracks.trackId }),\n\t\t\t\t)\n\n\t\t\t\tconst removedTrackIds = deletedLinks.map((x) => x.trackId)\n\t\t\t\tconst removedCount = removedTrackIds.length\n\n\t\t\t\tif (removedCount === 0) {\n\t\t\t\t\tthrow createTrackNotInPlaylist(trackIdList[0], playlistId)\n\t\t\t\t}\n\n\t\t\t\t// 更新 itemCount（不小于 0）\n\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:update:playlist:itemCount', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.update(schema.playlists)\n\t\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\t\titemCount: sql`MAX(0, ${schema.playlists.itemCount} - ${removedCount})`,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.where(eq(schema.playlists.id, playlistId)),\n\t\t\t\t)\n\n\t\t\t\t// 计算 missing 列表（传入但未删除，说明本就不在该列表）\n\t\t\t\tconst removedSet = new Set(removedTrackIds)\n\t\t\t\tconst missingTrackIds = trackIdList.filter((id) => !removedSet.has(id))\n\n\t\t\t\treturn { removedTrackIds, missingTrackIds }\n\t\t\t})(),\n\t\t\t(e) => {\n\t\t\t\tif (e instanceof ServiceError) return e\n\t\t\t\treturn new DatabaseError('从播放列表批量移除歌曲的事务失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t})\n\t\t\t},\n\t\t)\n\t}\n\n\t/**\n\t * 在本地播放列表中移动单个歌曲的位置（fractional indexing）。\n\t * 只需知道目标槽位两侧的 sort_key 即可，单行写入，无需移动其他行。\n\t *\n\t * @param playlistId - 目标播放列表的 ID。\n\t * @param payload - 包含 trackId 和目标位置前后两项的 sortKey。\n\t * @returns ResultAsync\n\t */\n\tpublic reorderSingleLocalPlaylistTrack(\n\t\tplaylistId: number,\n\t\tpayload: ReorderLocalPlaylistTrackPayload,\n\t): ResultAsync<true, DatabaseError | ServiceError> {\n\t\tconst { trackId, prevSortKey, nextSortKey } = payload\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:exist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t\t\teq(schema.playlists.type, 'local'),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcolumns: { id: true },\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!playlist) {\n\t\t\t\t\tthrow createPlaylistNotFound(playlistId)\n\t\t\t\t}\n\n\t\t\t\t// 前置校验：prevSortKey 必须小于 nextSortKey\n\t\t\t\tif (\n\t\t\t\t\tprevSortKey !== null &&\n\t\t\t\t\tnextSortKey !== null &&\n\t\t\t\t\tprevSortKey >= nextSortKey\n\t\t\t\t) {\n\t\t\t\t\tthrow new ServiceError(\n\t\t\t\t\t\t`Invalid sort keys: prevSortKey must be less than nextSortKey (got \"${prevSortKey}\" >= \"${nextSortKey}\")`,\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\t// 生成新的 sort_key（在 prevSortKey 和 nextSortKey 之间）\n\t\t\t\tconst newSortKey = generateKeyBetween(prevSortKey, nextSortKey)\n\n\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:update:playlistTrack:sortKey', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.update(schema.playlistTracks)\n\t\t\t\t\t\t\t.set({ sortKey: newSortKey })\n\t\t\t\t\t\t\t.where(\n\t\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\t\teq(schema.playlistTracks.playlistId, playlistId),\n\t\t\t\t\t\t\t\t\teq(schema.playlistTracks.trackId, trackId),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\treturn true as const\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('重排序播放列表歌曲失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 获取播放列表中的所有歌曲\n\t * @param playlistId - 目标播放列表的 ID。\n\t * @returns ResultAsync\n\t */\n\tpublic getPlaylistTracks(\n\t\tplaylistId: number,\n\t): ResultAsync<Track[], DatabaseError | ServiceError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst type = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:type', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\tcolumns: { type: true },\n\t\t\t\t\t\t\twhere: eq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!type) throw createPlaylistNotFound(playlistId)\n\t\t\t\tif (type.type === 'dynamic') {\n\t\t\t\t\treturn this.queryDynamicPlaylistTrackRows({ playlistId })\n\t\t\t\t}\n\t\t\t\t// 所有播放列表类型统一使用 DESC：位置越靠前的曲目 sort_key 越大\n\t\t\t\tconst orderBy = desc(schema.playlistTracks.sortKey)\n\n\t\t\t\treturn Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlistTracks', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlistTracks.findMany({\n\t\t\t\t\t\t\twhere: eq(schema.playlistTracks.playlistId, playlistId),\n\t\t\t\t\t\t\torderBy: orderBy,\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\ttrack: {\n\t\t\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('获取播放列表歌曲的事务失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t).andThen((data) => {\n\t\t\tconst newTracks = []\n\t\t\tfor (const track of data) {\n\t\t\t\tconst t = this.trackService.formatTrack(track.track)\n\t\t\t\tif (!t)\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew ServiceError(\n\t\t\t\t\t\t\t`在格式化歌曲：${track.track.id} 时出错，可能是原数据不存在或 source & metadata 不匹配`,\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\tnewTracks.push(t)\n\t\t\t}\n\t\t\treturn okAsync(newTracks)\n\t\t})\n\t}\n\n\t/**\n\t * 获取所有 playlists\n\t */\n\tpublic getAllPlaylists(): ResultAsync<\n\t\t(typeof schema.playlists.$inferSelect & {\n\t\t\tauthor: typeof schema.artists.$inferSelect | null\n\t\t})[],\n\t\tDatabaseError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlists = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlists', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findMany({\n\t\t\t\t\t\t\torderBy: desc(schema.playlists.updatedAt),\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\tauthor: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tconst countMap = await this.getDynamicPlaylistCounts(\n\t\t\t\t\tplaylists\n\t\t\t\t\t\t.filter((playlist) => playlist.type === 'dynamic')\n\t\t\t\t\t\t.map((playlist) => playlist.id),\n\t\t\t\t)\n\n\t\t\t\treturn playlists.map((playlist) => {\n\t\t\t\t\tif (playlist.type !== 'dynamic') return playlist\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...playlist,\n\t\t\t\t\t\titemCount: countMap.get(playlist.id) ?? 0,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t})(),\n\t\t\t(e) => new DatabaseError('获取所有 playlists 失败', { cause: e }),\n\t\t).andThen((playlists) => {\n\t\t\treturn okAsync(playlists)\n\t\t})\n\t}\n\n\t/**\n\t * 获取指定 playlist 的元数据\n\t * @param playlistId\n\t */\n\tpublic getPlaylistMetadata(playlistId: number): ResultAsync<\n\t\t| (typeof schema.playlists.$inferSelect & {\n\t\t\t\tauthor: typeof schema.artists.$inferSelect | null\n\t\t  } & {\n\t\t\t\tvalidTrackCount: number\n\t\t\t\ttotalDuration: number\n\t\t  })\n\t\t| undefined,\n\t\tDatabaseError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: eq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\tauthor: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tif (!playlist || playlist.type !== 'dynamic') {\n\t\t\t\t\treturn Sentry.startSpan({ name: 'db:query:playlist', op: 'db' }, () =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: eq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\tauthor: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\textras: {\n\t\t\t\t\t\t\t\tvalidTrackCount: sql<number>`(\n            SELECT COUNT(pt.track_id)\n            FROM ${schema.playlistTracks} AS pt\n            LEFT JOIN ${schema.bilibiliMetadata} AS bm\n              ON pt.track_id = bm.track_id\n            WHERE pt.playlist_id = ${playlistId}\n              AND (bm.video_is_valid IS NOT false)\n          )`.as('valid_track_count'),\n\t\t\t\t\t\t\t\ttotalDuration: sql<number>`(\n            SELECT COALESCE(SUM(t.duration), 0)\n            FROM ${schema.playlistTracks} AS pt\n            JOIN ${schema.tracks} AS t\n              ON pt.track_id = t.id\n            LEFT JOIN ${schema.bilibiliMetadata} AS bm\n              ON pt.track_id = bm.track_id\n            WHERE pt.playlist_id = ${playlistId}\n              AND (bm.video_is_valid IS NOT false)\n\t\t\t\t\t\t)`.as('total_duration'),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst stats = await this.getDynamicPlaylistStats(playlistId)\n\n\t\t\t\treturn {\n\t\t\t\t\t...playlist,\n\t\t\t\t\titemCount: stats.itemCount,\n\t\t\t\t\tvalidTrackCount: stats.validTrackCount,\n\t\t\t\t\ttotalDuration: stats.totalDuration,\n\t\t\t\t}\n\t\t\t})(),\n\t\t\t(e) => new DatabaseError('获取 playlist 元数据失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 根据 remoteSyncId 和 type 查找或创建一个本地同步的远程播放列表。\n\t * @param payload - 创建播放列表所需的数据。\n\t * @returns ResultAsync 包含找到的或新创建的 Playlist，或一个 DatabaseError。\n\t */\n\tpublic findOrCreateRemotePlaylist(\n\t\tpayload: CreatePlaylistPayload,\n\t): ResultAsync<\n\t\ttypeof schema.playlists.$inferSelect,\n\t\tDatabaseError | ServiceError\n\t> {\n\t\tconst { remoteSyncId, type } = payload\n\t\tif (!remoteSyncId || type === 'local' || type === 'dynamic') {\n\t\t\treturn errAsync(\n\t\t\t\tcreateValidationError(\n\t\t\t\t\t'无效的 remoteSyncId 或 type，调用 findOrCreateRemotePlaylist 时必须提供 remoteSyncId 和非 local 的 type',\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst existingPlaylist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlists.remoteSyncId, remoteSyncId),\n\t\t\t\t\t\t\t\teq(schema.playlists.type, type),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tif (existingPlaylist) {\n\t\t\t\t\treturn existingPlaylist\n\t\t\t\t}\n\n\t\t\t\tconst [newPlaylist] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:insert:playlist', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.insert(schema.playlists)\n\t\t\t\t\t\t\t.values({\n\t\t\t\t\t\t\t\ttitle: payload.title,\n\t\t\t\t\t\t\t\tauthorId: payload.authorId,\n\t\t\t\t\t\t\t\tdescription: payload.description,\n\t\t\t\t\t\t\t\tcoverUrl: payload.coverUrl,\n\t\t\t\t\t\t\t\ttype: payload.type,\n\t\t\t\t\t\t\t\tremoteSyncId: payload.remoteSyncId,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.returning(),\n\t\t\t\t)\n\n\t\t\t\treturn newPlaylist\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('查找或创建播放列表的事务失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 使用一个 track ID 数组**完全替换**一个播放列表的内容。并会更新播放列表的 itemCount 和 lastSyncedAt。\n\t * @param playlistId 要设置的播放列表 ID。\n\t * @param trackIds 有序的歌曲 ID 数组。\n\t * @returns ResultAsync\n\t */\n\tpublic replacePlaylistAllTracks(\n\t\tplaylistId: number,\n\t\ttrackIds: number[],\n\t): ResultAsync<true, DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:delete:playlistTracks', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.delete(schema.playlistTracks)\n\t\t\t\t\t\t\t.where(eq(schema.playlistTracks.playlistId, playlistId)),\n\t\t\t\t)\n\n\t\t\t\tif (trackIds.length > 0) {\n\t\t\t\t\t// 倒序生成 sort_key：trackIds[0]（排列首位）获得最大的 sort_key\n\t\t\t\t\t// 与 local playlist 约定一致：位置越靠前 sort_key 越大，查询时统一使用 DESC\n\t\t\t\t\tlet prevKey: string | null = null\n\t\t\t\t\tconst sortKeys: string[] = new Array(trackIds.length)\n\t\t\t\t\tfor (let i = trackIds.length - 1; i >= 0; i--) {\n\t\t\t\t\t\tsortKeys[i] = generateKeyBetween(prevKey, null)\n\t\t\t\t\t\tprevKey = sortKeys[i]!\n\t\t\t\t\t}\n\t\t\t\t\tconst newPlaylistTracks = trackIds.map((id, i) => ({\n\t\t\t\t\t\tplaylistId: playlistId,\n\t\t\t\t\t\ttrackId: id,\n\t\t\t\t\t\tsortKey: sortKeys[i],\n\t\t\t\t\t}))\n\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:insert:playlistTracks', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db.insert(schema.playlistTracks).values(newPlaylistTracks),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tawait Sentry.startSpan({ name: 'db:update:playlist', op: 'db' }, () =>\n\t\t\t\t\tthis.db\n\t\t\t\t\t\t.update(schema.playlists)\n\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\titemCount: trackIds.length,\n\t\t\t\t\t\t\tlastSyncedAt: new Date(),\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.where(eq(schema.playlists.id, playlistId)),\n\t\t\t\t)\n\n\t\t\t\treturn true as const\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\tnew DatabaseError(`设置播放列表歌曲失败 (ID: ${playlistId})`, {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 基于 type & remoteId 查询一个播放列表\n\t * @param type\n\t * @param remoteId\n\t */\n\tpublic findPlaylistByTypeAndRemoteId(\n\t\ttype: Playlist['type'],\n\t\tremoteId: number,\n\t): ResultAsync<\n\t\t| (typeof schema.playlists.$inferSelect & {\n\t\t\t\ttrackLinks: (typeof schema.playlistTracks.$inferSelect)[]\n\t\t  })\n\t\t| undefined,\n\t\tDatabaseError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:playlist', op: 'db' }, () =>\n\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\twhere: and(\n\t\t\t\t\t\teq(schema.playlists.type, type),\n\t\t\t\t\t\teq(schema.playlists.remoteSyncId, remoteId),\n\t\t\t\t\t),\n\t\t\t\t\twith: {\n\t\t\t\t\t\ttrackLinks: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) => new DatabaseError('查询播放列表失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 根据 ID 获取播放列表\n\t * @param playlistId\n\t */\n\tpublic getPlaylistById(playlistId: number) {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:playlist', op: 'db' }, () =>\n\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\twhere: eq(schema.playlists.id, playlistId),\n\t\t\t\t\twith: {\n\t\t\t\t\t\tauthor: true,\n\t\t\t\t\t\ttrackLinks: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) => new DatabaseError('查询播放列表失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 通过 uniqueKey 获取包含指定歌曲的所有本地播放列表\n\t * @param uniqueKey:  track uniqueKey\n\t */\n\tpublic getLocalPlaylistsContainingTrackByUniqueKey(\n\t\tuniqueKey: string,\n\t): ResultAsync<(typeof schema.playlists.$inferSelect)[], DatabaseError> {\n\t\treturn this.trackService\n\t\t\t.findTrackIdsByUniqueKeys([uniqueKey])\n\t\t\t.andThen((trackIds) => {\n\t\t\t\tif (!trackIds.has(uniqueKey)) return okAsync([])\n\t\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\t\tSentry.startSpan({ name: 'db:query:playlists', op: 'db' }, () =>\n\t\t\t\t\t\tthis.db.query.playlists.findMany({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlists.type, 'local'),\n\t\t\t\t\t\t\t\tinArray(\n\t\t\t\t\t\t\t\t\tschema.playlists.id,\n\t\t\t\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t\t\t\t.select({\n\t\t\t\t\t\t\t\t\t\t\tplaylistId: schema.playlistTracks.playlistId,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t.from(schema.playlistTracks)\n\t\t\t\t\t\t\t\t\t\t.where(\n\t\t\t\t\t\t\t\t\t\t\teq(\n\t\t\t\t\t\t\t\t\t\t\t\tschema.playlistTracks.trackId,\n\t\t\t\t\t\t\t\t\t\t\t\ttrackIds.get(uniqueKey)!,\n\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\t(e) =>\n\t\t\t\t\t\tnew DatabaseError('获取包含该歌曲的本地播放列表失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t\t\t).andThen((playlists) => {\n\t\t\t\t\treturn okAsync(playlists)\n\t\t\t\t})\n\t\t\t})\n\t}\n\n\t/**\n\t * 获取包含指定歌曲的所有本地播放列表\n\t * @param trackId:  track id（number）\n\t */\n\tpublic getLocalPlaylistsContainingTrackById(\n\t\ttrackId: number,\n\t): ResultAsync<(typeof schema.playlists.$inferSelect)[], DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:playlists', op: 'db' }, () =>\n\t\t\t\tthis.db.query.playlists.findMany({\n\t\t\t\t\twhere: and(\n\t\t\t\t\t\teq(schema.playlists.type, 'local'),\n\t\t\t\t\t\tinArray(\n\t\t\t\t\t\t\tschema.playlists.id,\n\t\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t\t.select({\n\t\t\t\t\t\t\t\t\tplaylistId: schema.playlistTracks.playlistId,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.from(schema.playlistTracks)\n\t\t\t\t\t\t\t\t.where(eq(schema.playlistTracks.trackId, trackId)),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\tnew DatabaseError('获取包含该歌曲的本地播放列表失败', {\n\t\t\t\t\tcause: e,\n\t\t\t\t}),\n\t\t).andThen((playlists) => {\n\t\t\treturn okAsync(playlists)\n\t\t})\n\t}\n\n\t/**\n\t * 搜索播放列表\n\t * @param query - 搜索关键词\n\t */\n\tpublic searchPlaylists(query: string): ResultAsync<\n\t\t(typeof schema.playlists.$inferSelect & {\n\t\t\tauthor: typeof schema.artists.$inferSelect | null\n\t\t})[],\n\t\tDatabaseError\n\t> {\n\t\tconst trimmed = query.trim()\n\t\tif (!trimmed) {\n\t\t\treturn okAsync([])\n\t\t}\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlists = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:searchPlaylists', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findMany({\n\t\t\t\t\t\t\twhere: like(schema.playlists.title, `%${trimmed}%`),\n\t\t\t\t\t\t\torderBy: desc(schema.playlists.updatedAt),\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\tauthor: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tconst countMap = await this.getDynamicPlaylistCounts(\n\t\t\t\t\tplaylists\n\t\t\t\t\t\t.filter((playlist) => playlist.type === 'dynamic')\n\t\t\t\t\t\t.map((playlist) => playlist.id),\n\t\t\t\t)\n\n\t\t\t\treturn playlists.map((playlist) => {\n\t\t\t\t\tif (playlist.type !== 'dynamic') return playlist\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...playlist,\n\t\t\t\t\t\titemCount: countMap.get(playlist.id) ?? 0,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t})(),\n\t\t\t(e) => new DatabaseError('搜索播放列表失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 在某个 playlist 中依据名字搜索歌曲\n\t * @param playlistId\n\t * @param query\n\t */\n\tpublic searchTrackInPlaylist(\n\t\tplaylistId: number,\n\t\tquery: string,\n\t): ResultAsync<Track[], DatabaseError | ServiceError> {\n\t\tconst q = `%${query.trim().toLowerCase()}%`\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:type', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\tcolumns: { type: true },\n\t\t\t\t\t\t\twhere: eq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!playlist) throw createPlaylistNotFound(playlistId)\n\t\t\t\tif (playlist.type === 'dynamic') {\n\t\t\t\t\tconst rows = await this.queryDynamicPlaylistTrackRows({\n\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\tquery,\n\t\t\t\t\t})\n\t\t\t\t\tconst tracks: Track[] = []\n\t\t\t\t\tfor (const row of rows) {\n\t\t\t\t\t\tconst track = this.trackService.formatTrack(row.track)\n\t\t\t\t\t\tif (!track) {\n\t\t\t\t\t\t\tthrow new ServiceError(\n\t\t\t\t\t\t\t\t`在格式化歌曲：${row.track.id} 时出错，可能是原数据不存在或 source & metadata 不匹配`,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttracks.push(track)\n\t\t\t\t\t}\n\t\t\t\t\treturn tracks\n\t\t\t\t}\n\n\t\t\t\tconst trackIdSubq = db\n\t\t\t\t\t.select({ id: schema.tracks.id })\n\t\t\t\t\t.from(schema.tracks)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.artists,\n\t\t\t\t\t\teq(schema.tracks.artistId, schema.artists.id),\n\t\t\t\t\t)\n\t\t\t\t\t.where(like(sql`lower(${schema.tracks.title})`, q))\n\n\t\t\t\tconst rows = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlistTracks', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tdb.query.playlistTracks.findMany({\n\t\t\t\t\t\t\twhere: and(\n\t\t\t\t\t\t\t\teq(schema.playlistTracks.playlistId, playlistId),\n\t\t\t\t\t\t\t\tinArray(schema.playlistTracks.trackId, trackIdSubq),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\ttrack: {\n\t\t\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\torderBy: desc(schema.playlistTracks.sortKey),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tconst newTracks = []\n\t\t\t\tfor (const row of rows) {\n\t\t\t\t\tconst t = this.trackService.formatTrack(row.track)\n\t\t\t\t\tif (!t)\n\t\t\t\t\t\tthrow new ServiceError(\n\t\t\t\t\t\t\t`在格式化歌曲：${row.track.id} 时出错，可能是原数据不存在或 source & metadata 不匹配`,\n\t\t\t\t\t\t)\n\t\t\t\t\tnewTracks.push(t)\n\t\t\t\t}\n\t\t\t\treturn newTracks\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('搜索歌曲失败', { cause: e }),\n\t\t)\n\t}\n\n\t/**\n\t * 游标分页的获取播放列表中歌曲\n\t *\n\t * @param options - 分页选项\n\t * @param options.playlistId - 目标播放列表的 ID。\n\t * @param options.initialLimit - 如果是第一页，使用的数量限制（如无则为 limit）\n\t * @param options.limit - 每次获取的数量\n\t * @param options.cursor - 上一页最后一条记录的游标。\n\t * 如果是第一页，则为 undefined。\n\t * @returns ResultAsync 包含歌曲列表和下一个游标\n\t */\n\tpublic getPlaylistTracksPaginated(options: {\n\t\tplaylistId: number\n\t\tinitialLimit?: number\n\t\tlimit: number\n\t\tcursor:\n\t\t\t| {\n\t\t\t\t\tlastSortKey: string\n\t\t\t\t\tcreatedAt: number\n\t\t\t\t\tlastId: number\n\t\t\t  }\n\t\t\t| undefined\n\t}): ResultAsync<\n\t\t{\n\t\t\ttracks: Track[]\n\t\t\tsortKeys: string[]\n\t\t\tnextCursor?: {\n\t\t\t\tlastSortKey: string\n\t\t\t\tcreatedAt: number\n\t\t\t\tlastId: number\n\t\t\t}\n\t\t\tnextPageFirstSortKey?: string\n\t\t},\n\t\tDatabaseError | ServiceError\n\t> {\n\t\tconst { limit, cursor, playlistId, initialLimit } = options\n\n\t\tconst effectiveLimit = cursor ? limit : (initialLimit ?? limit)\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst playlist = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlist:type', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\t\t\tcolumns: { type: true },\n\t\t\t\t\t\t\twhere: eq(schema.playlists.id, playlistId),\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (!playlist) throw createPlaylistNotFound(playlistId)\n\t\t\t\tif (playlist.type === 'dynamic') {\n\t\t\t\t\treturn this.queryDynamicPlaylistTrackRows({\n\t\t\t\t\t\tplaylistId,\n\t\t\t\t\t\tlimit: effectiveLimit + 1,\n\t\t\t\t\t\tcursor,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// 所有播放列表类型统一使用 DESC：位置越靠前的曲目 sort_key 越大\n\t\t\t\tconst sortDirection = desc\n\t\t\t\tconst operator = lt\n\n\t\t\t\tconst orderBy = [\n\t\t\t\t\tsortDirection(schema.playlistTracks.sortKey),\n\t\t\t\t\tsortDirection(schema.playlistTracks.createdAt),\n\t\t\t\t\tsortDirection(schema.playlistTracks.trackId),\n\t\t\t\t]\n\n\t\t\t\tconst whereClauses: (SQL | undefined)[] = [\n\t\t\t\t\teq(schema.playlistTracks.playlistId, playlistId),\n\t\t\t\t]\n\n\t\t\t\tif (cursor) {\n\t\t\t\t\tconst { lastSortKey, createdAt, lastId } = cursor\n\t\t\t\t\tconst dateObj = new Date(createdAt)\n\n\t\t\t\t\twhereClauses.push(\n\t\t\t\t\t\tor(\n\t\t\t\t\t\t\toperator(schema.playlistTracks.sortKey, lastSortKey),\n\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\teq(schema.playlistTracks.sortKey, lastSortKey),\n\t\t\t\t\t\t\t\toperator(schema.playlistTracks.createdAt, dateObj),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\teq(schema.playlistTracks.sortKey, lastSortKey),\n\t\t\t\t\t\t\t\teq(schema.playlistTracks.createdAt, dateObj),\n\t\t\t\t\t\t\t\toperator(schema.playlistTracks.trackId, lastId),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst data = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:playlistTracks:paginated', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.playlistTracks.findMany({\n\t\t\t\t\t\t\twhere: and(...whereClauses),\n\t\t\t\t\t\t\torderBy: orderBy,\n\t\t\t\t\t\t\tlimit: effectiveLimit + 1,\n\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\ttrack: {\n\t\t\t\t\t\t\t\t\twith: {\n\t\t\t\t\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\treturn data\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('分页获取播放列表歌曲的事务失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t).andThen((data) => {\n\t\t\tconst newTracks: Track[] = []\n\t\t\tconst sortKeys: string[] = []\n\t\t\tfor (const pt of data) {\n\t\t\t\tconst t = this.trackService.formatTrack(pt.track)\n\t\t\t\tif (!t) {\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tnew ServiceError(\n\t\t\t\t\t\t\t`在格式化歌曲：${pt.track.id} 时出错，可能是原数据不存在或 source & metadata 不匹配`,\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tnewTracks.push(t)\n\t\t\t\tsortKeys.push(pt.sortKey)\n\t\t\t}\n\n\t\t\tlet nextCursor\n\t\t\tlet nextPageFirstSortKey\n\t\t\tconst hasMore = data.length === effectiveLimit + 1\n\n\t\t\tif (hasMore) {\n\t\t\t\tconst lastItem = data[effectiveLimit - 1]\n\t\t\t\tnextCursor = {\n\t\t\t\t\tlastSortKey: lastItem.sortKey,\n\t\t\t\t\tcreatedAt: lastItem.createdAt.getTime(),\n\t\t\t\t\tlastId: lastItem.trackId,\n\t\t\t\t}\n\t\t\t\tnextPageFirstSortKey = data[effectiveLimit].sortKey\n\t\t\t}\n\n\t\t\treturn okAsync({\n\t\t\t\ttracks: hasMore ? newTracks.slice(0, effectiveLimit) : newTracks,\n\t\t\t\tsortKeys: hasMore ? sortKeys.slice(0, effectiveLimit) : sortKeys,\n\t\t\t\tnextCursor,\n\t\t\t\tnextPageFirstSortKey,\n\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 根据 shareId（后端 UUID）查找本地歌单\n\t */\n\tpublic findPlaylistByShareId(\n\t\tshareId: string,\n\t): ResultAsync<typeof schema.playlists.$inferSelect | false, DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:playlist:byShareId', op: 'db' }, () =>\n\t\t\t\tthis.db.query.playlists.findFirst({\n\t\t\t\t\twhere: eq(schema.playlists.shareId, shareId),\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) => new DatabaseError('根据 shareId 查找歌单失败', { cause: e }),\n\t\t).andThen((playlist) => {\n\t\t\tif (!playlist) return okAsync(false as const)\n\t\t\treturn okAsync(playlist)\n\t\t})\n\t}\n\n\t/**\n\t * 获取所有已共享（shareId 不为 null）的本地歌单\n\t */\n\tpublic getSharedPlaylists(): ResultAsync<\n\t\t(typeof schema.playlists.$inferSelect)[],\n\t\tDatabaseError\n\t> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:playlists:shared', op: 'db' }, () =>\n\t\t\t\tthis.db.query.playlists.findMany({\n\t\t\t\t\twhere: (p, { isNotNull }) => isNotNull(p.shareId),\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) => new DatabaseError('获取共享歌单列表失败', { cause: e }),\n\t\t)\n\t}\n}\n\nexport const playlistService = new PlaylistService(db, trackService)\n"
  },
  {
    "path": "apps/mobile/src/lib/services/syncLocalToBilibiliService.ts",
    "content": "import { err, ok, type Result, type ResultAsync } from 'neverthrow'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport type { Track } from '@/types/core/media'\nimport log from '@/utils/log'\nimport { diffSets } from '@/utils/set'\n\nconst logger = log.extend('Services.SyncLocalToBilibili')\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nclass SyncLocalToBilibiliService {\n\t/**\n\t * 通过名称查找远程收藏夹\n\t */\n\tfindRemotePlaylistByName(\n\t\tuserMid: number,\n\t\tname: string,\n\t): ResultAsync<\n\t\t{ id: number; title: string; media_count: number } | null,\n\t\tError\n\t> {\n\t\treturn bilibiliApi.getFavoritePlaylists(userMid).map((list) => {\n\t\t\tconst found = list.find((p) => p.title.trim() === name.trim())\n\t\t\treturn found\n\t\t\t\t? { id: found.id, title: found.title, media_count: found.media_count }\n\t\t\t\t: null\n\t\t})\n\t}\n\n\t/**\n\t * 创建新的远程收藏夹\n\t */\n\tcreateRemotePlaylist(\n\t\tname: string,\n\t\tintro?: string,\n\t): ResultAsync<{ id: number }, Error> {\n\t\treturn bilibiliApi.createFavoriteFolder(name, intro).map((res) => ({\n\t\t\tid: res.id,\n\t\t}))\n\t}\n\n\t/**\n\t * 计算同步差异\n\t * 策略：镜像同步（远程将与本地一致），远程多余的项将被移除。\n\t * 返回两组 bvid 用于后续操作\n\t */\n\tasync calculateSyncDiff(\n\t\tlocalTracks: Track[],\n\t\tremotePlaylistId: number,\n\t): Promise<\n\t\tResult<\n\t\t\t{\n\t\t\t\ttoAdd: string[]\n\t\t\t\ttoRemove: string[]\n\t\t\t},\n\t\t\tError\n\t\t>\n\t> {\n\t\t// 1. 获取所有远程内容\n\t\tconst remoteContentsResult =\n\t\t\tawait bilibiliApi.getFavoriteListAllContents(remotePlaylistId)\n\n\t\tif (remoteContentsResult.isErr()) {\n\t\t\treturn err(remoteContentsResult.error)\n\t\t}\n\n\t\tconst remoteBvids = new Set(remoteContentsResult.value.map((i) => i.bvid))\n\n\t\t// 2. 筛选本地 B 站来源的歌曲\n\t\tconst validLocalTracks = localTracks.filter(\n\t\t\t(t): t is Track & { source: 'bilibili' } =>\n\t\t\t\tt.source === 'bilibili' && !!t.bilibiliMetadata?.bvid,\n\t\t)\n\n\t\tconst localBvids = new Set(\n\t\t\tvalidLocalTracks.map((t) => t.bilibiliMetadata.bvid),\n\t\t)\n\n\t\t// 3. 对比差异\n\t\tconst { added: addedBvids, removed: removedBvids } = diffSets(\n\t\t\tremoteBvids, // source\n\t\t\tlocalBvids, // target\n\t\t)\n\n\t\treturn ok({\n\t\t\ttoAdd: Array.from(addedBvids),\n\t\t\ttoRemove: Array.from(removedBvids),\n\t\t})\n\t}\n\n\t/**\n\t * 批量添加歌曲到远程收藏夹\n\t */\n\tasync executeBatchAdd(\n\t\tfolderId: number,\n\t\tbvidsToAdd: string[],\n\t\tonProgress?: (curr: number) => void,\n\t): Promise<Result<number, Error>> {\n\t\tlet successCount = 0\n\t\tlet failCount = 0\n\n\t\tconst CONCURRENCY = 1\n\t\tconst queue = [...bvidsToAdd]\n\n\t\tconst worker = async () => {\n\t\t\twhile (queue.length > 0) {\n\t\t\t\tconst bvid = queue.shift()\n\t\t\t\tif (!bvid) break\n\n\t\t\t\t// 添加到 folderId，不从任何文件夹移除\n\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\tconst res = await bilibiliApi.dealFavoriteForOneVideo(\n\t\t\t\t\tbvid,\n\t\t\t\t\t[String(folderId)],\n\t\t\t\t\t[],\n\t\t\t\t)\n\n\t\t\t\tif (res.isOk()) {\n\t\t\t\t\tsuccessCount++\n\t\t\t\t} else {\n\t\t\t\t\tlogger.warning(\n\t\t\t\t\t\t`Failed to add ${bvid} to folder ${folderId}`,\n\t\t\t\t\t\tres.error,\n\t\t\t\t\t)\n\t\t\t\t\tfailCount++\n\t\t\t\t}\n\t\t\t\tonProgress?.(successCount + failCount)\n\n\t\t\t\t// 添加延时防止风控\n\t\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\t\tawait sleep(300)\n\t\t\t}\n\t\t}\n\n\t\tawait Promise.all(\n\t\t\tArray(CONCURRENCY)\n\t\t\t\t.fill(0)\n\t\t\t\t.map(() => worker()),\n\t\t)\n\n\t\tif (failCount > 0) {\n\t\t\tlogger.warning(\n\t\t\t\t`Batch add completed with ${failCount} failures out of ${bvidsToAdd.length}`,\n\t\t\t)\n\t\t}\n\t\treturn ok(failCount)\n\t}\n\n\t/**\n\t * 批量从远程收藏夹移除歌曲\n\t */\n\tasync executeBatchRemove(\n\t\tfolderId: number,\n\t\ttokensToRemove: string[],\n\t): Promise<Result<void, Error>> {\n\t\tif (tokensToRemove.length === 0) return ok(void 0)\n\n\t\t// API 限制分块\n\t\tconst CHUNK_SIZE = 20\n\t\tfor (let i = 0; i < tokensToRemove.length; i += CHUNK_SIZE) {\n\t\t\tconst chunk = tokensToRemove.slice(i, i + CHUNK_SIZE)\n\t\t\t// oxlint-disable-next-line no-await-in-loop\n\t\t\tconst res = await bilibiliApi.batchDeleteFavoriteListContents(\n\t\t\t\tfolderId,\n\t\t\t\tchunk,\n\t\t\t)\n\t\t\tif (res.isErr()) {\n\t\t\t\treturn err(res.error)\n\t\t\t}\n\t\t}\n\t\treturn ok(void 0)\n\t}\n}\n\nexport const syncLocalToBilibiliService = new SyncLocalToBilibiliService()\n"
  },
  {
    "path": "apps/mobile/src/lib/services/trackService.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport type { SQL } from 'drizzle-orm'\nimport { and, count, desc, eq, inArray, lt, or, sql, sum } from 'drizzle-orm'\nimport { type ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'\nimport { Result, ResultAsync, err, errAsync, okAsync } from 'neverthrow'\n\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { ServiceError } from '@/lib/errors'\nimport {\n\tDatabaseError,\n\tcreateNotImplementedError,\n\tcreateTrackNotFound,\n\tcreateValidationError,\n} from '@/lib/errors/service'\nimport type {\n\tBilibiliTrack,\n\tLocalTrack,\n\tPlayRecord,\n\tTrack,\n} from '@/types/core/media'\nimport type {\n\tBilibiliMetadataPayload,\n\tCreateBilibiliTrackPayload,\n\tCreateTrackPayload,\n\tCreateTrackPayloadBase,\n\tUpdateTrackPayload,\n\tUpdateTrackPayloadBase,\n} from '@/types/services/track'\nimport log from '@/utils/log'\n\nimport generateUniqueTrackKey from './genKey'\n\nconst logger = log.extend('Service.Track')\ntype Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]\ntype DBLike = ExpoSQLiteDatabase<typeof schema> | Tx\ntype SelectTrackBase = typeof schema.tracks.$inferSelect\ntype SelectTrackWithMetadata = SelectTrackBase & {\n\tartist: typeof schema.artists.$inferSelect | null\n\tbilibiliMetadata: typeof schema.bilibiliMetadata.$inferSelect | null\n\tlocalMetadata: typeof schema.localMetadata.$inferSelect | null\n}\n\nexport class TrackService {\n\tconstructor(private readonly db: DBLike) {}\n\n\t/**\n\t * 返回一个使用新数据库连接（例如事务）的新实例。\n\t * @param conn - 新的数据库连接或事务。\n\t * @returns 一个新的实例。\n\t */\n\twithDB(conn: DBLike) {\n\t\treturn new TrackService(conn)\n\t}\n\n\t/**\n\t * 基本上是为了让 Typescript 开心\n\t * @param dbTrack\n\t * @returns\n\t */\n\tpublic formatTrack(\n\t\tdbTrack: SelectTrackWithMetadata | undefined | null,\n\t): Track | null {\n\t\tif (!dbTrack) {\n\t\t\treturn null\n\t\t}\n\n\t\tconst baseTrack = {\n\t\t\tid: dbTrack.id,\n\t\t\tuniqueKey: dbTrack.uniqueKey,\n\t\t\ttitle: dbTrack.title,\n\t\t\tartist: dbTrack.artist,\n\t\t\tcoverUrl: dbTrack.coverUrl,\n\t\t\tduration: dbTrack.duration,\n\t\t\tcreatedAt: dbTrack.createdAt,\n\t\t\tsource: dbTrack.source,\n\t\t\tupdatedAt: dbTrack.updatedAt,\n\t\t}\n\n\t\tif (dbTrack.source === 'bilibili' && dbTrack.bilibiliMetadata) {\n\t\t\treturn {\n\t\t\t\t...baseTrack,\n\t\t\t\tbilibiliMetadata: dbTrack.bilibiliMetadata,\n\t\t\t} as BilibiliTrack\n\t\t}\n\n\t\tif (dbTrack.source === 'local' && dbTrack.localMetadata) {\n\t\t\treturn {\n\t\t\t\t...baseTrack,\n\t\t\t\tlocalMetadata: dbTrack.localMetadata,\n\t\t\t} as LocalTrack\n\t\t}\n\n\t\tlogger.warning(`track ${dbTrack.id} 存在不一致的 source 和 metadata。`)\n\t\treturn null\n\t}\n\n\t/**\n\t * 创建一个新的 track\n\t * @param payload - 创建 track 所需的数据。\n\t * @returns ResultAsync 包含成功创建的 Track 或一个错误。\n\t */\n\tprivate _createTrack(\n\t\tpayload: CreateTrackPayload,\n\t): ResultAsync<Track, ServiceError | DatabaseError> {\n\t\t// validate\n\t\tif (payload.source === 'bilibili' && !payload.bilibiliMetadata) {\n\t\t\treturn errAsync(\n\t\t\t\tcreateValidationError(\n\t\t\t\t\t'当 source 为 bilibili 时，bilibiliMetadata 不能为空。',\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\t\tif (payload.source === 'local' && !payload.localMetadata) {\n\t\t\treturn errAsync(\n\t\t\t\tcreateValidationError(\n\t\t\t\t\t'当 source 为 local 时，localMetadata 不能为空。',\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\tconst uniqueKey = generateUniqueTrackKey(payload)\n\t\tif (uniqueKey.isErr()) {\n\t\t\treturn errAsync(uniqueKey.error)\n\t\t}\n\n\t\tconst transactionResult = ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\t// 创建 track\n\t\t\t\tconst [newTrack] = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:insert:track', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.insert(schema.tracks)\n\t\t\t\t\t\t\t.values({\n\t\t\t\t\t\t\t\ttitle: payload.title,\n\t\t\t\t\t\t\t\tsource: payload.source,\n\t\t\t\t\t\t\t\tartistId: payload.artistId,\n\t\t\t\t\t\t\t\tcoverUrl: payload.coverUrl,\n\t\t\t\t\t\t\t\tduration: payload.duration,\n\t\t\t\t\t\t\t\tuniqueKey: uniqueKey.value,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.returning({ id: schema.tracks.id }),\n\t\t\t\t)\n\n\t\t\t\tconst trackId = newTrack.id\n\n\t\t\t\t// 创建元数据\n\t\t\t\tif (payload.source === 'bilibili') {\n\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:insert:bilibiliMetadata', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db.insert(schema.bilibiliMetadata).values({\n\t\t\t\t\t\t\t\ttrackId,\n\t\t\t\t\t\t\t\tbvid: payload.bilibiliMetadata.bvid,\n\t\t\t\t\t\t\t\tcid: payload.bilibiliMetadata.cid,\n\t\t\t\t\t\t\t\tisMultiPage: payload.bilibiliMetadata.isMultiPage,\n\t\t\t\t\t\t\t\tmainTrackTitle: payload.bilibiliMetadata.mainTrackTitle,\n\t\t\t\t\t\t\t\tvideoIsValid: payload.bilibiliMetadata.videoIsValid,\n\t\t\t\t\t\t\t} satisfies BilibiliMetadataPayload & {\n\t\t\t\t\t\t\t\ttrackId: number\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t} else if (payload.source === 'local') {\n\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:insert:localMetadata', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db.insert(schema.localMetadata).values({\n\t\t\t\t\t\t\t\ttrackId,\n\t\t\t\t\t\t\t\tlocalPath: payload.localMetadata.localPath,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\treturn trackId\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('创建 track 事务失败', { cause: e }),\n\t\t)\n\n\t\treturn transactionResult.andThen((newTrackId) =>\n\t\t\tthis.getTrackById(newTrackId),\n\t\t)\n\t}\n\n\t/**\n\t * 更新一个现有的 track 。\n\t * @param payload - 更新 track 所需的数据。\n\t * @returns ResultAsync 包含更新后的 Track 或一个错误。\n\t */\n\tpublic updateTrack(\n\t\tpayload: UpdateTrackPayload,\n\t): ResultAsync<Track, ServiceError | DatabaseError> {\n\t\tconst { id, ...dataToUpdate } = payload\n\n\t\tconst updateResult = ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\treturn await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:update:track', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.update(schema.tracks)\n\t\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\t\ttitle: dataToUpdate.title ?? undefined,\n\t\t\t\t\t\t\t\tartistId: dataToUpdate.artistId,\n\t\t\t\t\t\t\t\tcoverUrl: dataToUpdate.coverUrl,\n\t\t\t\t\t\t\t\tduration: dataToUpdate.duration,\n\t\t\t\t\t\t\t} satisfies Omit<UpdateTrackPayloadBase, 'id'>)\n\t\t\t\t\t\t\t.where(eq(schema.tracks.id, id)),\n\t\t\t\t)\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`更新 track 失败：${id}`, { cause: e }),\n\t\t)\n\n\t\treturn updateResult.andThen(() => this.getTrackById(id))\n\t}\n\n\t/**\n\t * 通过 ID 获取单个 track 的完整信息。\n\t * @param id -  track 的数据库 ID。\n\t * @returns ResultAsync\n\t */\n\tpublic getTrackById(\n\t\tid: number,\n\t): ResultAsync<Track, ServiceError | DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:track', op: 'db' }, () =>\n\t\t\t\tthis.db.query.tracks.findFirst({\n\t\t\t\t\twhere: eq(schema.tracks.id, id),\n\t\t\t\t\twith: {\n\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`查找 track 失败：${id}`, { cause: e }),\n\t\t).andThen((dbTrack) => {\n\t\t\tconst result = this.formatTrack(dbTrack)\n\t\t\tif (!result) {\n\t\t\t\treturn errAsync(createTrackNotFound(id))\n\t\t\t}\n\t\t\treturn okAsync(result)\n\t\t})\n\t}\n\n\t/**\n\t * 删除一个 track。\n\t * @param id - 要删除的 track 的 ID。\n\t * @returns ResultAsync\n\t */\n\tpublic deleteTrack(\n\t\tid: number,\n\t): ResultAsync<{ deletedId: number }, ServiceError | DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:delete:track', op: 'db' }, () =>\n\t\t\t\tthis.db\n\t\t\t\t\t.delete(schema.tracks)\n\t\t\t\t\t.where(eq(schema.tracks.id, id))\n\t\t\t\t\t.returning({ deletedId: schema.tracks.id }),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`删除 track 失败：${id}`, { cause: e }),\n\t\t).andThen((results) => {\n\t\t\tconst result = results[0]\n\t\t\tif (!result) {\n\t\t\t\treturn errAsync(createTrackNotFound(id))\n\t\t\t}\n\t\t\treturn okAsync(result)\n\t\t})\n\t}\n\n\t/**\n\t * 为 track 增加一次播放记录。\n\t * @param trackId -  track 的 ID。\n\t * @param record - 播放记录。\n\t * @returns ResultAsync 包含 true 或一个错误。\n\t */\n\tpublic addPlayRecordFromTrackId(\n\t\ttrackId: number,\n\t\trecord: PlayRecord,\n\t): ResultAsync<true, ServiceError | DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:insert:play_history', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.insert(schema.playHistory).values({\n\t\t\t\t\t\t\ttrackId,\n\t\t\t\t\t\t\tstartTime: record.startTime,\n\t\t\t\t\t\t\tdurationPlayed: record.durationPlayed,\n\t\t\t\t\t\t\tcompleted: record.completed,\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\treturn true as const\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`增加播放记录失败：${trackId}`, {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\tpublic addPlayRecordFromUniqueKey(\n\t\tuniqueKey: string,\n\t\trecord: PlayRecord,\n\t): ResultAsync<true, ServiceError | DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst track = await this.findTrackIdsByUniqueKeys([uniqueKey])\n\t\t\t\tif (track.isErr()) {\n\t\t\t\t\tthrow track.error\n\t\t\t\t}\n\t\t\t\tconst trackId = track.value.get(uniqueKey)\n\t\t\t\tif (!trackId) {\n\t\t\t\t\tthrow createTrackNotFound(uniqueKey)\n\t\t\t\t}\n\n\t\t\t\tawait this.addPlayRecordFromTrackId(trackId, record)\n\n\t\t\t\treturn true as const\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError(`增加播放记录失败：${uniqueKey}`, {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 根据 Bilibili 的元数据获取 track 。\n\t * @param bilibiliMeatadata\n\t * @returns\n\t */\n\tpublic getTrackByBilibiliMetadata(\n\t\tbilibiliMetadata: BilibiliMetadataPayload,\n\t): ResultAsync<Track, ServiceError | DatabaseError> {\n\t\tconst identifier = generateUniqueTrackKey({\n\t\t\tsource: 'bilibili',\n\t\t\tbilibiliMetadata: bilibiliMetadata,\n\t\t})\n\t\tif (identifier.isErr()) {\n\t\t\treturn errAsync(identifier.error)\n\t\t}\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:track', op: 'db' }, () =>\n\t\t\t\tthis.db.query.tracks.findFirst({\n\t\t\t\t\twhere: (track, { eq }) => eq(track.uniqueKey, identifier.value),\n\t\t\t\t\twith: {\n\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('根据 Bilibili 元数据查找 track 失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t).andThen((track) => {\n\t\t\tif (!track) {\n\t\t\t\treturn errAsync(createTrackNotFound(`uniqueKey=${identifier.value}`))\n\t\t\t}\n\n\t\t\tconst formattedTrack = this.formatTrack(track)\n\t\t\tif (!formattedTrack) {\n\t\t\t\treturn errAsync(\n\t\t\t\t\tcreateValidationError(\n\t\t\t\t\t\t`根据 Bilibili 元数据查找 track 失败：元数据不匹配。`,\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\n\t\t\treturn okAsync(formattedTrack)\n\t\t})\n\t}\n\n\t/**\n\t * 查找 track ，如果不存在则根据提供的 payload 创建一个新的。\n\t * 唯一性检查基于 generateUniqueTrackKey 生成的唯一标识符。\n\t * @param payload - 创建 track 所需的数据。\n\t * @returns ResultAsync\n\t */\n\tpublic findOrCreateTrack(\n\t\tpayload: CreateTrackPayload,\n\t): ResultAsync<Track, ServiceError | DatabaseError> {\n\t\tconst uniqueKeyResult = generateUniqueTrackKey(payload)\n\t\tif (uniqueKeyResult.isErr()) {\n\t\t\treturn errAsync(uniqueKeyResult.error)\n\t\t}\n\t\tconst uniqueKey = uniqueKeyResult.value\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:track', op: 'db' }, () =>\n\t\t\t\tthis.db.query.tracks.findFirst({\n\t\t\t\t\twhere: (track, { eq }) => eq(track.uniqueKey, uniqueKey),\n\t\t\t\t\twith: {\n\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('根据 uniqueKey 查找 track 失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t\t\t.andThen((dbTrack) => {\n\t\t\t\tif (dbTrack) {\n\t\t\t\t\tconst formattedTrack = this.formatTrack(dbTrack)\n\t\t\t\t\tif (formattedTrack) {\n\t\t\t\t\t\treturn okAsync(formattedTrack)\n\t\t\t\t\t}\n\t\t\t\t\treturn errAsync(\n\t\t\t\t\t\tcreateValidationError(\n\t\t\t\t\t\t\t`已存在的 track ${dbTrack.id} source 与 metadata 不匹配`,\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn errAsync(createTrackNotFound(uniqueKey))\n\t\t\t})\n\t\t\t.orElse((error) => {\n\t\t\t\tif (error instanceof ServiceError && error.type === 'TrackNotFound') {\n\t\t\t\t\treturn this._createTrack(payload)\n\t\t\t\t}\n\t\t\t\treturn errAsync(error)\n\t\t\t})\n\t}\n\n\t/**\n\t * 批量查找或创建 tracks，并处理其关联的元数据。\n\t *\n\t * @param payloads - 要创建或查找的 track 数据。\n\t * @param source - 所有 track 必须来自的同一个来源。\n\t * @returns 如果操作成功，其中包含一个从 uniqueKey -> track ID 的映射。\n\t */\n\tpublic findOrCreateManyTracks(\n\t\tpayloads: CreateTrackPayload[],\n\t\tsource: Track['source'],\n\t): ResultAsync<Map<string, number>, ServiceError | DatabaseError> {\n\t\tif (payloads.length === 0) {\n\t\t\treturn okAsync(new Map<string, number>())\n\t\t}\n\n\t\tconst processedPayloadsResult = Result.combine(\n\t\t\tpayloads.map((p) => {\n\t\t\t\tif (p.source !== source)\n\t\t\t\t\treturn err(createValidationError('source 不一致'))\n\t\t\t\treturn generateUniqueTrackKey(p).map((uniqueKey) => ({\n\t\t\t\t\tuniqueKey,\n\t\t\t\t\tpayload: p,\n\t\t\t\t}))\n\t\t\t}),\n\t\t)\n\n\t\tif (processedPayloadsResult.isErr()) {\n\t\t\treturn errAsync(processedPayloadsResult.error)\n\t\t}\n\n\t\t// Deduplicate payloads based on uniqueKey\n\t\tconst uniquePayloadsMap = new Map<\n\t\t\tstring,\n\t\t\t{ uniqueKey: string; payload: CreateTrackPayload }\n\t\t>()\n\t\tfor (const p of processedPayloadsResult.value) {\n\t\t\tif (!uniquePayloadsMap.has(p.uniqueKey)) {\n\t\t\t\tuniquePayloadsMap.set(p.uniqueKey, p)\n\t\t\t}\n\t\t}\n\t\tconst processedPayloads = Array.from(uniquePayloadsMap.values())\n\t\tconst uniqueKeys = processedPayloads.map((p) => p.uniqueKey)\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\t(async () => {\n\t\t\t\tconst trackValuesToInsert = processedPayloads.map(\n\t\t\t\t\t({ uniqueKey, payload }) =>\n\t\t\t\t\t\t({\n\t\t\t\t\t\t\ttitle: payload.title,\n\t\t\t\t\t\t\tartistId: payload.artistId,\n\t\t\t\t\t\t\tcoverUrl: payload.coverUrl,\n\t\t\t\t\t\t\tduration: payload.duration,\n\t\t\t\t\t\t\tuniqueKey: uniqueKey,\n\t\t\t\t\t\t\tsource: payload.source,\n\t\t\t\t\t\t}) satisfies CreateTrackPayloadBase & {\n\t\t\t\t\t\t\tuniqueKey: string\n\t\t\t\t\t\t\tsource: string\n\t\t\t\t\t\t},\n\t\t\t\t)\n\n\t\t\t\tif (trackValuesToInsert.length > 0) {\n\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t{ name: 'db:insert:many:tracks', op: 'db' },\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t\t.insert(schema.tracks)\n\t\t\t\t\t\t\t\t.values(trackValuesToInsert)\n\t\t\t\t\t\t\t\t.onConflictDoNothing(),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tconst allTracks = await Sentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:many:tracks', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db.query.tracks.findMany({\n\t\t\t\t\t\t\twhere: and(inArray(schema.tracks.uniqueKey, uniqueKeys)),\n\t\t\t\t\t\t\tcolumns: {\n\t\t\t\t\t\t\t\tid: true,\n\t\t\t\t\t\t\t\tuniqueKey: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}),\n\t\t\t\t)\n\n\t\t\t\tconst finalUniqueKeyToIdMap = new Map(\n\t\t\t\t\tallTracks.map((t) => [t.uniqueKey, t.id]),\n\t\t\t\t)\n\n\t\t\t\tif (finalUniqueKeyToIdMap.size !== uniqueKeys.length) {\n\t\t\t\t\tthrow new DatabaseError(\n\t\t\t\t\t\t'创建或查找 tracks 后数据不一致，部分 track 未能成功写入或查询。',\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tswitch (source) {\n\t\t\t\t\tcase 'bilibili': {\n\t\t\t\t\t\tconst bilibiliMetadataValues = processedPayloads.map(\n\t\t\t\t\t\t\t({ uniqueKey, payload }) => {\n\t\t\t\t\t\t\t\tconst trackId = finalUniqueKeyToIdMap.get(uniqueKey)\n\t\t\t\t\t\t\t\tif (!trackId) {\n\t\t\t\t\t\t\t\t\tthrow new ServiceError(\n\t\t\t\t\t\t\t\t\t\t`该错误不应该出现，无法为 ${uniqueKey} 找到 trackId`,\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\ttrackId,\n\t\t\t\t\t\t\t\t\t...(payload as CreateBilibiliTrackPayload).bilibiliMetadata,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\n\t\t\t\t\t\tif (bilibiliMetadataValues.length > 0) {\n\t\t\t\t\t\t\tawait Sentry.startSpan(\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tname: 'db:insert:many:bilibiliMetadata',\n\t\t\t\t\t\t\t\t\top: 'db',\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t\t\t\t.insert(schema.bilibiliMetadata)\n\t\t\t\t\t\t\t\t\t\t.values(bilibiliMetadataValues)\n\t\t\t\t\t\t\t\t\t\t.onConflictDoNothing(),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tcase 'local': {\n\t\t\t\t\t\tthrow createNotImplementedError('处理 local source 的逻辑尚未实现')\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst orderedMap = new Map<string, number>()\n\n\t\t\t\tfor (const uniqueKey of uniqueKeys) {\n\t\t\t\t\t// 前面做过一致性检查了，这里不可能不存在\n\t\t\t\t\torderedMap.set(uniqueKey, finalUniqueKeyToIdMap.get(uniqueKey)!)\n\t\t\t\t}\n\n\t\t\t\treturn orderedMap\n\t\t\t})(),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('批量查找或创建 tracks 失败', {\n\t\t\t\t\t\t\tcause: e,\n\t\t\t\t\t\t}),\n\t\t)\n\t}\n\n\t/**\n\t * 根据 uniqueKey 批量查找 track 的 ID。\n\t * @param uniqueKeys\n\t * @returns 如果成功，即为找到的 track 的 uniqueKey -> id 映射\n\t */\n\tpublic findTrackIdsByUniqueKeys(\n\t\tuniqueKeys: string[],\n\t): ResultAsync<Map<string, number>, DatabaseError> {\n\t\tif (uniqueKeys.length === 0) {\n\t\t\treturn okAsync(new Map<string, number>())\n\t\t}\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:many:tracks', op: 'db' }, () =>\n\t\t\t\tthis.db.query.tracks.findMany({\n\t\t\t\t\twhere: and(inArray(schema.tracks.uniqueKey, uniqueKeys)),\n\t\t\t\t\tcolumns: {\n\t\t\t\t\t\tid: true,\n\t\t\t\t\t\tuniqueKey: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('批量查找 tracks 失败', { cause: e }),\n\t\t).andThen((existingTracks) => {\n\t\t\tconst uniqueKeyToIdMap = new Map<string, number>()\n\t\t\tfor (const track of existingTracks) {\n\t\t\t\tuniqueKeyToIdMap.set(track.uniqueKey, track.id)\n\t\t\t}\n\t\t\treturn okAsync(uniqueKeyToIdMap)\n\t\t})\n\t}\n\n\t/**\n\t * 获取播放次数排行榜（游标分页）。\n\t *\n\t * @param {object} [options] 配置项\n\t * @param {number} [options.limit] 每页返回的数量。\n\t * @param {boolean} [options.onlyCompleted=true] 是否只统计完整播放。\n\t * @param {number} [options.initialLimit] 如果是第一页，使用的数量限制（如无则为 limit）\n\t * @param {object} [options.cursor] 上一页的游标（来自上一页的 `nextCursor`）。\n\t * @param {number} [options.cursor.lastPlayCount] 上一页最后一个项目的播放量。\n\t * @param {number} [options.cursor.lastUpdatedAt] 上一页最后一个项目的更新时间戳。\n\t * @param {number} [options.cursor.lastId] 上一页最后一个项目的 ID。\n\t * @returns 播放次数排行榜及下一页游标的异步结果。\n\t */\n\tpublic getPlayCountHistoryPaginated(options: {\n\t\tlimit: number\n\t\tinitialLimit?: number\n\t\tonlyCompleted?: boolean\n\t\tcursor?: { lastPlayCount: number; lastUpdatedAt: number; lastId: number }\n\t}): ResultAsync<\n\t\t{\n\t\t\titems: { track: Track; playCount: number }[]\n\t\t\tnextCursor?: {\n\t\t\t\tlastPlayCount: number\n\t\t\t\tlastUpdatedAt: number\n\t\t\t\tlastId: number\n\t\t\t}\n\t\t},\n\t\tDatabaseError | ServiceError\n\t> {\n\t\tconst { limit, onlyCompleted = true, cursor, initialLimit } = options\n\n\t\tconst effectiveLimit = cursor ? limit : (initialLimit ?? limit)\n\n\t\tconst playCountSql = this.db\n\t\t\t.select({\n\t\t\t\ttrackId: schema.playHistory.trackId,\n\t\t\t\tcount: count().as('count'),\n\t\t\t})\n\t\t\t.from(schema.playHistory)\n\t\t\t.where(onlyCompleted ? eq(schema.playHistory.completed, true) : undefined)\n\t\t\t.groupBy(schema.playHistory.trackId)\n\t\t\t.as('play_counts')\n\n\t\tconst whereConditions: (SQL | undefined)[] = []\n\n\t\tif (cursor) {\n\t\t\tconst cursorUpdatedAt = new Date(cursor.lastUpdatedAt)\n\t\t\twhereConditions.push(\n\t\t\t\tor(\n\t\t\t\t\tlt(playCountSql.count, cursor.lastPlayCount),\n\t\t\t\t\tand(\n\t\t\t\t\t\teq(playCountSql.count, cursor.lastPlayCount),\n\t\t\t\t\t\tor(\n\t\t\t\t\t\t\tlt(schema.tracks.updatedAt, cursorUpdatedAt),\n\t\t\t\t\t\t\tand(\n\t\t\t\t\t\t\t\teq(schema.tracks.updatedAt, cursorUpdatedAt),\n\t\t\t\t\t\t\t\tlt(schema.tracks.id, cursor.lastId),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\tconst historyQuery = Sentry.startSpan(\n\t\t\t{ name: 'db:query:playHistory', op: 'db' },\n\t\t\t() =>\n\t\t\t\tthis.db\n\t\t\t\t\t.select({\n\t\t\t\t\t\ttrack: schema.tracks,\n\t\t\t\t\t\tartist: schema.artists,\n\t\t\t\t\t\tbilibiliMetadata: schema.bilibiliMetadata,\n\t\t\t\t\t\tlocalMetadata: schema.localMetadata,\n\t\t\t\t\t\tplayCount: playCountSql.count,\n\t\t\t\t\t})\n\t\t\t\t\t.from(schema.tracks)\n\t\t\t\t\t.innerJoin(playCountSql, eq(schema.tracks.id, playCountSql.trackId))\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.artists,\n\t\t\t\t\t\teq(schema.tracks.artistId, schema.artists.id),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.bilibiliMetadata,\n\t\t\t\t\t\teq(schema.tracks.id, schema.bilibiliMetadata.trackId),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.localMetadata,\n\t\t\t\t\t\teq(schema.tracks.id, schema.localMetadata.trackId),\n\t\t\t\t\t)\n\t\t\t\t\t.where(and(...whereConditions))\n\t\t\t\t\t.orderBy(\n\t\t\t\t\t\tdesc(playCountSql.count),\n\t\t\t\t\t\tdesc(schema.tracks.updatedAt),\n\t\t\t\t\t\tdesc(schema.tracks.id),\n\t\t\t\t\t)\n\t\t\t\t\t.limit(effectiveLimit + 1),\n\t\t)\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\thistoryQuery,\n\t\t\t(e) => new DatabaseError('获取播放次数排行失败', { cause: e }),\n\t\t).andThen((rows) => {\n\t\t\tconst hasNextPage = rows.length > effectiveLimit\n\t\t\tconst resultItems = hasNextPage ? rows.slice(0, effectiveLimit) : rows\n\n\t\t\tconst items: { track: Track; playCount: number }[] = []\n\t\t\tfor (const row of resultItems) {\n\t\t\t\tconst track = this.formatTrack({\n\t\t\t\t\t...row.track,\n\t\t\t\t\tartist: row.artist,\n\t\t\t\t\tbilibiliMetadata: row.bilibiliMetadata,\n\t\t\t\t\tlocalMetadata: row.localMetadata,\n\t\t\t\t})\n\t\t\t\tif (!track) continue\n\t\t\t\titems.push({ track, playCount: row.playCount ?? 0 })\n\t\t\t}\n\n\t\t\tlet nextCursor\n\t\t\tif (hasNextPage) {\n\t\t\t\tconst lastRow = resultItems[resultItems.length - 1]\n\t\t\t\tif (lastRow) {\n\t\t\t\t\tnextCursor = {\n\t\t\t\t\t\tlastPlayCount: lastRow.playCount ?? 0,\n\t\t\t\t\t\tlastUpdatedAt: lastRow.track.updatedAt.getTime(),\n\t\t\t\t\t\tlastId: lastRow.track.id,\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn okAsync({\n\t\t\t\titems: items,\n\t\t\t\tnextCursor,\n\t\t\t})\n\t\t})\n\t}\n\n\t/**\n\t * 获取所有歌曲的总播放时长。\n\t * - 当 `onlyCompleted` 为 `true` (默认) 时, 计算方法为 `duration * playCount` (仅统计完整播放)。\n\t * - 当 `onlyCompleted` 为 `false` 时, 计算方法为每条播放记录中 `durationPlayed` 的总和。\n\t * @param options.onlyCompleted 是否仅统计完整播放（completed=true），默认 true\n\t * @returns ResultAsync 包含总播放时长（秒）或一个错误。\n\t */\n\tpublic getTotalPlaybackDuration(options?: {\n\t\tonlyCompleted?: boolean\n\t}): ResultAsync<number, DatabaseError> {\n\t\tconst onlyCompleted = options?.onlyCompleted ?? true\n\n\t\tif (onlyCompleted) {\n\t\t\tconst playCountSql = this.db\n\t\t\t\t.select({\n\t\t\t\t\ttrackId: schema.playHistory.trackId,\n\t\t\t\t\tcount: count().as('count'),\n\t\t\t\t})\n\t\t\t\t.from(schema.playHistory)\n\t\t\t\t.where(eq(schema.playHistory.completed, true))\n\t\t\t\t.groupBy(schema.playHistory.trackId)\n\t\t\t\t.as('play_counts')\n\n\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\tSentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:totalPlaybackDuration:completed', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.select({\n\t\t\t\t\t\t\t\ttotalDuration:\n\t\t\t\t\t\t\t\t\tsql<number>`sum(${schema.tracks.duration} * ${playCountSql.count})`.mapWith(\n\t\t\t\t\t\t\t\t\t\tNumber,\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.from(schema.tracks)\n\t\t\t\t\t\t\t.innerJoin(\n\t\t\t\t\t\t\t\tplayCountSql,\n\t\t\t\t\t\t\t\teq(schema.tracks.id, playCountSql.trackId),\n\t\t\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t\t(e) => new DatabaseError('获取总播放时长失败', { cause: e }),\n\t\t\t).andThen((rows) => {\n\t\t\t\tconst totalDuration = rows[0]?.totalDuration\n\t\t\t\treturn okAsync(totalDuration ?? 0)\n\t\t\t})\n\t\t} else {\n\t\t\treturn ResultAsync.fromPromise(\n\t\t\t\tSentry.startSpan(\n\t\t\t\t\t{ name: 'db:query:totalPlaybackDuration:all', op: 'db' },\n\t\t\t\t\t() =>\n\t\t\t\t\t\tthis.db\n\t\t\t\t\t\t\t.select({\n\t\t\t\t\t\t\t\ttotalDuration: sum(schema.playHistory.durationPlayed).mapWith(\n\t\t\t\t\t\t\t\t\tNumber,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.from(schema.playHistory),\n\t\t\t\t),\n\t\t\t\t(e) => new DatabaseError('获取总播放时长失败', { cause: e }),\n\t\t\t).andThen((rows) => {\n\t\t\t\tconst totalDuration = rows[0]?.totalDuration\n\t\t\t\treturn okAsync(totalDuration ?? 0)\n\t\t\t})\n\t\t}\n\t}\n\n\tpublic getTrackByUniqueKey(\n\t\tuniqueKey: string,\n\t): ResultAsync<Track, ServiceError | DatabaseError> {\n\t\treturn ResultAsync.fromPromise(\n\t\t\tSentry.startSpan({ name: 'db:query:track', op: 'db' }, () =>\n\t\t\t\tthis.db.query.tracks.findFirst({\n\t\t\t\t\twhere: eq(schema.tracks.uniqueKey, uniqueKey),\n\t\t\t\t\twith: {\n\t\t\t\t\t\tartist: true,\n\t\t\t\t\t\tbilibiliMetadata: true,\n\t\t\t\t\t\tlocalMetadata: true,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t),\n\t\t\t(e) =>\n\t\t\t\te instanceof ServiceError\n\t\t\t\t\t? e\n\t\t\t\t\t: new DatabaseError('查找 track 失败', { cause: e }),\n\t\t).andThen((dbTrack) => {\n\t\t\tconst formattedTrack = this.formatTrack(dbTrack)\n\t\t\tif (!formattedTrack) {\n\t\t\t\treturn errAsync(createTrackNotFound(uniqueKey))\n\t\t\t}\n\t\t\treturn okAsync(formattedTrack)\n\t\t})\n\t}\n\n\t/**\n\t * 获取最近 N 天内播放时长最多的歌曲。\n\t *\n\t * @param {object} options 配置项\n\t * @param {number} options.days 最近的天数\n\t * @param {number} options.limit 返回的最大数量\n\t * @returns 播放时长排行及总播放时长的异步结果。\n\t */\n\tpublic getMostPlayedTracksInLastDays(options: {\n\t\tdays: number\n\t\tlimit: number\n\t}): ResultAsync<\n\t\tArray<{ track: Track; totalDuration: number }>,\n\t\tDatabaseError\n\t> {\n\t\tconst { days, limit } = options\n\n\t\t// Calculate cutoff timestamp in seconds\n\t\tconst cutoffTimeS = Math.floor(\n\t\t\t(Date.now() - days * 24 * 60 * 60 * 1000) / 1000,\n\t\t)\n\n\t\tconst normalizedStartTime = schema.playHistory.startTime\n\n\t\t// Subquery: aggregate total duration played per track\n\t\tconst durationSumSql = this.db\n\t\t\t.select({\n\t\t\t\ttrackId: schema.playHistory.trackId,\n\t\t\t\ttotalDuration: sum(schema.playHistory.durationPlayed).as(\n\t\t\t\t\t'total_duration',\n\t\t\t\t),\n\t\t\t})\n\t\t\t.from(schema.playHistory)\n\t\t\t.where(sql`${normalizedStartTime} >= ${cutoffTimeS}`)\n\t\t\t.groupBy(schema.playHistory.trackId)\n\t\t\t.as('duration_sums')\n\n\t\tconst historyQuery = Sentry.startSpan(\n\t\t\t{ name: 'db:query:mostPlayedTracksByDuration', op: 'db' },\n\t\t\t() =>\n\t\t\t\tthis.db\n\t\t\t\t\t.select({\n\t\t\t\t\t\ttrack: schema.tracks,\n\t\t\t\t\t\tartist: schema.artists,\n\t\t\t\t\t\tbilibiliMetadata: schema.bilibiliMetadata,\n\t\t\t\t\t\tlocalMetadata: schema.localMetadata,\n\t\t\t\t\t\ttotalDuration: durationSumSql.totalDuration,\n\t\t\t\t\t})\n\t\t\t\t\t.from(schema.tracks)\n\t\t\t\t\t.innerJoin(\n\t\t\t\t\t\tdurationSumSql,\n\t\t\t\t\t\teq(schema.tracks.id, durationSumSql.trackId),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.artists,\n\t\t\t\t\t\teq(schema.tracks.artistId, schema.artists.id),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.bilibiliMetadata,\n\t\t\t\t\t\teq(schema.tracks.id, schema.bilibiliMetadata.trackId),\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tschema.localMetadata,\n\t\t\t\t\t\teq(schema.tracks.id, schema.localMetadata.trackId),\n\t\t\t\t\t)\n\t\t\t\t\t.orderBy(desc(durationSumSql.totalDuration))\n\t\t\t\t\t.limit(limit),\n\t\t)\n\n\t\treturn ResultAsync.fromPromise(\n\t\t\thistoryQuery,\n\t\t\t(e) => new DatabaseError('获取最近播放时长排行失败', { cause: e }),\n\t\t).andThen((rows) => {\n\t\t\tconst results: Array<{ track: Track; totalDuration: number }> = []\n\t\t\tfor (const row of rows) {\n\t\t\t\tconst track = this.formatTrack({\n\t\t\t\t\t...row.track,\n\t\t\t\t\tartist: row.artist,\n\t\t\t\t\tbilibiliMetadata: row.bilibiliMetadata,\n\t\t\t\t\tlocalMetadata: row.localMetadata,\n\t\t\t\t})\n\t\t\t\tif (!track) continue\n\t\t\t\tresults.push({\n\t\t\t\t\ttrack,\n\t\t\t\t\ttotalDuration: Number(row.totalDuration ?? 0),\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn okAsync(results)\n\t\t})\n\t}\n}\n\nexport const trackService = new TrackService(db)\n"
  },
  {
    "path": "apps/mobile/src/lib/services/updateService.ts",
    "content": "import * as Sentry from '@sentry/react-native'\nimport * as Application from 'expo-application'\nimport Constants from 'expo-constants'\nimport { err, ok, type Result } from 'neverthrow'\n\nexport interface ReleaseInfo {\n\tversion: string\n\tnotes: string\n\tlisted_notes?: string[]\n\turl: string\n\tdownloads?: UpdateDownloads\n\tforced: boolean\n}\n\nexport interface UpdateDownloads {\n\tandroid?: Record<string, string>\n}\n\nexport interface UpdateManifest {\n\tversion: string\n\tnotes?: string\n\tlisted_notes?: string[]\n\turl: string\n\tdownloads?: UpdateDownloads\n\tforced?: boolean\n}\n\nconst getManifestUrl = (): string | undefined => {\n\tconst extra = Constants?.expoConfig?.extra as\n\t\t| { updateManifestUrl?: string }\n\t\t| undefined\n\treturn extra?.updateManifestUrl\n}\n\nconst toError = (e: unknown): Error =>\n\te instanceof Error ? e : new Error(String(e))\n\nconst normalizeVersion = (v?: string | null): string => {\n\tif (!v) return '0.0.0'\n\treturn v.startsWith('v') ? v.slice(1) : v\n}\n\nexport const compareSemver = (a: string, b: string): number => {\n\tconst pa = normalizeVersion(a)\n\t\t.split('.')\n\t\t.map((n) => parseInt(n, 10) || 0)\n\tconst pb = normalizeVersion(b)\n\t\t.split('.')\n\t\t.map((n) => parseInt(n, 10) || 0)\n\tfor (let i = 0; i < Math.max(pa.length, pb.length); i++) {\n\t\tconst ai = pa[i] ?? 0\n\t\tconst bi = pb[i] ?? 0\n\t\tif (ai > bi) return 1\n\t\tif (ai < bi) return -1\n\t}\n\treturn 0\n}\n\nexport async function fetchLatestRelease(): Promise<\n\tResult<ReleaseInfo, Error>\n> {\n\ttry {\n\t\tconst manifestUrl = getManifestUrl()\n\t\tif (!manifestUrl) {\n\t\t\treturn err(new Error('未在 app.config 中配置更新渠道 updateManifestUrl'))\n\t\t}\n\t\tconst res = await Sentry.startSpan(\n\t\t\t{ name: 'http:fetch:update-manifest', op: 'http' },\n\t\t\t() => fetch(manifestUrl, { cache: 'no-store' }),\n\t\t)\n\t\tif (!res.ok) {\n\t\t\treturn err(new Error(`拉取更新信息: ${res.status} ${res.statusText}`))\n\t\t}\n\t\tconst json: unknown = await res.json()\n\t\tif (typeof json !== 'object' || json === null) {\n\t\t\treturn err(new Error('更新信息格式错误'))\n\t\t}\n\t\tconst obj = json as Record<string, unknown>\n\t\tconst version = obj.version\n\t\tconst url = obj.url\n\t\tif (typeof version !== 'string' || typeof url !== 'string') {\n\t\t\treturn err(new Error('更新信息格式错误'))\n\t\t}\n\t\tconst notes = typeof obj.notes === 'string' ? obj.notes : ''\n\t\tconst listed_notes =\n\t\t\tArray.isArray(obj.listed_notes) &&\n\t\t\tobj.listed_notes.every((i) => typeof i === 'string')\n\t\t\t\t? obj.listed_notes\n\t\t\t\t: undefined\n\t\tconst forced = typeof obj.forced === 'boolean' ? obj.forced : false\n\t\tconst downloads =\n\t\t\ttypeof obj.downloads === 'object' && obj.downloads !== null\n\t\t\t\t? parseDownloads(obj.downloads)\n\t\t\t\t: undefined\n\t\tconst releaseInfo = {\n\t\t\tversion: normalizeVersion(version),\n\t\t\turl,\n\t\t\tnotes,\n\t\t\tlisted_notes,\n\t\t\tdownloads,\n\t\t\tforced,\n\t\t}\n\t\treturn ok(releaseInfo)\n\t} catch (e) {\n\t\treturn err(toError(e))\n\t}\n}\n\n/**\n * 检查是否有新版本\n * @returns 如果没有新的版本，返回的 update 为 null\n */\nexport async function checkForAppUpdate(): Promise<\n\tResult<{ update: ReleaseInfo | null; currentVersion: string }, Error>\n> {\n\tconst currentVersion = normalizeVersion(\n\t\tApplication.nativeApplicationVersion ?? '0.0.0',\n\t)\n\tconst latest = await fetchLatestRelease()\n\tif (latest.isErr()) return err(latest.error)\n\tconst info = latest.value\n\tif (compareSemver(info.version, currentVersion) <= 0) {\n\t\treturn ok({ update: null, currentVersion })\n\t}\n\treturn ok({ update: info, currentVersion })\n}\n\nconst parseDownloads = (value: object): UpdateDownloads | undefined => {\n\tconst downloads = value as Record<string, unknown>\n\tconst android = parseStringRecord(downloads.android)\n\tif (!android) return undefined\n\treturn { android }\n}\n\nconst parseStringRecord = (\n\tvalue: unknown,\n): Record<string, string> | undefined => {\n\tif (typeof value !== 'object' || value === null || Array.isArray(value)) {\n\t\treturn undefined\n\t}\n\n\tconst entries = Object.entries(value)\n\t\t.filter(([, v]) => typeof v === 'string')\n\t\t.map(([k, v]) => [k, v] as const)\n\n\tif (entries.length === 0) return undefined\n\treturn Object.fromEntries(entries)\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/theme/material3Colors.ts",
    "content": "import { Color } from 'expo-router'\nimport type { ColorSchemeName } from 'react-native'\nimport { MD3DarkTheme, MD3LightTheme } from 'react-native-paper'\nimport type { MD3Colors } from 'react-native-paper/lib/typescript/types'\n\n/**\n * Build a React Native Paper MD3Colors object from Expo Router's dynamic Material 3 colors.\n */\nexport function buildMaterial3PaperColors(\n\tcolorScheme: ColorSchemeName,\n): MD3Colors {\n\tconst d = Color.android.dynamic\n\tconst fallback =\n\t\tcolorScheme === 'dark' ? MD3DarkTheme.colors : MD3LightTheme.colors\n\n\treturn {\n\t\tprimary: (d.primary as string) ?? fallback.primary,\n\t\tprimaryContainer:\n\t\t\t(d.primaryContainer as string) ?? fallback.primaryContainer,\n\t\tsecondary: (d.secondary as string) ?? fallback.secondary,\n\t\tsecondaryContainer:\n\t\t\t(d.secondaryContainer as string) ?? fallback.secondaryContainer,\n\t\ttertiary: (d.tertiary as string) ?? fallback.tertiary,\n\t\ttertiaryContainer:\n\t\t\t(d.tertiaryContainer as string) ?? fallback.tertiaryContainer,\n\t\tsurface: (d.surface as string) ?? fallback.surface,\n\t\tsurfaceVariant: (d.surfaceVariant as string) ?? fallback.surfaceVariant,\n\t\tbackground: (d.background as string) ?? fallback.background,\n\t\terror: (d.error as string) ?? fallback.error,\n\t\terrorContainer: (d.errorContainer as string) ?? fallback.errorContainer,\n\t\tonPrimary: (d.onPrimary as string) ?? fallback.onPrimary,\n\t\tonPrimaryContainer:\n\t\t\t(d.onPrimaryContainer as string) ?? fallback.onPrimaryContainer,\n\t\tonSecondary: (d.onSecondary as string) ?? fallback.onSecondary,\n\t\tonSecondaryContainer:\n\t\t\t(d.onSecondaryContainer as string) ?? fallback.onSecondaryContainer,\n\t\tonTertiary: (d.onTertiary as string) ?? fallback.onTertiary,\n\t\tonTertiaryContainer:\n\t\t\t(d.onTertiaryContainer as string) ?? fallback.onTertiaryContainer,\n\t\tonSurface: (d.onSurface as string) ?? fallback.onSurface,\n\t\tonSurfaceVariant:\n\t\t\t(d.onSurfaceVariant as string) ?? fallback.onSurfaceVariant,\n\t\tonError: (d.onError as string) ?? fallback.onError,\n\t\tonErrorContainer:\n\t\t\t(d.onErrorContainer as string) ?? fallback.onErrorContainer,\n\t\tonBackground: (d.onBackground as string) ?? fallback.onBackground,\n\t\toutline: (d.outline as string) ?? fallback.outline,\n\t\toutlineVariant: (d.outlineVariant as string) ?? fallback.outlineVariant,\n\t\t// Renamed in Expo Router: surfaceInverse → inverseSurface\n\t\tinverseSurface: (d.surfaceInverse as string) ?? fallback.inverseSurface,\n\t\tinverseOnSurface:\n\t\t\t(d.onSurfaceInverse as string) ?? fallback.inverseOnSurface,\n\t\tinversePrimary: (d.primaryInverse as string) ?? fallback.inversePrimary,\n\t\tshadow: fallback.shadow,\n\t\tscrim: fallback.scrim,\n\t\t// Not available from Expo Router dynamic colors — use Paper defaults\n\t\tsurfaceDisabled: fallback.surfaceDisabled,\n\t\tonSurfaceDisabled: fallback.onSurfaceDisabled,\n\t\tbackdrop: fallback.backdrop,\n\t\televation: fallback.elevation,\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/utils/playlistUrlParser.ts",
    "content": "export const parseExternalPlaylistInfo = (\n\ttext: string,\n): { id: string; source: 'netease' | 'qq' } | null => {\n\t// Netease Music\n\tif (text.includes('music.163.com')) {\n\t\tconst result = /id=(\\d+)/.exec(text)\n\t\tif (result?.[1]) {\n\t\t\treturn { id: result[1], source: 'netease' }\n\t\t}\n\t}\n\n\t// QQ Music\n\tif (text.includes('.qq.com')) {\n\t\tconst result = /id=(\\d+)/.exec(text)\n\t\tif (result?.[1]) {\n\t\t\treturn { id: result[1], source: 'qq' }\n\t\t}\n\t}\n\n\treturn null\n}\n"
  },
  {
    "path": "apps/mobile/src/lib/workers/PlaylistSyncWorker.ts",
    "content": "import { and, asc, eq, inArray } from 'drizzle-orm'\n\nimport { api as bbplayerApi } from '@/lib/api/bbplayer/client'\nimport db from '@/lib/db/db'\nimport * as schema from '@/lib/db/schema'\nimport { playlistService } from '@/lib/services/playlistService'\nimport log from '@/utils/log'\n\nconst logger = log.extend('PlaylistSyncWorker')\n\ntype QueueRow = typeof schema.playlistSyncQueue.$inferSelect\n\ntype TrackMeta = {\n\ttrackId: number\n\tuniqueKey: string\n\ttitle: string\n\tartistName?: string | null\n\tartistId?: string | null\n\tcoverUrl?: string | null\n\tduration?: number | null\n\tbvid?: string | null\n\tcid?: number | null\n\tsortKey?: string | null\n}\n\n/**\n * 单例队列消费器：将 playlist_sync_queue 中的记录批量推送到后端。\n */\nclass PlaylistSyncWorker {\n\tprivate isRunning = false\n\tprivate runAgain = false\n\n\ttriggerSync() {\n\t\tvoid this.syncAllPlaylists()\n\t}\n\n\t/**\n\t * 应用启动时调用：将上次被意外中断（状态为 syncing 或 pending 但未被消费）的记录\n\t * 重置为 pending，然后触发同步。\n\t * - syncing：进程被杀死时正在上传，需要重置\n\t * - pending：进程被杀死时还没轮到，triggerSync 会正常消费，无需额外处理\n\t */\n\tasync recoverStuckRows(): Promise<void> {\n\t\ttry {\n\t\t\t// 仅需处理 syncing，pending 本来就可以被 triggerSync 消费\n\t\t\tconst stuck = await db\n\t\t\t\t.select({ id: schema.playlistSyncQueue.id })\n\t\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t\t.where(eq(schema.playlistSyncQueue.status, 'syncing'))\n\t\t\tif (stuck.length > 0) {\n\t\t\t\tawait db\n\t\t\t\t\t.update(schema.playlistSyncQueue)\n\t\t\t\t\t.set({ status: 'pending' })\n\t\t\t\t\t.where(eq(schema.playlistSyncQueue.status, 'syncing'))\n\t\t\t\tlogger.info(\n\t\t\t\t\t`恢复了 ${stuck.length} 条中断的同步记录（syncing → pending）`,\n\t\t\t\t)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.error('recoverStuckRows 失败', { error })\n\t\t}\n\t\t// 无论是否有 syncing 记录，都触发一次以消费所有 pending 行\n\t\tthis.triggerSync()\n\t}\n\n\tprivate async syncAllPlaylists(): Promise<void> {\n\t\tif (this.isRunning) {\n\t\t\tthis.runAgain = true\n\t\t\treturn\n\t\t}\n\n\t\tthis.isRunning = true\n\t\ttry {\n\t\t\tdo {\n\t\t\t\tthis.runAgain = false\n\t\t\t\tconst playlistRows = await db\n\t\t\t\t\t.select({ playlistId: schema.playlistSyncQueue.playlistId })\n\t\t\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t\t\t.where(eq(schema.playlistSyncQueue.status, 'pending'))\n\t\t\t\t\t.groupBy(schema.playlistSyncQueue.playlistId)\n\n\t\t\t\tfor (const row of playlistRows) {\n\t\t\t\t\tawait this.syncSinglePlaylist(row.playlistId)\n\t\t\t\t}\n\n\t\t\t\t// 每轮处理完后清理已完成的记录，避免表无限膨胀\n\t\t\t\tawait db\n\t\t\t\t\t.delete(schema.playlistSyncQueue)\n\t\t\t\t\t.where(eq(schema.playlistSyncQueue.status, 'done'))\n\t\t\t} while (this.runAgain)\n\t\t} finally {\n\t\t\tthis.isRunning = false\n\t\t}\n\t}\n\n\tprivate async syncSinglePlaylist(playlistId: number): Promise<void> {\n\t\t// 读取待处理队列\n\t\tconst queueRows = await db\n\t\t\t.select()\n\t\t\t.from(schema.playlistSyncQueue)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(schema.playlistSyncQueue.playlistId, playlistId),\n\t\t\t\t\teq(schema.playlistSyncQueue.status, 'pending'),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.orderBy(\n\t\t\t\tasc(schema.playlistSyncQueue.operationAt),\n\t\t\t\tasc(schema.playlistSyncQueue.id),\n\t\t\t)\n\n\t\tif (queueRows.length === 0) return\n\n\t\tconst playlistRes = await playlistService.getPlaylistById(playlistId)\n\t\tif (playlistRes.isErr()) {\n\t\t\t// 数据库查询异常（非歌单不存在），保留队列行等待下次重试\n\t\t\tlogger.error('syncSinglePlaylist: 读取歌单失败', {\n\t\t\t\tplaylistId,\n\t\t\t\terror: playlistRes.error,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tconst playlist = playlistRes.value\n\t\tif (!playlist?.shareId || !playlist.shareRole) {\n\t\t\t// 歌单不存在或未开启分享，永久无效，直接清理\n\t\t\tawait this.deleteRows(queueRows.map((r) => r.id))\n\t\t\treturn\n\t\t}\n\t\tif (playlist.shareRole === 'subscriber') {\n\t\t\t// 订阅者无写权限，永久无效，直接清理\n\t\t\tawait this.deleteRows(queueRows.map((r) => r.id))\n\t\t\treturn\n\t\t}\n\n\t\tconst metadataOps = queueRows.filter(\n\t\t\t(r) => r.operation === 'update_metadata',\n\t\t)\n\t\tconst trackOps = queueRows.filter((r) => r.operation !== 'update_metadata')\n\n\t\tif (trackOps.length > 0) {\n\t\t\tawait this.pushTrackChanges(playlist.shareId, playlistId, trackOps)\n\t\t}\n\n\t\tif (metadataOps.length > 0) {\n\t\t\tif (playlist.shareRole !== 'owner') {\n\t\t\t\t// 非 owner 无法修改元数据，永久无效，直接清理\n\t\t\t\tawait this.deleteRows(metadataOps.map((r) => r.id))\n\t\t\t} else {\n\t\t\t\tawait this.pushMetadataChanges(playlist.shareId, metadataOps)\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pushTrackChanges(\n\t\tshareId: string,\n\t\tplaylistId: number,\n\t\trows: QueueRow[],\n\t): Promise<void> {\n\t\tconst trackIds = this.collectTrackIds(rows)\n\n\t\tif (trackIds.size === 0) {\n\t\t\t// payload 损坏，无法解析出任何 trackId，永久无效，直接清理\n\t\t\tawait this.deleteRows(rows.map((r) => r.id))\n\t\t\treturn\n\t\t}\n\n\t\tconst metaMap = await this.fetchTrackMetadata(playlistId, [...trackIds])\n\n\t\tconst { changes, validRowIds, invalidRowIds } = this.mapTrackChangesToApi(\n\t\t\trows,\n\t\t\tmetaMap,\n\t\t)\n\n\t\tif (invalidRowIds.length > 0) {\n\t\t\t// payload 损坏或对应 track 已被删除，永久无效，直接清理\n\t\t\tawait this.deleteRows(invalidRowIds)\n\t\t}\n\n\t\tif (changes.length === 0) return\n\n\t\t// operation_at 升序，确保与服务器 LWW 对齐\n\t\tchanges.sort((a, b) => a.operation_at - b.operation_at)\n\n\t\t// 发起请求前先标记为 syncing，避免重启后重复提交\n\t\tif (validRowIds.size > 0) {\n\t\t\tawait this.markRows([...validRowIds], 'syncing')\n\t\t}\n\n\t\ttry {\n\t\t\tconst resp = await bbplayerApi.playlists[':id'].changes.$post({\n\t\t\t\tparam: { id: shareId },\n\t\t\t\tjson: { changes },\n\t\t\t})\n\t\t\tif (!resp.ok) {\n\t\t\t\tconst body = await resp.json().catch(() => ({}))\n\t\t\t\tthrow new Error(`API ${resp.status}` + (JSON.stringify(body) ?? ''))\n\t\t\t}\n\t\t\tconst data = (await resp.json()) as { applied_at?: number }\n\t\t\tawait db.transaction(async (tx) => {\n\t\t\t\tif (validRowIds.size > 0) {\n\t\t\t\t\tawait tx\n\t\t\t\t\t\t.update(schema.playlistSyncQueue)\n\t\t\t\t\t\t.set({ status: 'done' })\n\t\t\t\t\t\t.where(inArray(schema.playlistSyncQueue.id, [...validRowIds]))\n\t\t\t\t}\n\n\t\t\t\tif (typeof data.applied_at === 'number') {\n\t\t\t\t\tawait tx\n\t\t\t\t\t\t.update(schema.playlists)\n\t\t\t\t\t\t.set({ lastShareSyncAt: new Date(data.applied_at) })\n\t\t\t\t\t\t.where(eq(schema.playlists.id, playlistId))\n\t\t\t\t}\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tlogger.error('pushTrackChanges 失败', {\n\t\t\t\tplaylistId,\n\t\t\t\terror,\n\t\t\t})\n\t\t\tawait this.markRows([...validRowIds], 'failed')\n\t\t}\n\t}\n\n\tprivate collectTrackIds(rows: QueueRow[]): Set<number> {\n\t\tconst trackIds = new Set<number>()\n\t\tfor (const row of rows) {\n\t\t\tconst payload = this.parsePayload(row.payload)\n\t\t\tif (row.operation === 'add_tracks') {\n\t\t\t\t;(payload.trackIds as number[] | undefined)?.forEach((id) =>\n\t\t\t\t\ttrackIds.add(id),\n\t\t\t\t)\n\t\t\t} else if (row.operation === 'remove_tracks') {\n\t\t\t\t;(payload.removedTrackIds as number[] | undefined)?.forEach((id) =>\n\t\t\t\t\ttrackIds.add(id),\n\t\t\t\t)\n\t\t\t} else if (row.operation === 'reorder_track') {\n\t\t\t\tif (typeof payload.trackId === 'number') trackIds.add(payload.trackId)\n\t\t\t}\n\t\t}\n\t\treturn trackIds\n\t}\n\n\tprivate mapTrackChangesToApi(\n\t\trows: QueueRow[],\n\t\tmetaMap: Map<number, TrackMeta>,\n\t) {\n\t\ttype SyncChange =\n\t\t\t| {\n\t\t\t\t\top: 'upsert'\n\t\t\t\t\ttrack: {\n\t\t\t\t\t\tunique_key: string\n\t\t\t\t\t\ttitle: string\n\t\t\t\t\t\tartist_name?: string\n\t\t\t\t\t\tartist_id?: string\n\t\t\t\t\t\tcover_url?: string\n\t\t\t\t\t\tduration?: number\n\t\t\t\t\t\tbilibili_bvid: string\n\t\t\t\t\t\tbilibili_cid?: string\n\t\t\t\t\t}\n\t\t\t\t\tsort_key: string\n\t\t\t\t\toperation_at: number\n\t\t\t  }\n\t\t\t| {\n\t\t\t\t\top: 'remove'\n\t\t\t\t\ttrack_unique_key: string\n\t\t\t\t\toperation_at: number\n\t\t\t  }\n\t\t\t| {\n\t\t\t\t\top: 'reorder'\n\t\t\t\t\ttrack_unique_key: string\n\t\t\t\t\tsort_key: string\n\t\t\t\t\toperation_at: number\n\t\t\t  }\n\n\t\tconst invalidRowIds: number[] = []\n\t\tconst validRowIds = new Set<number>()\n\t\tconst changes: SyncChange[] = []\n\n\t\tfor (const row of rows) {\n\t\t\tconst payload = this.parsePayload(row.payload)\n\t\t\tlet rowValid = true\n\t\t\tconst rowChanges: SyncChange[] = []\n\n\t\t\tif (row.operation === 'add_tracks') {\n\t\t\t\tconst ids = (payload.trackIds as number[]) || []\n\t\t\t\tif (ids.length === 0) rowValid = false\n\t\t\t\tfor (const tid of ids) {\n\t\t\t\t\tconst meta = metaMap.get(tid)\n\t\t\t\t\tif (!meta || !meta.sortKey || !meta.bvid) {\n\t\t\t\t\t\trowValid = false\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\trowChanges.push({\n\t\t\t\t\t\top: 'upsert',\n\t\t\t\t\t\ttrack: {\n\t\t\t\t\t\t\tunique_key: meta.uniqueKey,\n\t\t\t\t\t\t\ttitle: meta.title,\n\t\t\t\t\t\t\tartist_name: meta.artistName ?? undefined,\n\t\t\t\t\t\t\tartist_id: meta.artistId ?? undefined,\n\t\t\t\t\t\t\tcover_url: meta.coverUrl ?? undefined,\n\t\t\t\t\t\t\tduration: meta.duration ?? undefined,\n\t\t\t\t\t\t\tbilibili_bvid: meta.bvid,\n\t\t\t\t\t\t\tbilibili_cid: meta.cid?.toString(),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsort_key: meta.sortKey,\n\t\t\t\t\t\toperation_at: this.toMillis(row.operationAt),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else if (row.operation === 'remove_tracks') {\n\t\t\t\tconst ids = (payload.removedTrackIds as number[]) || []\n\t\t\t\tif (ids.length === 0) rowValid = false\n\t\t\t\tfor (const tid of ids) {\n\t\t\t\t\tconst meta = metaMap.get(tid)\n\t\t\t\t\tif (!meta) {\n\t\t\t\t\t\trowValid = false\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\trowChanges.push({\n\t\t\t\t\t\top: 'remove',\n\t\t\t\t\t\ttrack_unique_key: meta.uniqueKey,\n\t\t\t\t\t\toperation_at: this.toMillis(row.operationAt),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else if (row.operation === 'reorder_track') {\n\t\t\t\tconst tid = payload.trackId as number\n\t\t\t\tconst meta = metaMap.get(tid)\n\t\t\t\tif (!meta || !meta.sortKey) {\n\t\t\t\t\trowValid = false\n\t\t\t\t} else {\n\t\t\t\t\trowChanges.push({\n\t\t\t\t\t\top: 'reorder',\n\t\t\t\t\t\ttrack_unique_key: meta.uniqueKey,\n\t\t\t\t\t\tsort_key: meta.sortKey,\n\t\t\t\t\t\toperation_at: this.toMillis(row.operationAt),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (rowValid && rowChanges.length > 0) {\n\t\t\t\tchanges.push(...rowChanges)\n\t\t\t\tvalidRowIds.add(row.id)\n\t\t\t} else {\n\t\t\t\tinvalidRowIds.push(row.id)\n\t\t\t}\n\t\t}\n\n\t\treturn { changes, validRowIds, invalidRowIds }\n\t}\n\n\tprivate async pushMetadataChanges(\n\t\tshareId: string,\n\t\trows: QueueRow[],\n\t): Promise<void> {\n\t\t// 只取最后一条（LWW）\n\t\tconst latest = rows[rows.length - 1]\n\t\tconst payload = this.parsePayload(latest.payload) as {\n\t\t\ttitle?: string | null\n\t\t\tdescription?: string | null\n\t\t\tcoverUrl?: string | null\n\t\t}\n\t\tconst rowIds = rows.map((r) => r.id)\n\t\tawait this.markRows(rowIds, 'syncing')\n\n\t\ttry {\n\t\t\tconst resp = await bbplayerApi.playlists[':id'].$patch({\n\t\t\t\tparam: { id: shareId },\n\t\t\t\tjson: {\n\t\t\t\t\ttitle: payload.title ?? undefined,\n\t\t\t\t\tdescription: payload.description ?? undefined,\n\t\t\t\t\tcover_url: payload.coverUrl ?? undefined,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (!resp.ok) {\n\t\t\t\tconst body = await resp.json().catch(() => ({}))\n\t\t\t\tthrow new Error(`API ${resp.status}` + (JSON.stringify(body) ?? ''))\n\t\t\t}\n\n\t\t\tawait db\n\t\t\t\t.update(schema.playlistSyncQueue)\n\t\t\t\t.set({ status: 'done' })\n\t\t\t\t.where(inArray(schema.playlistSyncQueue.id, rowIds))\n\t\t} catch (error) {\n\t\t\tlogger.error('pushMetadataChanges 失败', { error })\n\t\t\tawait this.markRows(rowIds, 'failed')\n\t\t}\n\t}\n\n\tprivate async fetchTrackMetadata(\n\t\tplaylistId: number,\n\t\ttrackIds: number[],\n\t): Promise<Map<number, TrackMeta>> {\n\t\tif (trackIds.length === 0) return new Map()\n\n\t\tconst metaRows = await db\n\t\t\t.select({\n\t\t\t\ttrackId: schema.tracks.id,\n\t\t\t\tuniqueKey: schema.tracks.uniqueKey,\n\t\t\t\ttitle: schema.tracks.title,\n\t\t\t\tartistName: schema.artists.name,\n\t\t\t\tartistId: schema.artists.remoteId,\n\t\t\t\tcoverUrl: schema.tracks.coverUrl,\n\t\t\t\tduration: schema.tracks.duration,\n\t\t\t\tbvid: schema.bilibiliMetadata.bvid,\n\t\t\t\tcid: schema.bilibiliMetadata.cid,\n\t\t\t})\n\t\t\t.from(schema.tracks)\n\t\t\t.leftJoin(schema.artists, eq(schema.tracks.artistId, schema.artists.id))\n\t\t\t.leftJoin(\n\t\t\t\tschema.bilibiliMetadata,\n\t\t\t\teq(schema.tracks.id, schema.bilibiliMetadata.trackId),\n\t\t\t)\n\t\t\t.where(inArray(schema.tracks.id, trackIds))\n\n\t\tconst sortKeyRows = await db\n\t\t\t.select({\n\t\t\t\ttrackId: schema.playlistTracks.trackId,\n\t\t\t\tsortKey: schema.playlistTracks.sortKey,\n\t\t\t})\n\t\t\t.from(schema.playlistTracks)\n\t\t\t.where(\n\t\t\t\tand(\n\t\t\t\t\teq(schema.playlistTracks.playlistId, playlistId),\n\t\t\t\t\tinArray(schema.playlistTracks.trackId, trackIds),\n\t\t\t\t),\n\t\t\t)\n\n\t\tconst sortMap = new Map<number, string>()\n\t\tfor (const row of sortKeyRows) {\n\t\t\tsortMap.set(row.trackId, row.sortKey)\n\t\t}\n\n\t\tconst metaMap = new Map<number, TrackMeta>()\n\t\tfor (const row of metaRows) {\n\t\t\tmetaMap.set(row.trackId, {\n\t\t\t\ttrackId: row.trackId,\n\t\t\t\tuniqueKey: row.uniqueKey,\n\t\t\t\ttitle: row.title,\n\t\t\t\tartistName: row.artistName,\n\t\t\t\tartistId: row.artistId,\n\t\t\t\tcoverUrl: row.coverUrl,\n\t\t\t\tduration: row.duration,\n\t\t\t\tbvid: row.bvid,\n\t\t\t\tcid: row.cid,\n\t\t\t\tsortKey: sortMap.get(row.trackId),\n\t\t\t})\n\t\t}\n\n\t\treturn metaMap\n\t}\n\n\tprivate parsePayload(payload: unknown): Record<string, unknown> {\n\t\tif (payload === null || payload === undefined) return {}\n\t\tif (typeof payload === 'string') {\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(payload)\n\t\t\t} catch (e) {\n\t\t\t\tlogger.error('parsePayload 失败', { payload, error: e })\n\t\t\t\treturn {}\n\t\t\t}\n\t\t}\n\t\tif (typeof payload === 'object') return payload as Record<string, unknown>\n\t\treturn {}\n\t}\n\n\tprivate toMillis(value: unknown): number {\n\t\tif (value instanceof Date) return value.getTime()\n\t\tif (typeof value === 'number') return value\n\t\tif (typeof value === 'string') {\n\t\t\tconst num = Number(value)\n\t\t\treturn Number.isNaN(num) ? Date.now() : num\n\t\t}\n\t\treturn Date.now()\n\t}\n\n\tprivate async markRows(\n\t\tids: number[],\n\t\tstatus: 'pending' | 'syncing' | 'done' | 'failed',\n\t): Promise<void> {\n\t\tif (ids.length === 0) return\n\t\tawait db\n\t\t\t.update(schema.playlistSyncQueue)\n\t\t\t.set({ status })\n\t\t\t.where(inArray(schema.playlistSyncQueue.id, ids))\n\t}\n\n\t/** 永久性无效的记录（不可能成功），直接从队列中删除 */\n\tprivate async deleteRows(ids: number[]): Promise<void> {\n\t\tif (ids.length === 0) return\n\t\tawait db\n\t\t\t.delete(schema.playlistSyncQueue)\n\t\t\t.where(inArray(schema.playlistSyncQueue.id, ids))\n\t}\n}\n\nexport const playlistSyncWorker = new PlaylistSyncWorker()\n"
  },
  {
    "path": "apps/mobile/src/theme/dimensions.ts",
    "content": "export const LIST_ITEM_COVER_SIZE = 48\nexport const LIST_ITEM_BORDER_RADIUS = 12\n\nexport const SQUIRCLE_RADIUS_RATIO = 0.22\n\nexport const SQUIRCLE_CORNER_SMOOTHING = 0.6\n\nexport const LIST_ITEM_HEIGHT = 64\n"
  },
  {
    "path": "apps/mobile/src/types/apis/baidu.ts",
    "content": "export interface BaiduSearchResponse {\n\terror_code: number\n\tresult: {\n\t\tsong_info: {\n\t\t\tsong_list: {\n\t\t\t\tsong_id: string\n\t\t\t\ttitle: string\n\t\t\t\tauthor: string\n\t\t\t\talbum_title: string\n\t\t\t\tpic_small: string\n\t\t\t\tpic_premium: string\n\t\t\t\tpic_huge: string\n\t\t\t\tlrclink: string\n\t\t\t}[]\n\t\t}\n\t}\n}\n\nexport interface BaiduLyricResponse {\n\tlrcContent: string\n\ttitle: string\n}\n"
  },
  {
    "path": "apps/mobile/src/types/apis/bilibili.ts",
    "content": "/**\n * 获取音频流入参（dash）\n */\ninterface BilibiliAudioStreamParams {\n\tbvid: string\n\tcid: number\n\taudioQuality: number\n\tenableDolby: boolean\n\tenableHiRes: boolean\n}\n\n/**\n * 获取音频流（dash）返回值\n */\ninterface BilibiliAudioStreamResponse {\n\tdurl?: [\n\t\t{\n\t\t\torder: number // 恒为 1\n\t\t\turl: string\n\t\t\tbackup_url: string[]\n\t\t},\n\t]\n\tdash?: {\n\t\taudio:\n\t\t\t| {\n\t\t\t\t\tid: number\n\t\t\t\t\tbaseUrl: string\n\t\t\t\t\tbackupUrl: string[]\n\t\t\t  }[]\n\t\t\t| null\n\t\tdolby?: {\n\t\t\ttype: number\n\t\t\taudio:\n\t\t\t\t| {\n\t\t\t\t\t\tid: number\n\t\t\t\t\t\tbaseUrl: string\n\t\t\t\t\t\tbackupUrl: string[]\n\t\t\t\t  }[]\n\t\t\t\t| null\n\t\t} | null\n\t\tflac?: {\n\t\t\tdisplay: boolean\n\t\t\taudio: {\n\t\t\t\tid: number\n\t\t\t\tbaseUrl: string\n\t\t\t\tbackupUrl: string[]\n\t\t\t} | null\n\t\t} | null\n\t}\n\tvolume?:\n\t\t| {\n\t\t\t\tmeasured_i: number\n\t\t\t\ttarget_i: number\n\t\t\t\tmulti_scene_args: {\n\t\t\t\t\thigh_dynamic_target_i: '-24'\n\t\t\t\t\tnormal_target_i: '-14'\n\t\t\t\t\tundersized_target_i: '-28'\n\t\t\t\t}\n\t\t  }\n\t\t| undefined\n}\n\n/**\n * 历史记录获得的视频信息\n */\ninterface BilibiliHistoryVideo {\n\taid: number\n\tbvid: string\n\ttitle: string\n\tpic: string\n\tpubdate: number\n\towner: {\n\t\tname: string\n\t\tmid: number\n\t\tface: string\n\t}\n\tduration: number\n}\n\n/**\n * 通过details接口获取的视频完整信息\n */\ninterface BilibiliVideoDetails {\n\taid: number\n\tbvid: string\n\ttitle: string\n\tpic: string\n\tpubdate: number\n\tduration: number\n\tdesc: string\n\towner: {\n\t\tname: string\n\t\tmid: number\n\t\tface: string\n\t}\n\tcid: number\n\tpages: BilibiliVideoDetailsPage[]\n}\n\n/**\n * bilibili 视频详情接口获取到的 pages 字段\n */\ninterface BilibiliVideoDetailsPage {\n\tpart: string\n\tduration: number\n\tcid: number\n}\n\n/**\n * 收藏夹信息\n */\ninterface BilibiliPlaylist {\n\tid: number\n\ttitle: string\n\tmedia_count: number\n\tfav_state: number // 目标 id 是否存在于收藏夹中：0：不存在；1：存在（当未提供 rid 时始终为 0）\n}\n\n/**\n * 搜索结果视频信息\n */\ninterface BilibiliSearchVideo {\n\taid: number\n\tbvid: string\n\ttitle: string\n\tpic: string\n\tauthor: string\n\tduration: string // MM:SS（MM 可以超过 60min）\n\tsenddate: number\n\tmid: number\n\ttypeid: number\n}\n\n/**\n * 热门搜索信息\n */\ninterface BilibiliHotSearch {\n\tkeyword: string\n\tshow_name: string\n}\n\n/**\n * 用户详细信息\n */\ninterface BilibiliUserInfo {\n\tmid: number\n\tname: string\n\tface: string\n\tsign: string\n}\n\n/**\n * 收藏夹内容项\n */\ninterface BilibiliFavoriteListContent {\n\tid: number\n\tbvid: string\n\tupper: {\n\t\tmid: number\n\t\tname: string\n\t\tface: string\n\t}\n\ttitle: string\n\tcover: string\n\tduration: number\n\tpubdate: number\n\tpage: number\n\ttype: number // 2：视频稿件 12：音频 21：视频合集\n\tattr: number // 失效\t0: 正常；9: up自己删除；1: 其他原因删除\n}\n\n/**\n * 收藏夹内容列表\n */\ninterface BilibiliFavoriteListContents {\n\tinfo: {\n\t\tid: number\n\t\ttitle: string\n\t\tcover: string\n\t\tmedia_count: number\n\t\tintro: string\n\t\tupper: {\n\t\t\tname: string\n\t\t\tface: string\n\t\t\tmid: number\n\t\t}\n\t} | null\n\tmedias: BilibiliFavoriteListContent[] | null\n\thas_more: boolean\n\tttl: number\n}\n\n/**\n * 收藏夹所有内容（仅ID）\n */\ntype BilibiliFavoriteListAllContents = {\n\tid: number\n\tbvid: string\n\ttype: number // 2：视频稿件 12：音频 21：视频合集\n}[]\n\n/**\n * 追更合集/收藏夹列表中的单项数据\n */\ninterface BilibiliCollection {\n\tid: number\n\ttitle: string\n\tcover: string\n\tupper: {\n\t\tmid: number\n\t\tname: string\n\t\t// face: string 恒为空\n\t}\n\tmedia_count: number\n\tctime: number // 创建时间\n\tintro: string\n\tattr: number // 在不转换成 8-bit 的情况下，可能会有值：22 关注的别人收藏夹 0 追更视频合集 1 已失效（应通过 state 来区分）\n\tstate: 0 | 1 // 0: 正常；1:收藏夹已失效\n}\n\n/**\n * 追更合集/收藏夹内容\n */\ninterface BilibiliCollectionContent {\n\tinfo: {\n\t\tid: number\n\t\tseason_type: number // 未知\n\t\ttitle: string\n\t\tcover: string\n\t\tmedia_count: number\n\t\tintro: string\n\t\tupper: {\n\t\t\tname: string\n\t\t\tmid: number\n\t\t}\n\t}\n\tmedias: {\n\t\tid: number // avid\n\t\tbvid: string\n\t\ttitle: string\n\t\tcover: string\n\t\tintro: string\n\t\tduration: number\n\t\tpubtime: number\n\t\tupper: {\n\t\t\tmid: number\n\t\t\tname: string\n\t\t}\n\t}\n}\n\n/**\n * 合集详情信息\n */\ninterface BilibiliCollectionInfo {\n\tid: number\n\tseason_type: number // wtf\n\ttitle: string\n\tcover: string\n\tupper: {\n\t\tmid: number\n\t\tname: string\n\t}\n\tcnt_info: {\n\t\tcollect: number\n\t\tplay: number\n\t\tdanmaku: number\n\t}\n\tmedia_count: number\n\tintro: string\n}\n\n/**\n * 合集内单个内容\n */\ninterface BilibiliMediaItemInCollection {\n\tid: number\n\ttitle: string\n\tcover: string\n\tduration: number\n\tpubtime: number\n\tbvid: string\n\tupper: {\n\t\tmid: number\n\t\tname: string\n\t}\n\tcnt_info: {\n\t\tcollect: number\n\t\tplay: number\n\t\tdanmaku: number\n\t}\n}\n\n/**\n * /x/space/fav/season/list\n * 合集内容\n */\ninterface BilibiliCollectionAllContents {\n\tinfo: BilibiliCollectionInfo\n\tmedias: BilibiliMediaItemInCollection[] | null\n}\n\n/**\n * 分 p 视频数据\n */\ninterface BilibiliMultipageVideo {\n\tcid: number\n\tpage: number\n\tpart: string\n\tduration: number\n\tfirst_frame: string\n}\n\n/**\n * 添加/删除一个视频到收藏夹的响应\n */\ninterface BilibiliDealFavoriteForOneVideoResponse {\n\tprompt: boolean\n\tga_data: unknown\n\ttoast_msg: string\n\tsuccess_num: number\n}\n\n/**\n * 用户上传内容接口返回\n */\ninterface BilibiliUserUploadedVideosResponse {\n\tpage: {\n\t\tpn: number\n\t\tps: number\n\t\tcount: number\n\t}\n\tlist: {\n\t\tvlist: {\n\t\t\taid: number\n\t\t\tbvid: string\n\t\t\ttitle: string\n\t\t\tpic: string\n\t\t\tcreated: number\n\t\t\tlength: string // MM:SS\n\t\t\tauthor: string // 不一定是所查询的 up 主本人，因为存在合作视频\n\t\t}[]\n\t}\n}\n\nenum BilibiliQrCodeLoginStatus {\n\tQRCODE_LOGIN_STATUS_WAIT = 86101, // 等待扫码\n\tQRCODE_LOGIN_STATUS_SCANNED_BUT_NOT_CONFIRMED = 86090, // 扫码但未确认\n\tQRCODE_LOGIN_STATUS_SUCCESS = 0, // 扫码成功\n\tQRCODE_LOGIN_STATUS_QRCODE_EXPIRED = 86038, // 二维码已过期\n}\n\n/**\n * 手机号登录 - 获取验证码图形验证信息\n */\ninterface BilibiliCaptchaTokenData {\n\ttoken: string\n\tgeetest: {\n\t\tgt: string\n\t\tchallenge: string\n\t}\n\ttencent: {\n\t\tappid: string\n\t}\n}\n\n/**\n * 手机号登录 - 发送短信验证码结果\n */\ninterface BilibiliSmsSendData {\n\tcaptcha_key: string\n}\n\n/**\n * 手机号登录 - 登录结果\n */\ninterface BilibiliSmsLoginData {\n\tstatus: number\n\tmessage: string\n\turl: string\n\tmid: number\n\taccess_token: string\n\trefresh_token: string\n\texpires_in: number\n\ttoken_info: {\n\t\tmid: number\n\t\taccess_token: string\n\t\trefresh_token: string\n\t\texpires_in: number\n\t} | null\n}\n\n/**\n * 搜索建议\n */\ninterface BilibiliSearchSuggestionItem {\n\tterm: string\n\tvalue: string\n\tref: number\n\tname: string\n\tspid: number\n\ttype: string\n}\n\ninterface BilibiliWebPlayerInfo {\n\tbgm_info?: {\n\t\tmusic_id: number\n\t\tmusic_title: string\n\t\tjump_url: string\n\t}\n}\n\ninterface BilibiliToViewVideoList {\n\tcount: number\n\tlist: {\n\t\taid: number\n\t\tbvid: string\n\t\tcount: number // 分 p 数\n\t\tpubdate: number\n\t\towner: {\n\t\t\tmid: number\n\t\t\tname: string\n\t\t\tface: string\n\t\t}\n\t\tcid: number\n\t\ttitle: string\n\t\tduration: number\n\t\tpic: string\n\t\tprogress: number\n\t}[]\n}\n\n/**\n * 评论区用户信息\n */\ninterface BilibiliCommentMember {\n\tmid: string\n\tuname: string\n\tsex: string\n\tsign: string\n\tavatar: string\n\trank: string\n\tlevel_info: {\n\t\tcurrent_level: number\n\t}\n}\n\n/**\n * 评论内容\n */\ninterface BilibiliCommentContent {\n\tmessage: string\n\tplat: number\n\tdevice: string\n\tmembers: unknown[]\n\tjump_url: Record<string, unknown>\n\tmax_line: number\n\tpictures?: {\n\t\timg_src: string\n\t\timg_width: number\n\t\timg_height: number\n\t\timg_size: number\n\t}[]\n}\n\n/**\n * 单条评论信息\n */\ninterface BilibiliCommentItem {\n\trpid: number\n\toid: number\n\ttype: number\n\tmid: number\n\troot: number\n\tparent: number\n\tdialog: number\n\tcount: number\n\trcount: number\n\tstate: number\n\tfansgrade: number\n\tattr: number\n\tctime: number\n\trpid_str: string\n\troot_str: string\n\tparent_str: string\n\tlike: number\n\taction: number\n\tmember: BilibiliCommentMember\n\tcontent: BilibiliCommentContent\n\treplies: BilibiliCommentItem[] | null\n\tassist: number\n\tfolder: {\n\t\thas_folded: boolean\n\t\tis_folded: boolean\n\t\trule: string\n\t}\n\tinvisible: boolean\n}\n\n/**\n * 获取评论区列表返回值\n */\ninterface BilibiliCommentsResponse {\n\tcursor: {\n\t\tis_begin: boolean\n\t\tprev: number\n\t\tnext: number\n\t\tis_end: boolean\n\t\tmode: number\n\t\tshow_header: number\n\t\tall_count: number\n\t\tsupport_mode: number[]\n\t\tname: string\n\t}\n\treplies: BilibiliCommentItem[] | null\n\ttop: {\n\t\tupper: BilibiliCommentItem | null\n\t\tadmin: BilibiliCommentItem | null\n\t}\n}\n\n/**\n * 获取楼中楼（子评论）返回值\n */\ninterface BilibiliReplyCommentsResponse {\n\tpage: {\n\t\tnum: number\n\t\tsize: number\n\t\tcount: number\n\t}\n\treplies: BilibiliCommentItem[] | null\n\troot: BilibiliCommentItem\n}\n\n/**\n * 单条弹幕数据（项目内使用）\n */\ninterface BilibiliDanmakuItem {\n\tid: number | Long\n\tprogress: number // 弹幕出现时间（ms）\n\tmode: number // 弹幕模式：1/2/3：滚动；4：底部；5：顶部\n\tfontsize?: 18 | 25 | 36 | null // 我们可能不会使用这个值，统一归一化\n\tcolor?: number | null // 十进制 RGB888\n\tcontent: string // 弹幕内容\n\tweight?: number | null // 弹幕权重 [0-10]，我们在过滤弹幕时有用，值越大权重越高\n}\n\nexport type {\n\tBilibiliAudioStreamParams,\n\tBilibiliAudioStreamResponse,\n\tBilibiliCaptchaTokenData,\n\tBilibiliCollection,\n\tBilibiliCollectionAllContents,\n\tBilibiliCollectionContent,\n\tBilibiliCollectionInfo,\n\tBilibiliCommentContent,\n\tBilibiliCommentItem,\n\tBilibiliCommentMember,\n\tBilibiliCommentsResponse,\n\tBilibiliDealFavoriteForOneVideoResponse,\n\tBilibiliFavoriteListAllContents,\n\tBilibiliFavoriteListContent,\n\tBilibiliFavoriteListContents,\n\tBilibiliHistoryVideo,\n\tBilibiliHotSearch,\n\tBilibiliMediaItemInCollection,\n\tBilibiliMultipageVideo,\n\tBilibiliPlaylist,\n\tBilibiliReplyCommentsResponse,\n\tBilibiliSearchSuggestionItem,\n\tBilibiliSearchVideo,\n\tBilibiliSmsLoginData,\n\tBilibiliSmsSendData,\n\tBilibiliToViewVideoList,\n\tBilibiliUserInfo,\n\tBilibiliUserUploadedVideosResponse,\n\tBilibiliVideoDetails,\n\tBilibiliWebPlayerInfo,\n\tBilibiliDanmakuItem,\n}\n\nexport { BilibiliQrCodeLoginStatus }\n"
  },
  {
    "path": "apps/mobile/src/types/apis/kugou.ts",
    "content": "export interface KugouSearchResponse {\n\tstatus: number\n\tdata: {\n\t\tinfo: {\n\t\t\thash: string\n\t\t\tfilename: string\n\t\t\talbum_name: string\n\t\t\tduration: number // assume seconds\n\t\t\tsingername: string\n\t\t\tsongname: string\n\t\t}[]\n\t\ttotal: number\n\t}\n}\n\nexport interface KugouLyricSearchResponse {\n\tstatus: number\n\tcandidates: {\n\t\tid: string\n\t\taccesskey: string\n\t\tfmt: string\n\t\tduration: number\n\t\tsinger: string\n\t\tsong: string\n\t}[]\n}\n\nexport interface KugouLyricDownloadResponse {\n\tstatus: number\n\tcontent: string // Base64 encoded lrc\n\tfmt: string\n}\n"
  },
  {
    "path": "apps/mobile/src/types/apis/kuwo.ts",
    "content": "export interface KuwoSearchResponse {\n\tcode: number\n\tmessage: string\n\tdata: {\n\t\ttotal: string\n\t\tlist: {\n\t\t\trid: number\n\t\t\tname: string\n\t\t\tartist: string\n\t\t\talbum: string\n\t\t\thasmv: number\n\t\t\treleaseDate: string\n\t\t\tsongTimeMinutes: string\n\t\t\tisListenFee: boolean\n\t\t\tpic: string\n\t\t\talbumid: number\n\t\t\tartistid: number\n\t\t\tduration: number // assume seconds based on meting\n\t\t}[]\n\t}\n}\n\nexport interface KuwoLyricResponse {\n\tstatus: number\n\tdata: {\n\t\tlrclist: {\n\t\t\tlineLyric: string\n\t\t\ttime: string // e.g. \"0.33\"\n\t\t}[]\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/types/apis/netease.ts",
    "content": "export interface NeteasePlaylistResponse {\n\tcode: number\n\tplaylist?: NeteasePlaylist\n}\n\nexport interface NeteasePlaylist {\n\tid: number\n\tname: string\n\tcoverImgId: number\n\tcoverImgUrl: string\n\tuserId: number\n\tcreateTime: number\n\tdescription: string | null\n\ttags: string[]\n\tbackgroundCoverId: number\n\tbackgroundCoverUrl: string | null\n\tsubscribedCount: number\n\tcloudTrackCount: number\n\ttrackCount: number\n\tcreator?: NeteaseCreator | null\n\ttracks?: NeteaseSong[] | null\n}\n\nexport interface NeteaseCreator {\n\tuserId: number\n\tnickname: string\n\tsignature: string\n\tdescription: string\n\tavatarUrl: string\n\tbackgroundUrl: string\n}\n\nexport interface NeteaseSong {\n\tid: number\n\tname: string\n\tar: NeteaseArtist[]\n\talia: string[] // Alias\n\tal: NeteaseAlbum\n\tdt: number // Duration\n\ttns?: string[] // Translated names\n}\n\nexport interface NeteaseArtist {\n\tid: number\n\tname: string\n\ttns: string[]\n\talias: string[]\n}\n\nexport interface NeteaseAlbum {\n\tid: number\n\tname: string\n\tpicUrl: string\n\ttns: string[]\n}\n\nexport interface NeteaseLyricResponse {\n\tlrc: {\n\t\tversion: number\n\t\tlyric: string\n\t}\n\t/** 翻译歌词 */\n\ttlyric?: {\n\t\tversion: number\n\t\tlyric: string\n\t}\n\t/** 罗马音歌词 */\n\tromalrc?: {\n\t\tversion: number\n\t\tlyric: string\n\t}\n\t/** 逐字歌词 (Verbatim) */\n\tyrc?: {\n\t\tversion: number\n\t\tlyric: string\n\t}\n\t/** 与 yrc 相对应的翻译歌词，如果使用 yrc 就必须用这个，否则时间戳对应不上 */\n\tytlrc?: {\n\t\tversion: number\n\t\tlyric: string\n\t}\n\t/** 与 yrc 相对应的罗马音歌词，如果使用 yrc 就必须用这个，否则时间戳对应不上 */\n\tyromalrc?: {\n\t\tversion: number\n\t\tlyric: string\n\t}\n\tcode: number\n}\n\nexport interface NeteaseSearchResponse {\n\tresult: {\n\t\tsongs: NeteaseSong[]\n\t}\n\tcode: number\n}\n"
  },
  {
    "path": "apps/mobile/src/types/apis/qqmusic.ts",
    "content": "export interface QQMusicSearchResponse {\n\tcode: number\n\treq: {\n\t\tcode: number\n\t\tdata: {\n\t\t\tbody: {\n\t\t\t\tsong: {\n\t\t\t\t\tlist: QQMusicSong[]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tmeta: {\n\t\t\tcid: number\n\t\t\tcurpage: number\n\t\t\tdir: string\n\t\t\tdisplay_num: number\n\t\t\tein: number\n\t\t\tnext_page: number\n\t\t\tnext_page_start: number\n\t\t\tnum: number\n\t\t\tnum_per_page: number\n\t\t\tp: number\n\t\t\tsin: number\n\t\t\tsum: number\n\t\t\ttotal_num: number\n\t\t\tuid: string\n\t\t}\n\t}\n}\n\nexport interface QQMusicSong {\n\tid: number\n\tmid: string\n\tname: string\n\ttitle: string\n\tsubtitle: string\n\tsinger: {\n\t\tid: number\n\t\tmid: string\n\t\tname: string\n\t\ttitle: string\n\t\ttype: number\n\t\tuin: number\n\t}[]\n\talbum: {\n\t\tid: number\n\t\tmid: string\n\t\tname: string\n\t\ttitle: string\n\t\tsubtitle: string\n\t\ttime_public: string\n\t\tpmid: string\n\t}\n\tmv: {\n\t\tid: number\n\t\tvid: string\n\t\tname: string\n\t\ttitle: string\n\t\tvt: number\n\t}\n\tinterval: number // Duration in seconds\n\t// ... there are many other fields but we primarily need id, mid, name, singer, and interval\n}\n\nexport interface QQMusicLyricResponse {\n\tretcode: number\n\tcode: number\n\tsubcode: number\n\tlyric: string\n\ttrans: string\n}\n\nexport interface QQMusicPlaylistResponse {\n\tcode: number\n\tdata: {\n\t\tcdlist: QQMusicPlaylist[]\n\t}\n}\n\nexport interface QQMusicPlaylist {\n\tdisstid: string\n\tdissname: string\n\tdesc: string\n\tsongnum: number\n\tlogo: string\n\tnickname: string\n\tsonglist: QQMusicSong[]\n}\n"
  },
  {
    "path": "apps/mobile/src/types/core/appStore.ts",
    "content": "import type { Result } from 'neverthrow'\n\ninterface Settings {\n\tsendPlayHistory: boolean\n\tenableDebugLog: boolean\n\tenableOldSchoolStyleLyric: boolean\n\tenableSpectrumVisualizer: boolean\n\tplayerBackgroundStyle: 'gradient' | 'md3'\n\tnowPlayingBarStyle: 'float' | 'bottom'\n\tlyricSource: 'auto' | 'netease' | 'qqmusic' | 'kugou'\n\tenableVerbatimLyrics: boolean\n\tenableDataCollection: boolean\n\tenableDanmaku: boolean\n\tdanmakuFilterLevel: number\n\tdownloadMaxParallelTasks: number\n}\n\ninterface BilibiliUserSummary {\n\tmid?: number\n\tname?: string\n\tface?: string\n\tcachedAt?: number\n}\n\ninterface AppState {\n\tbilibiliCookie: Record<string, string> | null\n\tbilibiliUserInfo: BilibiliUserSummary | null\n\tbbplayerToken: string | null\n\tsettings: Settings\n\n\t// Cookies\n\thasBilibiliCookie: () => boolean\n\tsetBilibiliCookie: (cookieString: string) => Result<void, Error>\n\tupdateBilibiliCookie: (updates: Record<string, string>) => Result<void, Error>\n\tclearBilibiliCookie: () => void\n\tsetBilibiliUserInfo: (info: BilibiliUserSummary | null) => void\n\n\t// Auth\n\tsetBbplayerToken: (token: string) => void\n\tclearBbplayerToken: () => void\n\n\t// Settings\n\tsetSettings: (updates: Partial<Settings>) => void\n\n\tsetEnableDebugLog: (value: boolean) => void\n\tsetEnableDataCollection: (value: boolean) => void\n}\n\nexport type { AppState, BilibiliUserSummary, Settings }\n"
  },
  {
    "path": "apps/mobile/src/types/core/downloadManagerStore.ts",
    "content": "export interface DownloadTask {\n\tuniqueKey: string\n\ttitle: string\n\tcoverUrl?: string\n\tstatus: 'queued' | 'downloading' | 'completed' | 'failed'\n\terror?: string\n}\n\nexport interface DownloadState {\n\tdownloads: Record<string, DownloadTask>\n\tmaxConcurrentDownloads: number\n}\n\nexport interface DownloadActions {\n\t// external\n\tqueueDownloads: (\n\t\ttracks: {\n\t\t\tuniqueKey: string\n\t\t\ttitle: string\n\t\t\tcoverUrl?: string\n\t\t}[],\n\t) => void\n\tcancelDownload: (uniqueKey: string) => void\n\tretryDownload: (uniqueKey: string) => void\n\tclearAll: () => void\n\t/**\n\t * 手动触发队列下载，在应用启动时使用\n\t */\n\tstartDownload: () => void\n\n\t// internal\n\t_setDownloadStatus: (\n\t\tuniqueKey: string,\n\t\tstatus: DownloadTask['status'],\n\t\terror?: string,\n\t) => void\n\t_setDownloadProgress: (\n\t\tuniqueKey: string,\n\t\tcurrent: number,\n\t\ttotal: number,\n\t) => void\n\n\t_processQueue: () => void\n}\n"
  },
  {
    "path": "apps/mobile/src/types/core/media.ts",
    "content": "export interface Artist {\n\tid: number\n\tname: string\n\tavatarUrl?: string | null\n\tsignature?: string | null\n\tsource: 'bilibili' | 'local'\n\tremoteId?: string | null\n\tcreatedAt: Date\n\tupdatedAt: Date\n}\n\nexport interface PlayRecord {\n\tstartTime: number // 播放开始的时间戳 (ms)\n\tdurationPlayed: number // 实际播放的秒数\n\tcompleted: boolean // 是否完整播放\n}\n\ninterface BaseTrack {\n\tid: number\n\tuniqueKey: string\n\ttitle: string\n\tartist: Artist | null\n\tcoverUrl: string | null\n\tsource: 'bilibili' | 'local'\n\tcreatedAt: Date\n\tduration: number // 歌曲时长，单位：秒\n\tupdatedAt: Date\n}\n\nexport interface BilibiliTrack extends BaseTrack {\n\tsource: 'bilibili'\n\ttitleHtml?: string // 带有高亮标签的标题\n\tbilibiliMetadata: {\n\t\tbvid: string\n\t\tcid: number | null\n\t\tisMultiPage: boolean\n\t\tvideoIsValid: boolean\n\t\tmainTrackTitle?: string | null // 如果是分 p 视频，保存该分 p 所在的主视频标题\n\t\t// 运行时产生的数据，在获取流后才会存在\n\t\tbilibiliStreamUrl?: {\n\t\t\turl: string\n\t\t\tquality: number\n\t\t\tgetTime: number\n\t\t\ttype: 'mp4' | 'dash' | 'local'\n\t\t\tvolume?: {\n\t\t\t\tmeasured_i: number\n\t\t\t\ttarget_i: number\n\t\t\t\tmulti_scene_args: {\n\t\t\t\t\thigh_dynamic_target_i: '-24'\n\t\t\t\t\tnormal_target_i: '-14'\n\t\t\t\t\tundersized_target_i: '-28'\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nexport interface LocalTrack extends BaseTrack {\n\tsource: 'local'\n\tlocalMetadata: {\n\t\tlocalPath: string\n\t}\n}\n\nexport type Track = BilibiliTrack | LocalTrack\n\nexport interface Playlist {\n\tid: number\n\ttitle: string\n\tauthor: Artist | null // 本地播放列表不存在 author\n\tdescription: string | null\n\tcoverUrl: string | null\n\titemCount: number\n\tcontents?: Track[]\n\ttype: 'favorite' | 'collection' | 'multi_page' | 'local' | 'dynamic'\n\tremoteSyncId: number | null\n\tlastSyncedAt: Date | null\n\t// 歌单分享功能字段\n\tshareId: string | null\n\tshareRole: 'owner' | 'editor' | 'subscriber' | null\n\tlastShareSyncAt: Date | null\n\tcreatedAt: Date\n\tupdatedAt: Date\n}\n"
  },
  {
    "path": "apps/mobile/src/types/core/scope.ts",
    "content": "/**\n * 项目不同分区\n */\nexport enum ProjectScope {\n\tBilibiliAPI = 'BilibiliAPI',\n\tService = 'Service',\n\tFacade = 'Facade',\n\tUI = 'UI',\n\tPlayer = 'Player',\n\tUtils = 'Utils',\n}\n"
  },
  {
    "path": "apps/mobile/src/types/external_playlist.ts",
    "content": "export interface GenericTrack {\n\ttitle: string\n\tartists: string[]\n\talbum: string\n\tduration: number // milliseconds\n\tcoverUrl?: string | undefined\n\ttranslatedTitle?: string | undefined\n}\n\nexport interface GenericPlaylist {\n\tid: string\n\ttitle: string\n\tcoverUrl: string\n\tdescription: string\n\ttrackCount: number\n\tauthor: {\n\t\tname: string\n\t\tid?: string | number\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/types/flashlist.ts",
    "content": "import type { ListRenderItemInfo } from '@shopify/flash-list'\n\nexport type ListRenderItemInfoWithExtraData<TItem, TExtraData> = Omit<\n\tListRenderItemInfo<TItem>,\n\t'extraData'\n> & {\n\textraData?: TExtraData\n}\n\n/**\n * 播放列表页面的多选状态管理\n */\nexport interface SelectionState {\n\t/**\n\t * 是否处于多选模式\n\t */\n\tactive: boolean\n\t/**\n\t * 已选中的项目ID\n\t */\n\tselected: Set<number>\n\t/**\n\t * 切换项目的选中状态\n\t */\n\ttoggle: (id: number) => void\n\t/**\n\t * 进入多选模式\n\t */\n\tenter: (id: number) => void\n}\n"
  },
  {
    "path": "apps/mobile/src/types/navigation.ts",
    "content": "import type { AlertModalProps } from '@/components/modals/AlertModal'\nimport type { MatchResult } from '@/lib/services/externalPlaylistService'\nimport type { Playlist, Track } from '@/types/core/media'\nimport type { GenericTrack } from '@/types/external_playlist'\nimport type { LyricFileData } from '@/types/player/lyrics'\nimport type { CreateArtistPayload } from '@/types/services/artist'\nimport type { CreateTrackPayload } from '@/types/services/track'\n\nexport interface ModalPropsMap {\n\tManualMatchExternalSync: {\n\t\ttrack: GenericTrack\n\t\tinitialQuery: string\n\t\tonMatch: (result: MatchResult) => void\n\t}\n\tAddVideoToBilibiliFavorite: { bvid: string }\n\tEditPlaylistMetadata: { playlist: Playlist }\n\tEditTrackMetadata: { track: Track }\n\tQRCodeLogin: undefined\n\tCookieLogin: undefined\n\tPhoneLogin: undefined\n\tCreatePlaylist: { redirectToNewPlaylist?: boolean }\n\tUpdateApp: { version: string; notes: string; url: string; forced?: boolean }\n\tUpdateTrackLocalPlaylists: { track: Track }\n\tWelcome: undefined\n\tBatchAddTracksToLocalPlaylist: {\n\t\tpayloads: { track: CreateTrackPayload; artist: CreateArtistPayload }[]\n\t}\n\tDuplicateLocalPlaylist: { sourcePlaylistId: number; rawName: string }\n\tManualSearchLyrics: { uniqueKey: string; initialQuery: string }\n\tInputExternalPlaylistInfo: undefined\n\tAlert: AlertModalProps\n\tEditLyrics: { uniqueKey: string; lyrics: LyricFileData }\n\tSleepTimer: undefined\n\tSaveQueueToPlaylist: { trackIds: string[] }\n\tDonationQR: { type: 'wechat' | 'alipay' }\n\tPlaybackSpeed: undefined\n\tLyricsSelection: undefined\n\tSongShare: undefined\n\tSyncLocalToBilibili: { playlistId: number }\n\tFavoriteSyncProgress: {\n\t\tfavoriteId: number\n\t\tshouldRedirectToLocalPlaylist?: boolean\n\t}\n\tDanmakuSettings: undefined\n\tCoverDownloadProgress: undefined\n\tEnableSharing: {\n\t\tplaylistId: number\n\t\tshareId?: string | null\n\t\tshareRole?: 'owner' | 'editor' | 'subscriber' | null\n\t}\n\tSubscribeToSharedPlaylist: undefined\n\tMergePlaylists: undefined\n}\n\nexport type ModalKey = keyof ModalPropsMap\nexport interface ModalInstance<K extends ModalKey = ModalKey> {\n\tkey: K\n\tprops: ModalPropsMap[K]\n\toptions?: { dismissible?: boolean } // default: true\n}\n"
  },
  {
    "path": "apps/mobile/src/types/player/lyrics.ts",
    "content": "export type Tags = Record<string, string>\n\nexport interface OldLyricLine {\n\t/**\n\t * 歌词的起始时间，单位：秒\n\t */\n\ttimestamp: number\n\t/**\n\t * 原始歌词内容\n\t */\n\ttext: string\n\t/**\n\t * 翻译歌词\n\t */\n\ttranslation?: string\n}\n\nexport interface ParsedLrc {\n\ttags: Tags\n\tlyrics: OldLyricLine[] | null\n\trawOriginalLyrics: string // 原始歌词\n\trawTranslatedLyrics?: string // 原始翻译歌词\n\toffset?: number // 单位秒\n}\n\nexport type LyricSearchResult = (\n\t| {\n\t\t\tsource: 'netease'\n\t\t\tduration: number // 秒\n\t\t\ttitle: string\n\t\t\tartist: string\n\t\t\tremoteId: number\n\t  }\n\t| {\n\t\t\tsource: 'qqmusic'\n\t\t\tduration: number // 秒\n\t\t\ttitle: string\n\t\t\tartist: string\n\t\t\tremoteId: string\n\t  }\n\t| {\n\t\t\tsource: 'kugou'\n\t\t\tduration: number // 秒\n\t\t\ttitle: string\n\t\t\tartist: string\n\t\t\tremoteId: string\n\t  }\n)[]\n\nexport interface LyricFileData {\n\tid: string // 歌曲唯一ID\n\tupdateTime: number // 缓存时间\n\n\t// 所有歌词都是 SPL 格式\n\tlrc?: string | undefined // 主歌词\n\ttlyric?: string | undefined // 翻译歌词\n\tromalrc?: string | undefined // 罗马音歌词\n\n\t/** 当歌词获取失败时（如离线状态），存储错误信息直接展示，不走解析流程 */\n\terrorMessage?: string | undefined\n\n\t/**\n\t * 用户手动跳过了该歌曲的歌词获取。\n\t * 为 true 时，smartFetchLyrics 不会尝试重新获取网络歌词。\n\t * 当用户手动搜索或编辑歌词时，此字段应被重置为 false。\n\t */\n\tmanualSkip?: boolean | undefined\n\n\tmisc?:\n\t\t| {\n\t\t\t\tuserOffset?: number | undefined // 用户设置的歌词偏移量\n\t\t  }\n\t\t| undefined\n}\n\n// 歌词提供者最终应该返回的数据结构\nexport type LyricProviderResponseData = Omit<\n\tLyricFileData,\n\t'id' | 'updateTime' | 'misc'\n>\n"
  },
  {
    "path": "apps/mobile/src/types/services/artist.ts",
    "content": "export interface CreateArtistPayload {\n\tname: string\n\tsource: 'bilibili' | 'local'\n\tremoteId?: string | null\n\tavatarUrl?: string | null\n\tsignature?: string | null\n}\n\nexport interface UpdateArtistPayload {\n\tname?: string | null\n\tavatarUrl?: string | null\n\tsignature?: string | null\n}\n"
  },
  {
    "path": "apps/mobile/src/types/services/playlist.ts",
    "content": "export type SharedPlaylistRole = 'owner' | 'editor' | 'subscriber'\n\nexport interface CreatePlaylistPayload {\n\ttitle: string\n\tdescription?: string | null\n\tcoverUrl?: string | null\n\tauthorId?: number | null // 如果是本地播放列表，则为 null\n\ttype: 'favorite' | 'collection' | 'multi_page' | 'local' | 'dynamic'\n\tremoteSyncId?: number | null\n\tshareId?: string | null\n\tshareRole?: SharedPlaylistRole | null\n\tlastShareSyncAt?: number | null\n}\n\nexport interface UpdatePlaylistPayload {\n\ttitle?: string | null\n\tdescription?: string | null\n\tcoverUrl?: string | null\n\t// 共享歌单升级/降级字段：普通本地歌单 → 共享歌单时需要更新这三个字段\n\tshareId?: string | null\n\tshareRole?: SharedPlaylistRole | null\n\tlastShareSyncAt?: number | null\n}\n\nexport interface ReorderLocalPlaylistTrackPayload {\n\ttrackId: number\n\tprevSortKey: string | null // 目标位置前一项的 sortKey，null 代表列表最前\n\tnextSortKey: string | null // 目标位置后一项的 sortKey，null 代表列表最后\n}\n"
  },
  {
    "path": "apps/mobile/src/types/services/track.ts",
    "content": "export interface BilibiliMetadataPayload {\n\tbvid: string\n\tisMultiPage: boolean\n\tcid?: number | null\n\tvideoIsValid: boolean\n\tmainTrackTitle?: string | null // 如果是分 p 视频，保存该分 p 所在的主视频标题\n}\n\nexport interface LocalMetadataPayload {\n\tlocalPath: string\n}\n\nexport interface CreateTrackPayloadBase {\n\ttitle: string\n\tartistId?: number | null\n\tcoverUrl?: string | null\n\tduration: number\n}\n\nexport interface CreateBilibiliTrackPayload extends CreateTrackPayloadBase {\n\tsource: 'bilibili'\n\tbilibiliMetadata: BilibiliMetadataPayload\n}\n\ninterface CreateLocalTrackPayload extends CreateTrackPayloadBase {\n\tsource: 'local'\n\tlocalMetadata: LocalMetadataPayload\n}\n\nexport type CreateTrackPayload =\n\t| CreateBilibiliTrackPayload\n\t| CreateLocalTrackPayload\n\n// export interface UpdateTrackPayload {\n//   id: number\n//   title?: string\n//   source?: 'bilibili' | 'local'\n//   artistId?: number\n//   coverUrl?: string\n//   duration?: number\n//   bilibiliMetadata?: BilibiliMetadataPayload\n//   localMetadata?: LocalMetadataPayload\n// }\n\nexport interface UpdateTrackPayloadBase {\n\tid: number\n\ttitle?: string | null\n\tcoverUrl?: string | null\n\tduration?: number | null\n\tartistId?: number | null\n}\n\ninterface UpdateBilibiliTrackPayload extends UpdateTrackPayloadBase {\n\tsource: 'bilibili'\n\tbilibiliMetadata?: Partial<BilibiliMetadataPayload>\n}\n\ninterface UpdateLocalTrackPayload extends UpdateTrackPayloadBase {\n\tsource: 'local'\n\tlocalMetadata?: Partial<LocalMetadataPayload>\n}\n\nexport type UpdateTrackPayload =\n\t| UpdateBilibiliTrackPayload\n\t| UpdateLocalTrackPayload\n\nexport type TrackSourceData =\n\t| {\n\t\t\tsource: 'bilibili'\n\t\t\tbilibiliMetadata: BilibiliMetadataPayload\n\t  }\n\t| {\n\t\t\tsource: 'local'\n\t\t\tlocalMetadata: LocalMetadataPayload\n\t  }\n"
  },
  {
    "path": "apps/mobile/src/types/storage.ts",
    "content": "export interface AppStorageSchema {\n\tfirst_open: boolean\n\tignore_alert_replace_playlist: boolean\n\tskip_version: string\n\tenable_sentry_report: boolean\n\tenable_debug_log: boolean\n\tsend_play_history: boolean\n\tbilibili_cookie: string\n\t'download-manager-storage-v2': string\n\t'player-storage-full': string\n\twbi_keys: string\n\tenable_old_school_style_lyric: boolean\n\tplayer_background_style: 'gradient' | 'md3' | 'streamer'\n\tnow_playing_bar_style: 'float' | 'bottom'\n\tenable_persist_current_position: boolean\n\t'app-storage': string\n\tcurrent_position: number\n\tenable_loudness_normalization: boolean\n\tdb_schema_version: number\n\tsort_key_migrated_v1: boolean\n\tsort_key_migrated_v2: boolean\n\tbbplayer_jwt: string\n\tsort_key_migrated_v3: boolean\n\tplay_history_migrated_v1: boolean\n}\n\nexport type StorageKey = keyof AppStorageSchema\n\ntype KeysForType<Schema, T> = {\n\t[K in keyof Schema]: Schema[K] extends T ? K : never\n}[keyof Schema]\n\ntype BooleanKeys<Schema> = KeysForType<Schema, boolean>\ntype StringKeys<Schema> = KeysForType<Schema, string>\ntype NumberKeys<Schema> = KeysForType<Schema, number>\ntype BufferKeys<Schema> = KeysForType<Schema, ArrayBuffer | ArrayBufferLike>\n\n/**\n * Represents a single MMKV instance.\n */\nexport interface TypedNativeMMKV<Schema> {\n\t/**\n\t * Set a value for the given `key`.\n\t *\n\t * @throws an Error if the value cannot be set.\n\t */\n\tset: <K extends keyof Schema>(key: K, value: Schema[K]) => void\n\t/**\n\t * Get the boolean value for the given `key`, or `undefined` if it does not exist.\n\t *\n\t * @default undefined\n\t */\n\tgetBoolean: (key: BooleanKeys<Schema>) => boolean | undefined\n\t/**\n\t * Get the string value for the given `key`, or `undefined` if it does not exist.\n\t *\n\t * @default undefined\n\t */\n\tgetString: (key: StringKeys<Schema>) => string | undefined\n\t/**\n\t * Get the number value for the given `key`, or `undefined` if it does not exist.\n\t *\n\t * @default undefined\n\t */\n\tgetNumber: (key: NumberKeys<Schema>) => number | undefined\n\t/**\n\t * Get a raw buffer of unsigned 8-bit (0-255) data.\n\t *\n\t * @default undefined\n\t */\n\tgetBuffer: (key: BufferKeys<Schema>) => ArrayBufferLike | undefined\n\t/**\n\t * Checks whether the given `key` is being stored in this MMKV instance.\n\t */\n\tcontains: (key: StorageKey) => boolean\n\t/**\n\t * Delete the given `key`.\n\t */\n\tremove: (key: StorageKey) => void\n\t/**\n\t * Get all keys.\n\t *\n\t * @default []\n\t */\n\tgetAllKeys: () => string[]\n\t/**\n\t * Delete all keys.\n\t */\n\tclearAll: () => void\n\t/**\n\t * Sets (or updates) the encryption-key to encrypt all data in this MMKV instance with.\n\t *\n\t * To remove encryption, pass `undefined` as a key.\n\t *\n\t * Encryption keys can have a maximum length of 16 bytes.\n\t *\n\t * @throws an Error if the instance cannot be recrypted.\n\t */\n\trecrypt: (key: string | undefined) => void\n\t/**\n\t * Trims the storage space and clears memory cache.\n\t *\n\t * Since MMKV does not resize itself after deleting keys, you can call `trim()`\n\t * after deleting a bunch of keys to manually trim the memory- and\n\t * disk-file to reduce storage and memory usage.\n\t *\n\t * In most applications, this is not needed at all.\n\t */\n\ttrim(): void\n\t/**\n\t * Get the current total size of the storage, in bytes.\n\t */\n\treadonly size: number\n\t/**\n\t * Returns whether this instance is in read-only mode or not.\n\t * If this is `true`, you can only use \"get\"-functions.\n\t */\n\treadonly isReadOnly: boolean\n}\n\nexport interface Listener {\n\tremove: () => void\n}\n\nexport interface TypedMMKVInterface extends TypedNativeMMKV<AppStorageSchema> {\n\t/**\n\t * Adds a value changed listener. The Listener will be called whenever any value\n\t * in this storage instance changes (set or delete).\n\t *\n\t * To unsubscribe from value changes, call `remove()` on the Listener.\n\t */\n\taddOnValueChangedListener: (\n\t\tonValueChanged: (key: StorageKey) => void,\n\t) => Listener\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/__mocks__/log.ts",
    "content": "export const toastAndLogError = jest.fn()\n\nconst mockLogError = jest.fn()\n\nconst logger = {\n\textend: jest.fn(() => ({\n\t\terror: mockLogError,\n\t\tdebug: jest.fn(),\n\t\tinfo: jest.fn(),\n\t\twarning: jest.fn(),\n\t})),\n\terror: mockLogError,\n\tdebug: jest.fn(),\n\tinfo: jest.fn(),\n\twarning: jest.fn(),\n}\n\nexport default logger\n"
  },
  {
    "path": "apps/mobile/src/utils/__tests__/set.test.ts",
    "content": "import { diffSets } from '@/utils/set'\n\ndescribe('set utils', () => {\n\tdescribe('diffSets', () => {\n\t\tit('应该识别新增元素', () => {\n\t\t\tconst source = new Set([1, 2])\n\t\t\tconst target = new Set([1, 2, 3])\n\t\t\tconst { added, removed } = diffSets(source, target)\n\t\t\texpect(added).toEqual(new Set([3]))\n\t\t\texpect(removed).toEqual(new Set())\n\t\t})\n\n\t\tit('应该识别删除元素', () => {\n\t\t\tconst source = new Set([1, 2, 3])\n\t\t\tconst target = new Set([1, 2])\n\t\t\tconst { added, removed } = diffSets(source, target)\n\t\t\texpect(added).toEqual(new Set())\n\t\t\texpect(removed).toEqual(new Set([3]))\n\t\t})\n\n\t\tit('应该识别新增和删除元素', () => {\n\t\t\tconst source = new Set([1, 2])\n\t\t\tconst target = new Set([1, 3])\n\t\t\tconst { added, removed } = diffSets(source, target)\n\t\t\texpect(added).toEqual(new Set([3]))\n\t\t\texpect(removed).toEqual(new Set([2]))\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "apps/mobile/src/utils/__tests__/sticky-mitt.test.ts",
    "content": "jest.mock('../log')\n\nimport createStickyEmitter from '@/utils/sticky-mitt'\n\ninterface Events {\n\t[key: string]: unknown\n\tfoo: string\n\tbar: number\n}\n\ndescribe('createStickyEmitter', () => {\n\tit('应该可以处理基本的事件发射和监听', () => {\n\t\tconst emitter = createStickyEmitter<Events>()\n\t\tconst handler = jest.fn()\n\n\t\temitter.on('foo', handler)\n\t\temitter.emit('foo', 'test')\n\n\t\texpect(handler).toHaveBeenCalledWith('test')\n\t\texpect(handler).toHaveBeenCalledTimes(1)\n\t})\n\n\tit('应该在监听器执行前立即调用一次 sticky 值', () => {\n\t\tconst emitter = createStickyEmitter<Events>()\n\t\tconst handler = jest.fn()\n\n\t\temitter.emitSticky('foo', 'sticky-test')\n\t\temitter.on('foo', handler)\n\n\t\texpect(handler).toHaveBeenCalledWith('sticky-test')\n\t\texpect(handler).toHaveBeenCalledTimes(1)\n\t})\n\n\tit('应该在后续 emitSticky 调用时更新 sticky 值', () => {\n\t\tconst emitter = createStickyEmitter<Events>()\n\t\tconst handler1 = jest.fn()\n\t\tconst handler2 = jest.fn()\n\n\t\temitter.emitSticky('foo', 'first')\n\t\temitter.on('foo', handler1)\n\n\t\texpect(handler1).toHaveBeenCalledWith('first')\n\t\texpect(handler1).toHaveBeenCalledTimes(1)\n\n\t\temitter.emitSticky('foo', 'second')\n\t\texpect(handler1).toHaveBeenCalledWith('second')\n\t\texpect(handler1).toHaveBeenCalledTimes(2)\n\n\t\temitter.on('foo', handler2)\n\t\texpect(handler2).toHaveBeenCalledWith('second')\n\t\texpect(handler2).toHaveBeenCalledTimes(1)\n\t})\n\n\tit('应该能清除特定的 sticky 事件', () => {\n\t\tconst emitter = createStickyEmitter<Events>()\n\t\tconst handler = jest.fn()\n\n\t\temitter.emitSticky('foo', 'sticky-test')\n\t\temitter.clearSticky('foo')\n\t\temitter.on('foo', handler)\n\n\t\texpect(handler).not.toHaveBeenCalled()\n\t})\n\n\tit('应该能清除所有 sticky 事件', () => {\n\t\tconst emitter = createStickyEmitter<Events>()\n\t\tconst fooHandler = jest.fn()\n\t\tconst barHandler = jest.fn()\n\n\t\temitter.emitSticky('foo', 'sticky-foo')\n\t\temitter.emitSticky('bar', 123)\n\t\temitter.clearAllSticky()\n\n\t\temitter.on('foo', fooHandler)\n\t\temitter.on('bar', barHandler)\n\n\t\texpect(fooHandler).not.toHaveBeenCalled()\n\t\texpect(barHandler).not.toHaveBeenCalled()\n\t})\n})\n"
  },
  {
    "path": "apps/mobile/src/utils/__tests__/time.test.ts",
    "content": "import { formatDurationToHHMMSS, formatMMSSToSeconds } from '@/utils/time'\n\ndescribe('time utils', () => {\n\tdescribe('formatDurationToHHMMSS', () => {\n\t\tit('应该格式化秒数为 MM:SS 格式', () => {\n\t\t\texpect(formatDurationToHHMMSS(59)).toBe('00:59')\n\t\t\texpect(formatDurationToHHMMSS(60)).toBe('01:00')\n\t\t\texpect(formatDurationToHHMMSS(119)).toBe('01:59')\n\t\t})\n\n\t\tit('应该格式化秒数为 HH:MM:SS 格式', () => {\n\t\t\texpect(formatDurationToHHMMSS(3599)).toBe('59:59')\n\t\t\texpect(formatDurationToHHMMSS(3600)).toBe('01:00:00')\n\t\t\texpect(formatDurationToHHMMSS(3661)).toBe('01:01:01')\n\t\t})\n\t})\n\n\tdescribe('formatMMSSToSeconds', () => {\n\t\tit('应该格式化 MM:SS 格式为秒数', () => {\n\t\t\texpect(formatMMSSToSeconds('00:59')).toBe(59)\n\t\t\texpect(formatMMSSToSeconds('01:00')).toBe(60)\n\t\t\texpect(formatMMSSToSeconds('01:59')).toBe(119)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "apps/mobile/src/utils/color.ts",
    "content": "interface RGBColor {\n\tr: number\n\tg: number\n\tb: number\n}\n\nconst hue2rgb = (p: number, q: number, t: number): number => {\n\tif (t < 0) t += 1\n\tif (t > 1) t -= 1\n\tif (t < 1 / 6) return p + (q - p) * 6 * t\n\tif (t < 1 / 2) return q\n\tif (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6\n\treturn p\n}\n\n/**\n * HSL 颜色值转换为 RGB.\n * h (色相) 范围 [0, 360]\n * s (饱和度) 范围 [0, 1]\n * l (亮度) 范围 [0, 1]\n * @returns {RGBColor} 范围 [0, 255]\n */\nfunction hslToRgb(h: number, s: number, l: number): RGBColor {\n\tlet r: number, g: number, b: number\n\n\tif (s === 0) {\n\t\tr = g = b = l\n\t} else {\n\t\tconst q: number = l < 0.5 ? l * (1 + s) : l + s - l * s\n\t\tconst p: number = 2 * l - q\n\t\tconst h_normalized: number = h / 360 // h 要归一化到 [0, 1]\n\n\t\tr = hue2rgb(p, q, h_normalized + 1 / 3)\n\t\tg = hue2rgb(p, q, h_normalized)\n\t\tb = hue2rgb(p, q, h_normalized - 1 / 3)\n\t}\n\n\treturn {\n\t\tr: Math.round(r * 255),\n\t\tg: Math.round(g * 255),\n\t\tb: Math.round(b * 255),\n\t}\n}\n\n/**\n * 将字符串转换为一个32位整数哈希值\n */\nfunction stringToHashCode(str: string): number {\n\tlet hash = 0\n\tif (str.length === 0) return hash\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char: number = str.charCodeAt(i) // charCodeAt 返回的就是 number\n\t\thash = (hash << 5) - hash + char\n\t\thash = hash & hash // 转换为32位整数\n\t}\n\treturn hash\n}\n\n/**\n * 最终的渐变色结果类型\n */\nexport interface GradientColors {\n\tcolor1: string\n\tcolor2: string\n}\n\n/**\n * 基于字符串生成一对渐变颜色，并自动根据是否为暗黑模式返回不同的颜色\n * @param name 字符串\n * @param isDarkMode 是否为暗黑模式\n * @returns {GradientColors} 两个 rgba 字符串\n */\nexport function getGradientColors(name: string, isDarkMode: boolean) {\n\tlet saturation: number, lightness: number, lightness2: number\n\n\tif (isDarkMode) {\n\t\tsaturation = 0.55\n\t\tlightness = 0.4\n\t\tlightness2 = 0.35\n\t} else {\n\t\tsaturation = 0.7\n\t\tlightness = 0.65\n\t\tlightness2 = 0.6\n\t}\n\n\tconst hash: number = stringToHashCode(name)\n\tconst baseHue: number = Math.abs(hash) % 360\n\tconst secondHue: number = (baseHue + 40) % 360 // 偏移40度\n\n\tconst rgb1: RGBColor = hslToRgb(baseHue, saturation, lightness)\n\tconst rgb2: RGBColor = hslToRgb(secondHue, saturation, lightness2)\n\n\tconst color1 = `rgba(${rgb1.r}, ${rgb1.g}, ${rgb1.b}, 1)`\n\tconst color2 = `rgba(${rgb2.r}, ${rgb2.g}, ${rgb2.b}, 1)`\n\n\treturn { color1, color2 }\n}\n\nexport function hexToHsl(hex: string): { h: number; s: number; l: number } {\n\tconst cleanHex = hex.replace(/^#/, '')\n\n\tconst r = parseInt(cleanHex.substring(0, 2), 16) / 255\n\tconst g = parseInt(cleanHex.substring(2, 4), 16) / 255\n\tconst b = parseInt(cleanHex.substring(4, 6), 16) / 255\n\n\tconst max = Math.max(r, g, b)\n\tconst min = Math.min(r, g, b)\n\tconst l = (max + min) / 2\n\n\tlet h = 0\n\tlet s = 0\n\n\tif (max !== min) {\n\t\tconst d = max - min\n\t\ts = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n\n\t\tswitch (max) {\n\t\t\tcase r:\n\t\t\t\th = ((g - b) / d + (g < b ? 6 : 0)) / 6\n\t\t\t\tbreak\n\t\t\tcase g:\n\t\t\t\th = ((b - r) / d + 2) / 6\n\t\t\t\tbreak\n\t\t\tcase b:\n\t\t\t\th = ((r - g) / d + 4) / 6\n\t\t\t\tbreak\n\t\t}\n\t}\n\n\treturn {\n\t\th: Math.round(h * 360),\n\t\ts: Math.round(s * 100),\n\t\tl: Math.round(l * 100),\n\t}\n}\n\nexport function hslToString(h: number, s: number, l: number): string {\n\treturn `hsl(${h}, ${s}%, ${l}%)`\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/danmaku.ts",
    "content": "import type { BilibiliDanmakuItem } from '@/types/apis/bilibili'\n\n/**\n * 清理弹幕数据\n * @param danmakus 原始弹幕数据\n * @param filterWeight 过滤权重阈值\n * @param maxNumPerSecond 每秒最大弹幕数\n * @returns 清理后的弹幕数据\n */\nexport function cleanDanmaku(\n\tdanmakus: BilibiliDanmakuItem[],\n\tfilterWeight: number,\n\tmaxNumPerSecond?: number,\n) {\n\tconst filteredDanmakus = danmakus.filter((d) => {\n\t\tconst w = d.weight ?? 10\n\t\treturn w >= filterWeight && d.progress !== undefined && d.progress !== null\n\t})\n\n\tconst sortedByWeight = [...filteredDanmakus].sort((a, b) => {\n\t\tconst wa = a.weight ?? 10\n\t\tconst wb = b.weight ?? 10\n\t\treturn wb - wa\n\t})\n\n\tif (maxNumPerSecond === undefined) {\n\t\treturn sortedByWeight.sort((a, b) => {\n\t\t\treturn a.progress - b.progress\n\t\t})\n\t}\n\n\tconst countMap = new Map<number, number>()\n\tconst result: BilibiliDanmakuItem[] = []\n\n\tfor (const dm of sortedByWeight) {\n\t\tconst second = Math.floor(dm.progress / 1000)\n\t\tconst currentCount = countMap.get(second) ?? 0\n\n\t\tif (currentCount < maxNumPerSecond) {\n\t\t\tresult.push(dm)\n\t\t\tcountMap.set(second, currentCount + 1)\n\t\t}\n\t}\n\n\treturn result.sort((a, b) => {\n\t\treturn a.progress - b.progress\n\t})\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/error-handling.ts",
    "content": "import { CustomError } from '@/lib/errors'\n\nimport log, { flatErrorMessage } from './log'\nimport toast from './toast'\n\n/**\n * 将错误消息和错误堆栈信息显示在 toast 上，并将错误信息记录到日志中（用于最顶端的调用者消费错误）\n * @param error 原始错误对象\n * @param message 需要显示的信息\n * @param scope 日志作用域\n */\nexport function toastAndLogError(\n\tmessage: string,\n\terror: unknown,\n\tscope: string,\n) {\n\tif (error instanceof CustomError) {\n\t\ttoast.error(`${message} -- ${error.type}`, {\n\t\t\tdescription: flatErrorMessage(error),\n\t\t\tduration: Number.POSITIVE_INFINITY,\n\t\t})\n\t\tlog\n\t\t\t.extend(scope)\n\t\t\t.error(`${message} -- ${error.type}: ${flatErrorMessage(error)}`)\n\t} else if (error instanceof Error) {\n\t\ttoast.error(message, {\n\t\t\tdescription: flatErrorMessage(error),\n\t\t\tduration: Number.POSITIVE_INFINITY,\n\t\t})\n\t\tlog.extend(scope).error(`${message}: ${flatErrorMessage(error)}`)\n\t} else if (error === undefined) {\n\t\ttoast.error(message, {\n\t\t\tduration: Number.POSITIVE_INFINITY,\n\t\t})\n\t} else {\n\t\ttoast.error(message, {\n\t\t\tdescription: String(error as unknown),\n\t\t\tduration: Number.POSITIVE_INFINITY,\n\t\t})\n\t\tlog.extend(scope).error(message, error)\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/haptics.ts",
    "content": "import * as ExpoHaptics from 'expo-haptics'\nimport { Platform } from 'react-native'\n\nimport { reportErrorToSentry } from './log'\n\nlet hapticsSupported = true\n\nexport const AndroidHaptics = ExpoHaptics.AndroidHaptics\n\n/**\n * Platform-agnostic haptics function.\n * On Android, it calls the specific Android haptic type.\n * On iOS, it maps the Android hint to the closest iOS equivalent.\n */\nexport const performHaptics = async (\n\ttype: ExpoHaptics.AndroidHaptics,\n): Promise<void> => {\n\tif (!hapticsSupported) return\n\n\ttry {\n\t\tif (Platform.OS === 'android') {\n\t\t\tawait ExpoHaptics.performAndroidHapticsAsync(type)\n\t\t} else {\n\t\t\t// iOS Mapping\n\t\t\tswitch (type) {\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Context_Click:\n\t\t\t\t\tawait ExpoHaptics.selectionAsync()\n\t\t\t\t\tbreak\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Confirm:\n\t\t\t\t\tawait ExpoHaptics.notificationAsync(\n\t\t\t\t\t\tExpoHaptics.NotificationFeedbackType.Success,\n\t\t\t\t\t)\n\t\t\t\t\tbreak\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Reject:\n\t\t\t\t\tawait ExpoHaptics.notificationAsync(\n\t\t\t\t\t\tExpoHaptics.NotificationFeedbackType.Error,\n\t\t\t\t\t)\n\t\t\t\t\tbreak\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Drag_Start:\n\t\t\t\t\tawait ExpoHaptics.impactAsync(ExpoHaptics.ImpactFeedbackStyle.Light)\n\t\t\t\t\tbreak\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Gesture_End:\n\t\t\t\t\tawait ExpoHaptics.impactAsync(ExpoHaptics.ImpactFeedbackStyle.Medium)\n\t\t\t\t\tbreak\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Clock_Tick:\n\t\t\t\t\tawait ExpoHaptics.selectionAsync()\n\t\t\t\t\tbreak\n\t\t\t\tcase ExpoHaptics.AndroidHaptics.Long_Press:\n\t\t\t\t\tawait ExpoHaptics.impactAsync(ExpoHaptics.ImpactFeedbackStyle.Heavy)\n\t\t\t\t\tbreak\n\t\t\t\tdefault:\n\t\t\t\t\t// Default fallback for other Android specific haptics on iOS\n\t\t\t\t\tawait ExpoHaptics.selectionAsync()\n\t\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t} catch (e) {\n\t\tif (e instanceof Error && e.message.includes('is not available')) {\n\t\t\thapticsSupported = false\n\t\t\treturn\n\t\t}\n\t\t// On iOS, we might want to suppress errors or log them differently,\n\t\t// but sticking to the existing pattern is fine.\n\t\treportErrorToSentry(e, 'performHaptics 出错', 'Utils.Haptics')\n\t}\n}\n\n/**\n * @deprecated Use performHaptics instead\n */\nexport const performAndroidHapticsAsync = performHaptics\n"
  },
  {
    "path": "apps/mobile/src/utils/log.ts",
    "content": "import type { transportFunctionType } from '@bbplayer/logs'\nimport { fileAsyncTransport, logger, mapConsoleTransport } from '@bbplayer/logs'\nimport * as Sentry from '@sentry/react-native'\nimport * as EXPOFS from 'expo-file-system'\nimport { err, ok, type Result } from 'neverthrow'\n\nimport { CustomError } from '@/lib/errors'\nimport type { ProjectScope } from '@/types/core/scope'\n\nconst isDev = __DEV__\n\nconst sentryBreadcrumbTransport: transportFunctionType<object> = (props) => {\n\tSentry.addBreadcrumb({\n\t\tcategory: 'log',\n\t\tlevel: props.level.text as Sentry.SeverityLevel,\n\t\tmessage: props.msg,\n\t})\n}\n\n// 创建 Logger 实例\nconst config = {\n\tseverity: isDev ? 'debug' : 'info',\n\ttransport: isDev\n\t\t? [mapConsoleTransport, fileAsyncTransport]\n\t\t: [sentryBreadcrumbTransport, fileAsyncTransport],\n\tlevels: {\n\t\tdebug: 0,\n\t\tinfo: 1,\n\t\twarning: 2,\n\t\terror: 3,\n\t},\n\ttransportOptions: {\n\t\tFS: EXPOFS,\n\t\tfileName: '{date-today}.log',\n\t\t// 日期命名格式 YYYY-M-D（**无零填充**）\n\t\tfileNameDateType: 'iso' as const,\n\t\tfilePath: `${EXPOFS.Paths.document.uri}logs`,\n\t\tmapLevels: {\n\t\t\tdebug: 'log',\n\t\t\tinfo: 'info',\n\t\t\twarning: 'warn',\n\t\t\terror: 'error',\n\t\t},\n\t},\n\tasyncFunc: setImmediate,\n\tasync: true,\n}\n\n/**\n * 清理 {keepDays} 天之前的日志文件\n * @param keepDays 保留最近几天的日志，默认为 7 天\n */\nexport function cleanOldLogFiles(keepDays = 7): Result<number, Error> {\n\ttry {\n\t\tconst logDir = new EXPOFS.Directory(EXPOFS.Paths.document, 'logs')\n\n\t\tif (!logDir.exists) {\n\t\t\tlog.debug('日志目录不存在，无需清理')\n\t\t\treturn ok(0)\n\t\t}\n\n\t\tconst list = logDir\n\t\t\t.list()\n\t\t\t.filter((f) => f instanceof EXPOFS.File)\n\t\t\t.map((f) => f.name)\n\n\t\tconst cutoffDate = new Date()\n\t\tcutoffDate.setHours(0, 0, 0, 0)\n\t\tcutoffDate.setDate(cutoffDate.getDate() - keepDays + 1)\n\n\t\tconst re = /^(\\d{4}-\\d{1,2}-\\d{1,2})\\.log$/\n\n\t\tlet deleted = 0\n\t\tfor (const name of list) {\n\t\t\tconst m = re.exec(name)\n\t\t\tif (!m) continue\n\n\t\t\tconst fileDate = new Date(m[1])\n\t\t\tif (Number.isNaN(fileDate.getTime())) continue\n\n\t\t\tif (fileDate < cutoffDate) {\n\t\t\t\tconst file = new EXPOFS.File(logDir, name)\n\t\t\t\ttry {\n\t\t\t\t\tfile.delete()\n\t\t\t\t\tdeleted += 1\n\t\t\t\t} catch (e) {\n\t\t\t\t\tlog.warning('删除旧日志文件失败', {\n\t\t\t\t\t\tfile: file.uri,\n\t\t\t\t\t\terror: String(e),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn ok(deleted)\n\t} catch (e) {\n\t\treturn err(e instanceof Error ? e : new Error(String(e)))\n\t}\n}\n\n/**\n * 将 Error 对象的 message、cause 递归展开为字符串，类似于 golang 的错误链\n * @param error 任何 Error 的子类\n * @param separator 分隔符\n * @param maxDepth 最大递归深度\n * @returns 一个用 separator 拼接的字符串\n */\n\nexport function flatErrorMessage(\n\terror: Error,\n\tseparator = ':: ',\n\t_temp: string[] = [],\n\t_depth = 0,\n\tmaxDepth = 10,\n) {\n\t_temp.push(error.message)\n\tif (_depth >= maxDepth) {\n\t\t_temp.push('[error depth exceeded]')\n\t\treturn _temp.join(separator)\n\t}\n\tif (error.cause) {\n\t\tif (error.cause instanceof Error) {\n\t\t\tflatErrorMessage(error.cause, separator, _temp, _depth + 1)\n\t\t}\n\t}\n\treturn _temp.join(separator)\n}\n\n/**\n * 将 Error 上报到 Sentry\n * @param error\n * @param scope 项目不同分区\n * @param message 附加信息\n */\n\nconst stringifyError = (e: unknown): string => {\n\tif (e === null) return 'null'\n\t// oxlint-disable-next-line @typescript-eslint/no-base-to-string\n\tif (typeof e !== 'object') return String(e)\n\ttry {\n\t\treturn JSON.stringify(e)\n\t} catch {\n\t\t// Circular reference or other stringify error\n\t\treturn Object.prototype.toString.call(e)\n\t}\n}\n\nexport function reportErrorToSentry(\n\terror: unknown,\n\tmessage?: string,\n\tscope?: ProjectScope | string,\n) {\n\tconst _error =\n\t\terror instanceof Error\n\t\t\t? error\n\t\t\t: new Error(`非 Error 类型错误：${stringifyError(error)}`, {\n\t\t\t\t\tcause: error,\n\t\t\t\t})\n\n\tconst isCustom = _error instanceof CustomError\n\n\tconst tags: Record<string, string | number | boolean | undefined> = {\n\t\tappScope: scope,\n\t}\n\tif (isCustom && typeof _error.type === 'string') {\n\t\ttags.errorType = _error.type\n\t}\n\n\tconst extra: Record<string, unknown> = { message }\n\tif (isCustom && _error.data !== undefined) {\n\t\textra.errorData = _error.data\n\t}\n\n\tconst id = Sentry.captureException(_error, { tags, extra })\n\tlog.error(`已上报错误到 sentry，id: ${id}`)\n}\n\ntry {\n\tnew EXPOFS.Directory(EXPOFS.Paths.document, 'logs').create({\n\t\tintermediates: true,\n\t\tidempotent: true,\n\t})\n} catch {}\nconst log = logger.createLogger(config)\n\nexport default log\n"
  },
  {
    "path": "apps/mobile/src/utils/lottie.ts",
    "content": "import { type AnimationObject } from 'lottie-react-native'\n\n/**\n * 将十六进制颜色替换 Lottie JSON 中的白色占位符 [1,1,1,1]。\n */\nexport function tintLottieSource(\n\tsource: AnimationObject,\n\thexColor: string,\n): AnimationObject {\n\tconst hex = hexColor.replace('#', '')\n\tconst r = (parseInt(hex.slice(0, 2), 16) / 255).toFixed(4)\n\tconst g = (parseInt(hex.slice(2, 4), 16) / 255).toFixed(4)\n\tconst b = (parseInt(hex.slice(4, 6), 16) / 255).toFixed(4)\n\ttry {\n\t\treturn JSON.parse(\n\t\t\tJSON.stringify(source).replace(/\\[1,1,1,1\\]/g, `[${r},${g},${b},1]`),\n\t\t) as AnimationObject\n\t} catch {\n\t\treturn source\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/matching.ts",
    "content": "/**\n * Gaussian function used for scoring\n * @param x The difference value\n * @param sigma The standard deviation\n * @returns A value between 0 and 1\n */\nexport function gaussian(x: number, sigma: number): number {\n\treturn Math.exp(-(x * x) / (2 * sigma * sigma))\n}\n\n/**\n * Calculate the length of the Longest Common Subsequence between two strings\n * @param s1 First string\n * @param s2 Second string\n * @returns Length of LCS\n */\nexport function lcs(s1: string, s2: string): number {\n\tconst m = s1.length\n\tconst n = s2.length\n\tconst dp: number[][] = Array.from({ length: m + 1 }, (): number[] =>\n\t\tnew Array<number>(n + 1).fill(0),\n\t)\n\n\tfor (let i = 1; i <= m; i++) {\n\t\tfor (let j = 1; j <= n; j++) {\n\t\t\tif (s1[i - 1] === s2[j - 1]) {\n\t\t\t\tdp[i][j] = dp[i - 1][j - 1] + 1\n\t\t\t} else {\n\t\t\t\tdp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])\n\t\t\t}\n\t\t}\n\t}\n\n\treturn dp[m][n]\n}\n\n/**\n * Calculate a normalized LCS score (0 to 1)\n * @param s1 First string\n * @param s2 Second string\n * @returns Score relative to the longer string length\n */\nexport function lcsScore(s1: string, s2: string): number {\n\tif (s1.length === 0 || s2.length === 0) return 0\n\tconst lcsLen = lcs(s1, s2)\n\t// Use max length to penalize length mismatches\n\treturn lcsLen / Math.max(s1.length, s2.length)\n}\n\n/**\n * Clean string for matching (remove non-alphanumeric, keep Chinese)\n * @param str Input string\n * @returns Cleaned string\n */\nexport function cleanString(str: string): string {\n\treturn str.toLowerCase().replace(/[^\\w\\u4e00-\\u9fa5]/g, '') // Keep alphanumeric and Chinese\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/mmkv.ts",
    "content": "import { createMMKV } from 'react-native-mmkv'\nimport type { StateStorage } from 'zustand/middleware/persist'\n\nimport type { TypedMMKVInterface } from '@/types/storage'\n\nconst mmkv = createMMKV()\n\nexport const storage = mmkv as unknown as TypedMMKVInterface\n\nexport const zustandStorage: StateStorage = {\n\tsetItem: (name, value) => {\n\t\t// @ts-expect-error -- 管不了 zustand 的类型定义\n\t\treturn storage.set(name, value)\n\t},\n\tgetItem: (name) => {\n\t\t// @ts-expect-error -- 管不了 zustand 的类型定义\n\t\tconst value = storage.getString(name)\n\t\treturn value ?? null\n\t},\n\tremoveItem: (name) => {\n\t\t// @ts-expect-error -- 管不了 zustand 的类型定义\n\t\treturn storage.remove(name)\n\t},\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/network.ts",
    "content": "import { NetInfoState, NetInfoStateType } from '@react-native-community/netinfo'\n\n/**\n * 判断当前是否处于真正的离线状态。\n *\n * 针对 NetInfo 的 isConnected 在连接 VPN 时可能出现假阳性（isConnected 为 true 但无互联网）的问题，\n * 采用以下策略：\n * 1. 如果 isConnected 为 false，则判定为离线。\n * 2. 如果 isConnected 为 true：\n *    - 如果是 wifi 或 cellular 类型，判定为在线（忽略 isInternetReachable 的假阴性）。\n *    - 如果是其他类型（如 vpn, ethernet 等），检查 isInternetReachable。\n *      如果 isInternetReachable 为 false，则判定为离线。\n */\nexport const isActuallyOffline = (state: NetInfoState): boolean => {\n\tif (state.isConnected === false) {\n\t\treturn true\n\t}\n\n\tif (\n\t\tstate.type === NetInfoStateType.wifi ||\n\t\tstate.type === NetInfoStateType.cellular\n\t) {\n\t\treturn false\n\t}\n\n\t// 对于 VPN 等其他类型，使用 isInternetReachable 判断\n\t// 如果 isInternetReachable 为 null，说明还在检测中，暂不判定为离线\n\treturn state.isInternetReachable === false\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/neverthrow-utils.ts",
    "content": "import { type Result, ResultAsync } from 'neverthrow'\n\n/**\n * 运行 ResultAsync 并返回 Ok 或抛出错误（注意，当返回内容为 undefined 时也会抛出错误）\n * @param resultAsync The ResultAsync instance from the API call.\n * @returns Promise<T> which resolves with value T or rejects with error E.\n */\nexport async function returnOrThrowAsync<T, E>(\n\tresultAsync: ResultAsync<T, E> | Promise<Result<T, E>>,\n): Promise<Exclude<T, undefined | null>> {\n\tconst result = await resultAsync\n\tif (result.isOk()) {\n\t\tconst value = result.value\n\t\tif (value === undefined || value === null) {\n\t\t\tthrow new Error('Result is undefined')\n\t\t}\n\t\treturn value as Exclude<T, undefined | null>\n\t}\n\t// oxlint-disable-next-line @typescript-eslint/only-throw-error\n\tthrow result.error\n}\n\n/**\n * Convert a function like `(...args: A) => Promise<Result<T, E>>` into `(...args: A) => ResultAsync<T, E>`.\n *\n * Similarly to the warnings at https://github.com/supermacro/neverthrow#resultasyncfromsafepromise-static-class-method\n *\n * you must ensure that `func` will never reject.\n */\nexport function wrapResultAsyncFunction<A extends unknown[], T, E>(\n\tfunc: (...args: A) => Promise<Result<T, E>>,\n): (...args: A) => ResultAsync<T, E> {\n\treturn (...args): ResultAsync<T, E> => new ResultAsync(func(...args))\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/player.ts",
    "content": "import { Orpheus, type Track as OrpheusTrack } from '@bbplayer/orpheus'\nimport type { Result } from 'neverthrow'\nimport { err, ok } from 'neverthrow'\n\nimport { trackKeys } from '@/hooks/queries/db/track'\nimport useAppStore from '@/hooks/stores/useAppStore'\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { queryClient } from '@/lib/config/queryClient'\nimport type { PlayerError } from '@/lib/errors/player'\nimport { createPlayerError } from '@/lib/errors/player'\nimport type { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili'\nimport { trackService } from '@/lib/services/trackService'\nimport type { Track } from '@/types/core/media'\n\nimport { toastAndLogError } from './error-handling'\nimport log, { flatErrorMessage } from './log'\n\nconst logger = log.extend('Utils.Player')\n\n/**\n * 将内部 Track 类型转换为 Orpheus 的 Track 类型。\n * @param track - 内部 Track 对象。\n * @returns 一个 Result 对象，成功时包含 OrpheusTrack，失败时包含 Error。\n */\nfunction convertToOrpheusTrack(\n\ttrack: Track,\n): Result<OrpheusTrack, BilibiliApiError | PlayerError> {\n\t// logger.debug('转换 Track 为 OrpheusTrack', {\n\t// \ttrackId: track.id,\n\t// \ttitle: track.title,\n\t// \tartist: track.artist,\n\t// })\n\n\tconst url = getInternalPlayUri(track)\n\n\t// 如果没有有效的 URL，返回错误\n\tif (!url) {\n\t\tconst errorMsg = '没有找到有效的音频流 URL'\n\t\tlogger.warning(errorMsg, track)\n\t\treturn err(\n\t\t\tcreatePlayerError('AudioUrlNotFound', `${errorMsg}: ${track.id}`),\n\t\t)\n\t}\n\n\tconst orpheusTrack: OrpheusTrack = {\n\t\tid: track.uniqueKey,\n\t\turl,\n\t\ttitle: track.title,\n\t\tartist: track.artist?.name,\n\t\tartwork: track.coverUrl ?? undefined,\n\t\tduration: track.duration,\n\t}\n\n\t// logger.debug('OrpheusTrack 转换完成', {\n\t// \ttitle: orpheusTrack.title,\n\t// \tid: orpheusTrack.id,\n\t// })\n\treturn ok(orpheusTrack)\n}\n\n/**\n * 上报播放记录\n * 由于这只是一个非常边缘的功能，我们不关心他是否出错，所以发生报错时只写个 log，返回 void\n */\nasync function reportPlaybackHistory(\n\tuniqueKey: string,\n\tposition: number,\n): Promise<void> {\n\tif (!useAppStore.getState().settings.sendPlayHistory) return\n\tif (!useAppStore.getState().hasBilibiliCookie()) return\n\tconst trackResult = await trackService.getTrackByUniqueKey(uniqueKey)\n\tif (trackResult.isErr()) {\n\t\ttoastAndLogError('查询 track 失败：', trackResult.error, 'Utils.Player')\n\t\treturn\n\t}\n\tconst track = trackResult.value\n\tif (track.source !== 'bilibili') {\n\t\treturn\n\t}\n\tlet cid = track.bilibiliMetadata.cid\n\tif (!cid && !track.bilibiliMetadata.isMultiPage) {\n\t\tconst videoPageResult = await bilibiliApi.getPageList(\n\t\t\ttrack.bilibiliMetadata.bvid,\n\t\t)\n\t\tif (videoPageResult.isErr()) {\n\t\t\ttoastAndLogError(\n\t\t\t\t'查询视频信息失败：',\n\t\t\t\tvideoPageResult.error,\n\t\t\t\t'Utils.Player',\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tif (videoPageResult.value.length === 0) {\n\t\t\tlogger.warning('视频无分 p 信息，无法上报播放记录', {\n\t\t\t\tbvid: track.bilibiliMetadata.bvid,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tcid = videoPageResult.value[0].cid\n\t} else if (track.bilibiliMetadata.isMultiPage && !cid) {\n\t\tlogger.warning('多 p 视频无法上报播放记录，不存在 cid', {\n\t\t\tbvid: track.bilibiliMetadata.bvid,\n\t\t})\n\t\treturn\n\t}\n\tlogger.debug('上报播放记录', {\n\t\tbvid: track.bilibiliMetadata.bvid,\n\t\tcid,\n\t\tposition,\n\t})\n\tconst result = await bilibiliApi.reportPlaybackHistory(\n\t\ttrack.bilibiliMetadata.bvid,\n\t\tcid!,\n\t\tposition,\n\t)\n\tif (result.isErr()) {\n\t\tlogger.warning('上报播放记录到 bilibili 失败', {\n\t\t\tparams: {\n\t\t\t\tbvid: track.bilibiliMetadata.bvid,\n\t\t\t\tcid,\n\t\t\t},\n\t\t\terror: result.error,\n\t\t})\n\t}\n\treturn\n}\n\n/**\n *\n * @param playNow 是否立即播放\n * @param clearQueue 是否清空队列\n * @param startFromKey 从指定的 key 开始播放（并立即开始播放，无视 playNow）\n * @param playNext 是否插入到下一首播放\n * @returns\n */\nasync function addToQueue({\n\ttracks,\n\tplayNow,\n\tclearQueue,\n\tstartFromKey,\n\tplayNext,\n}: {\n\ttracks: Track[]\n\tplayNow: boolean\n\tclearQueue: boolean\n\tstartFromKey?: string\n\tplayNext: boolean\n}) {\n\tif (!tracks || tracks.length === 0) {\n\t\treturn\n\t}\n\tif (playNext && tracks.length > 1) {\n\t\ttoastAndLogError(\n\t\t\t'AddToQueueError',\n\t\t\t'只能将单曲插入到下一首播放，已取消本次操作。',\n\t\t\t'Utils.Player',\n\t\t)\n\t\treturn\n\t}\n\tlogger.debug('添加曲目到播放队列', {\n\t\ttrackCount: tracks.length,\n\t\tplayNow,\n\t\tclearQueue,\n\t\tstartFromKey,\n\t\tplayNext,\n\t})\n\n\ttry {\n\t\tconst orpheusTracks: OrpheusTrack[] = []\n\t\tfor (const track of tracks) {\n\t\t\tconst result = convertToOrpheusTrack(track)\n\t\t\tif (result.isOk()) {\n\t\t\t\torpheusTracks.push(result.value)\n\t\t\t} else {\n\t\t\t\tlogger.error('转换为 OrpheusTrack 失败，跳过该曲目', {\n\t\t\t\t\ttrackId: track.id,\n\t\t\t\t\terror: result.error,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\tif (orpheusTracks.length === 0) {\n\t\t\treturn\n\t\t}\n\t\tif (playNext) {\n\t\t\t// 前面已经做过长度检查，这里直接取第一个\n\t\t\tawait Orpheus.playNext(orpheusTracks[0])\n\t\t\tif (playNow) {\n\t\t\t\tawait Orpheus.play()\n\t\t\t\treturn\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tawait Orpheus.addToEnd(orpheusTracks, startFromKey, clearQueue)\n\t\t// 原生层已经处理了 startFromKey 的播放逻辑，会在添加后直接播放，这里只需要处理 playNow 即可\n\t\tif (playNow && !startFromKey) {\n\t\t\tawait Orpheus.play()\n\t\t\treturn\n\t\t}\n\t} catch (e) {\n\t\tlogger.error('添加到队列失败：', { error: e })\n\t}\n}\n\nfunction getInternalPlayUri(track: Track) {\n\tif (track.source === 'bilibili') {\n\t\treturn track.bilibiliMetadata.isMultiPage\n\t\t\t? `orpheus://bilibili?bvid=${track.bilibiliMetadata.bvid}&cid=${track.bilibiliMetadata.cid}&hires=0&dolby=0`\n\t\t\t: `orpheus://bilibili?bvid=${track.bilibiliMetadata.bvid}&hires=0&dolby=0`\n\t}\n\tif (track.source === 'local' && track.localMetadata) {\n\t\treturn track.localMetadata.localPath\n\t}\n\treturn undefined\n}\n\nasync function finalizeAndRecordCurrentTrack(\n\tuniqueKey: string,\n\trealDuration: number,\n\tposition: number,\n) {\n\ttry {\n\t\tconst playedSeconds = Math.max(0, Math.floor(position))\n\t\tconst duration = Math.max(1, Math.floor(realDuration))\n\t\tconst effectivePlayed = Math.min(playedSeconds, duration)\n\t\tconst threshold = Math.max(Math.floor(duration * 0.9), duration - 2)\n\t\tconst completed = effectivePlayed >= threshold\n\t\tlogger.info('完成播放', { uniqueKey })\n\t\tlogger.debug('完成播放标记', {\n\t\t\tplayedSeconds,\n\t\t\tduration,\n\t\t\teffectivePlayed,\n\t\t\tthreshold,\n\t\t\tcompleted,\n\t\t\tuniqueKey,\n\t\t})\n\n\t\tconst res = await trackService.addPlayRecordFromUniqueKey(uniqueKey, {\n\t\t\tstartTime: (Date.now() - playedSeconds * 1000) / 1000,\n\t\t\tdurationPlayed: effectivePlayed,\n\t\t\tcompleted,\n\t\t})\n\n\t\tif (res.isErr()) {\n\t\t\tlogger.debug('增加播放记录失败', {\n\t\t\t\tuniqueKey,\n\t\t\t\tmessage: flatErrorMessage(res.error),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tlogger.debug('增加播放记录成功', {\n\t\t\tuniqueKey,\n\t\t})\n\n\t\tvoid queryClient.invalidateQueries({\n\t\t\tqueryKey: trackKeys.history(),\n\t\t})\n\n\t\tvoid reportPlaybackHistory(uniqueKey, effectivePlayed).catch((error) =>\n\t\t\tlogger.error('上报播放历史失败', error),\n\t\t)\n\t} catch (error) {\n\t\tlogger.debug('增加播放记录异常', error)\n\t}\n}\n\nexport {\n\taddToQueue,\n\tconvertToOrpheusTrack,\n\tfinalizeAndRecordCurrentTrack,\n\tgetInternalPlayUri,\n\treportPlaybackHistory,\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/search.ts",
    "content": "import type { Router } from 'expo-router'\n\nimport { bilibiliApi } from '@/lib/api/bilibili/api'\nimport { av2bv } from '@/lib/api/bilibili/utils'\n\nimport { toastAndLogError } from './error-handling'\nimport log from './log'\nimport toast from './toast'\n\nconst logger = log.extend('Utils.Search')\n\nconst BV_REGEX = /(?<![A-Za-z0-9])(bv[0-9A-Za-z]{10})(?![A-Za-z0-9])/i\nconst AV_REGEX = /(?<![A-Za-z0-9])av(\\d+)(?![A-Za-z0-9])/i\nconst SPACE_REGEX = /^\\/space\\/(\\d+)(?:\\/|$)/i\n\nconst cleanUrl = (s: string) => s.replace(/[),.;!?，。！？）]+$/, '')\nconst ensureProtocol = (s: string) =>\n\t/^https?:\\/\\//i.test(s) ? s : 'https://' + s\nconst removeBilibiliShareTrashContents = (s: string) => {\n\tconst i = s.search(/https?:\\/\\//i)\n\treturn i >= 0 ? s.slice(i) : s\n}\n\nexport type SearchStrategy =\n\t| { type: 'BVID'; bvid: string }\n\t| { type: 'FAVORITE'; id: string }\n\t| { type: 'COLLECTION'; id: string }\n\t| { type: 'SEARCH'; query: string }\n\t| { type: 'INVALID_URL_NO_CTYPE' }\n\t| { type: 'B23_RESOLVE_ERROR'; query: string; error: Error }\n\t| { type: 'B23_NO_BVID_ERROR'; query: string; resolvedUrl: string }\n\t| { type: 'AV_PARSE_ERROR'; query: string }\n\t| { type: 'UPLOADER'; mid: string } // 新增策略：作者/空间 mid\n\n/**\n * （伪）OmniBox，用于根据用户输入内容匹配对应的入口\n * @param raw 用户输入的内容\n * @returns 匹配到的策略\n */\nexport async function matchSearchStrategies(\n\traw: string,\n): Promise<SearchStrategy> {\n\tconst query = raw.trim()\n\n\tconst parseUrlToStrategy = (urlObj: URL): SearchStrategy | null => {\n\t\t// 1) 处理 ctype+fid（收藏夹/合集）\n\t\tconst ctype = urlObj.searchParams.get('ctype')\n\t\tconst fid = urlObj.searchParams.get('fid')\n\t\tif (ctype && fid) {\n\t\t\tif (ctype === '21') {\n\t\t\t\tlogger.debug('parseUrlToStrategy: 主站收藏夹 URL (ctype=21)', { fid })\n\t\t\t\treturn { type: 'COLLECTION', id: fid }\n\t\t\t} else if (ctype === '11') {\n\t\t\t\tlogger.debug('parseUrlToStrategy: 主站收藏夹 URL (ctype=11)', { fid })\n\t\t\t\treturn { type: 'FAVORITE', id: fid }\n\t\t\t}\n\t\t} else if (fid && !ctype) {\n\t\t\tlogger.debug(\n\t\t\t\t'parseUrlToStrategy: 主站 URL 缺少 ctype 参数，默认为收藏夹',\n\t\t\t\t{ fid },\n\t\t\t)\n\t\t\treturn { type: 'FAVORITE', id: fid }\n\t\t}\n\n\t\t// 处理 space.bilibili.com 域名，如果后面包含 `lists`，则认为是合集，否则为个人空间\n\t\tif (urlObj.hostname === 'space.bilibili.com') {\n\t\t\tconst sliced = urlObj.pathname.split('/')\n\t\t\tsliced.shift()\n\t\t\tconst mid = sliced.shift()\n\t\t\tif (mid) {\n\t\t\t\tif (sliced.includes('lists')) {\n\t\t\t\t\tconst collectionId = sliced.pop()\n\t\t\t\t\tif (!collectionId) {\n\t\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t\t'parseUrlToStrategy: 匹配 space.bilibili.com/<mid>/lists',\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmid,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)\n\t\t\t\t\t\treturn { type: 'UPLOADER', mid }\n\t\t\t\t\t}\n\t\t\t\t\tlogger.debug(\n\t\t\t\t\t\t'parseUrlToStrategy: 匹配 space.bilibili.com/<mid>/lists/<collectionId>',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcollectionId,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\t\t\t\t\treturn { type: 'COLLECTION', id: collectionId }\n\t\t\t\t}\n\t\t\t\tlogger.debug('parseUrlToStrategy: 匹配 space.bilibili.com/<mid>', {\n\t\t\t\t\tmid,\n\t\t\t\t})\n\t\t\t\treturn { type: 'UPLOADER', mid }\n\t\t\t}\n\t\t}\n\n\t\t// 2) 提取 mid（个人空间、作者页）—— /space/<mid> | space.bilibili.com/<mid>\n\t\tconst pathname = urlObj.pathname || ''\n\t\tconst spaceMatch = SPACE_REGEX.exec(pathname)\n\t\tif (spaceMatch) {\n\t\t\tconst mid = spaceMatch[1]\n\t\t\tlogger.debug('parseUrlToStrategy: 匹配 space/<mid>', { mid })\n\t\t\treturn { type: 'UPLOADER', mid }\n\t\t}\n\n\t\t// 3) 如果 URL 上包含 BV/AV，直接提取\n\t\tconst bvidInUrl = BV_REGEX.exec(urlObj.href)?.[1]\n\t\tif (bvidInUrl) {\n\t\t\tconst bvid = 'BV' + bvidInUrl.slice(2)\n\t\t\tlogger.debug('parseUrlToStrategy: URL 中匹配到 BV', { bvid })\n\t\t\treturn { type: 'BVID', bvid }\n\t\t}\n\t\tconst mAV = AV_REGEX.exec(urlObj.href)\n\t\tif (mAV) {\n\t\t\tconst avid = Number(mAV[1])\n\t\t\tif (Number.isFinite(avid) && avid > 0) {\n\t\t\t\tconst bvid = av2bv(avid)\n\t\t\t\tlogger.debug('parseUrlToStrategy: URL 中匹配到 AV', { avid, bvid })\n\t\t\t\treturn { type: 'BVID', bvid }\n\t\t\t} else {\n\t\t\t\tlogger.debug('parseUrlToStrategy: URL 中 AV 解析失败', {\n\t\t\t\t\thref: urlObj.href,\n\t\t\t\t})\n\t\t\t\treturn { type: 'AV_PARSE_ERROR', query: urlObj.href }\n\t\t\t}\n\t\t}\n\n\t\t// 未识别为已知的主站 URL 类型\n\t\treturn null\n\t}\n\n\t// 1. 处理 b23.tv 短链（解析后把解析结果当作完整 URL 再走一次完整解析）\n\tif (query) {\n\t\ttry {\n\t\t\tconst url = new URL(\n\t\t\t\tensureProtocol(cleanUrl(removeBilibiliShareTrashContents(query))),\n\t\t\t)\n\n\t\t\t// 1.1 如果是 b23.tv 短链的话，去解析并把解析结果当作完整 URL 继续解析\n\t\t\tif (/(^|\\.)b23\\.tv$/i.test(url.hostname)) {\n\t\t\t\tconst resolved = await bilibiliApi.getB23ResolvedUrl(url.toString())\n\t\t\t\tif (resolved.isErr()) {\n\t\t\t\t\tlogger.debug('1.1 短链解析失败', { query })\n\t\t\t\t\treturn { type: 'B23_RESOLVE_ERROR', query, error: resolved.error }\n\t\t\t\t}\n\n\t\t\t\ttry {\n\t\t\t\t\tconst resolvedUrlObj = new URL(resolved.value)\n\t\t\t\t\tconst parsed = parseUrlToStrategy(resolvedUrlObj)\n\t\t\t\t\tif (parsed) {\n\t\t\t\t\t\tlogger.debug('1.1 短链解析并作为完整 URL 继续解析', {\n\t\t\t\t\t\t\toriginal: url.toString(),\n\t\t\t\t\t\t\tresolved: resolved.value,\n\t\t\t\t\t\t\tstrategy: parsed.type,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn parsed\n\t\t\t\t\t}\n\t\t\t\t} catch (_e) {\n\t\t\t\t\t// 继续后面检查一下 Bvid\n\t\t\t\t}\n\n\t\t\t\tconst bvid = BV_REGEX.exec(resolved.value)?.[1]\n\t\t\t\tif (bvid) {\n\t\t\t\t\tconst normalized = 'BV' + bvid.slice(2)\n\t\t\t\t\tlogger.debug('1.1 短链解析后在 resolved 字符串中匹配到 BV', {\n\t\t\t\t\t\tbvid: normalized,\n\t\t\t\t\t})\n\t\t\t\t\treturn { type: 'BVID', bvid: normalized }\n\t\t\t\t}\n\n\t\t\t\tlogger.debug('1.1 短链解析出错（无已识别内容）', {\n\t\t\t\t\toriginal: url.toString(),\n\t\t\t\t\tresolved: resolved.value,\n\t\t\t\t})\n\t\t\t\treturn {\n\t\t\t\t\ttype: 'B23_NO_BVID_ERROR',\n\t\t\t\t\tquery,\n\t\t\t\t\tresolvedUrl: resolved.value,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 1.2 对于主站 url（用户直接粘贴的长链接），尝试解析为各种策略\n\t\t\tconst fromUrl = parseUrlToStrategy(url)\n\t\t\tif (fromUrl) {\n\t\t\t\tlogger.debug('1.2 匹配主站 URL', {\n\t\t\t\t\thref: url.toString(),\n\t\t\t\t\tstrategy: fromUrl.type,\n\t\t\t\t})\n\t\t\t\treturn fromUrl\n\t\t\t}\n\n\t\t\t// 如果没有返回（未识别的长链），继续走 BV/AV 检测或关键词搜索\n\t\t} catch {\n\t\t\tlogger.debug('URL 解析失败，继续走 BV/AV 检测', {\n\t\t\t\tquery,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 2. 任意位置提取 BV\n\tconst mBV = BV_REGEX.exec(query)\n\tif (mBV) {\n\t\tconst bvid = 'BV' + mBV[1].slice(2)\n\t\tlogger.debug('2 匹配 BV 号', { bvid })\n\t\treturn { type: 'BVID', bvid }\n\t}\n\n\t// 3. 任意位置提取 AV\n\tconst mAV = AV_REGEX.exec(query)\n\tif (mAV) {\n\t\tconst avid = Number(mAV[1])\n\t\tif (Number.isFinite(avid) && avid > 0) {\n\t\t\tconst bvid = av2bv(avid)\n\t\t\tlogger.debug('3 匹配 AV 号', { avid, bvid })\n\t\t\treturn { type: 'BVID', bvid }\n\t\t} else {\n\t\t\tlogger.debug('3 AV 号解析失败', { query })\n\t\t\treturn { type: 'AV_PARSE_ERROR', query }\n\t\t}\n\t}\n\n\t// 4. 走关键词搜索\n\tlogger.debug('4 默认关键词搜索', { query })\n\treturn { type: 'SEARCH', query }\n}\n\n/**\n * 根据匹配到的策略进行导航\n * @param strategy 匹配到的策略\n * @param navigation react navigation 导航实例\n * @returns 0 表示匹配策略为 id/url 等，不需要添加到历史记录；1 表示为正常搜索，需要添加到历史记录\n */\nexport function navigateWithSearchStrategy(\n\tstrategy: SearchStrategy,\n\trouter: Router,\n) {\n\tswitch (strategy.type) {\n\t\tcase 'BVID':\n\t\t\tlogger.debug('Navigating to PlaylistMultipage with bvid', {\n\t\t\t\tbvid: strategy.bvid,\n\t\t\t})\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/multipage/[bvid]',\n\t\t\t\tparams: { bvid: strategy.bvid },\n\t\t\t})\n\t\t\treturn 0\n\t\tcase 'FAVORITE':\n\t\t\tlogger.debug('Navigating to PlaylistFavorite', { id: strategy.id })\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/favorite/[id]',\n\t\t\t\tparams: { id: strategy.id },\n\t\t\t})\n\t\t\treturn 0\n\t\tcase 'COLLECTION':\n\t\t\tlogger.debug('Navigating to PlaylistCollection', { id: strategy.id })\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/collection/[id]',\n\t\t\t\tparams: { id: strategy.id },\n\t\t\t})\n\t\t\treturn 0\n\t\tcase 'UPLOADER':\n\t\t\tlogger.debug('Navigating to PlaylistUploader', { mid: strategy.mid })\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/uploader/[mid]',\n\t\t\t\tparams: { mid: strategy.mid },\n\t\t\t})\n\t\t\treturn 0\n\t\tcase 'SEARCH':\n\t\t\tlogger.debug('Navigating to SearchResult', { query: strategy.query })\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/search-result/global/[query]',\n\t\t\t\tparams: { query: strategy.query },\n\t\t\t})\n\t\t\treturn 1\n\t\tcase 'INVALID_URL_NO_CTYPE':\n\t\t\ttoast.error('链接中未找到 ctype 参数，你确定复制全了吗？')\n\t\t\treturn 0\n\t\tcase 'B23_RESOLVE_ERROR':\n\t\t\ttoastAndLogError('解析 b23.tv 短链接失败', strategy.error, 'Utils.Search')\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/search-result/global/[query]',\n\t\t\t\tparams: { query: strategy.query },\n\t\t\t})\n\t\t\treturn 1\n\t\tcase 'B23_NO_BVID_ERROR':\n\t\t\ttoastAndLogError(\n\t\t\t\t'未能从短链解析出已识别内容（BV/作者/收藏等）',\n\t\t\t\tnew Error(strategy.resolvedUrl),\n\t\t\t\t'Utils.Search',\n\t\t\t)\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/search-result/global/[query]',\n\t\t\t\tparams: { query: strategy.query },\n\t\t\t})\n\t\t\treturn 1\n\t\tcase 'AV_PARSE_ERROR':\n\t\t\ttoastAndLogError(\n\t\t\t\t'解析 avid 失败',\n\t\t\t\tnew Error(strategy.query),\n\t\t\t\t'Utils.Search',\n\t\t\t)\n\t\t\trouter.push({\n\t\t\t\tpathname: '/playlist/remote/search-result/global/[query]',\n\t\t\t\tparams: { query: strategy.query },\n\t\t\t})\n\t\t\treturn 1\n\t}\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/set.ts",
    "content": "/**\n * 对两个 Set 进行差集计算\n * @param source 原始 Set\n * @param target 新的 Set\n * @returns 返回一个包含 added 和 removed 两个 Set 的对象\n */\nexport function diffSets<T>(\n\tsource: Set<T>,\n\ttarget: Set<T>,\n): {\n\tadded: Set<T>\n\tremoved: Set<T>\n} {\n\tconst added = new Set<T>()\n\tconst removed = new Set<T>()\n\n\t// Find added elements (in target but not in source)\n\tfor (const elem of target) {\n\t\tif (!source.has(elem)) {\n\t\t\tadded.add(elem)\n\t\t}\n\t}\n\n\t// Find removed elements (in source but not in target)\n\tfor (const elem of source) {\n\t\tif (!target.has(elem)) {\n\t\t\tremoved.add(elem)\n\t\t}\n\t}\n\n\treturn { added, removed }\n}\n"
  },
  {
    "path": "apps/mobile/src/utils/sticky-mitt.ts",
    "content": "import mitt, { type Emitter, type Handler } from 'mitt'\n\nimport log from './log'\n\nconst logger = log.extend('Utils.StickyMitt')\n\n/**\n * 当一个新的监听器被添加时，如果对应事件存在粘性事件，会立即用该值触发一次监听器。\n *\n * @returns 一个支持粘性事件的 Emitter 实例。\n */\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction createStickyEmitter<Events extends Record<string, any>>() {\n\tconst emitter: Emitter<Events> = mitt<Events>()\n\n\tconst stickyEvents = new Map<keyof Events, Events[keyof Events]>()\n\n\treturn {\n\t\t/**\n\t\t * 发射一个粘性事件。\n\t\t * 这个事件的值会被存储起来，供未来的监听者使用。\n\t\t */\n\t\temitSticky<Key extends keyof Events>(\n\t\t\ttype: Key,\n\t\t\tpayload: Events[Key],\n\t\t): void {\n\t\t\tstickyEvents.set(type, payload)\n\t\t\temitter.emit(type, payload)\n\t\t},\n\n\t\t/**\n\t\t * 注册一个事件监听器。\n\t\t * 如果这是一个粘性事件且之前已经发射过，会立即用最后一次的值触发 handler。\n\t\t */\n\t\ton<Key extends keyof Events>(\n\t\t\ttype: Key,\n\t\t\thandler: Handler<Events[Key]>,\n\t\t): void {\n\t\t\temitter.on(type, handler)\n\t\t\tif (stickyEvents.has(type)) {\n\t\t\t\ttry {\n\t\t\t\t\thandler(stickyEvents.get(type) as Events[Key])\n\t\t\t\t} catch (err) {\n\t\t\t\t\tlogger.error('Sticky Event Handler 处理器出错', {\n\t\t\t\t\t\ttype,\n\t\t\t\t\t\terror: err,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * 清除某个事件类型的粘性状态。\n\t\t */\n\t\tclearSticky<Key extends keyof Events>(type: Key): void {\n\t\t\tstickyEvents.delete(type)\n\t\t},\n\n\t\t/**\n\t\t * 清除所有事件的粘性状态。\n\t\t */\n\t\tclearAllSticky(): void {\n\t\t\tstickyEvents.clear()\n\t\t},\n\n\t\t/**\n\t\t * 普通的 emit 方法\n\t\t */\n\t\temit<Key extends keyof Events>(type: Key, payload: Events[Key]): void {\n\t\t\temitter.emit(type, payload)\n\t\t},\n\n\t\t/**\n\t\t * 注销事件监听器\n\t\t */\n\t\toff<Key extends keyof Events>(\n\t\t\ttype: Key,\n\t\t\thandler: Handler<Events[Key]>,\n\t\t): void {\n\t\t\temitter.off(type, handler)\n\t\t},\n\n\t\t/**\n\t\t * 获取所有事件监听器的 Map。\n\t\t */\n\t\tget all() {\n\t\t\treturn emitter.all\n\t\t},\n\n\t\t/**\n\t\t * 订阅事件，返回一个取消函数。\n\t\t * 取消函数可以在事件处理器中调用，以取消订阅。\n\t\t */\n\t\tsubscribe<Key extends keyof Events>(\n\t\t\ttype: Key,\n\t\t\thandler: Handler<Events[Key]>,\n\t\t): () => void {\n\t\t\temitter.on(type, handler)\n\n\t\t\treturn () => {\n\t\t\t\temitter.off(type, handler)\n\t\t\t}\n\t\t},\n\n\t\tget allEvents() {\n\t\t\treturn stickyEvents\n\t\t},\n\t}\n}\n\nexport default createStickyEmitter\n"
  },
  {
    "path": "apps/mobile/src/utils/time.ts",
    "content": "// oxlint-disable-next-line import/no-unassigned-import\nimport 'dayjs/locale/zh-cn'\n\nimport dayjs from 'dayjs'\nimport relativeTime from 'dayjs/plugin/relativeTime'\n\ndayjs.extend(relativeTime)\ndayjs.locale('zh-cn')\n\n/**\n * 获取传入时间到现在的相对时间\n * @param date 时间戳或 Date 对象\n * @returns 相对时间\n */\nexport function formatRelativeTime(date: Date | string | number): string {\n\treturn dayjs(date).fromNow()\n}\n\n/**\n * 格式化秒数为 (HH:)MM:SS 格式\n * @param seconds\n * @returns\n */\nexport const formatDurationToHHMMSS = (seconds: number): string => {\n\tconst hours = Math.floor(seconds / 3600)\n\tconst minutes = Math.floor((seconds % 3600) / 60)\n\tconst remainingSeconds = seconds % 60\n\tif (hours === 0) {\n\t\treturn `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`\n\t}\n\treturn `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`\n}\n\n/**\n * 格式化秒数为 XX小时XX分钟 文本\n */\nexport const formatDurationToText = (seconds: number): string => {\n\tconst hours = Math.floor(seconds / 3600)\n\tconst minutes = Math.floor((seconds % 3600) / 60)\n\tlet text = ''\n\tif (hours > 0) text += `${hours} 小时 `\n\ttext += `${minutes} 分钟`\n\treturn text.trim()\n}\n\n/**\n * Parse duration string (e.g., \"03:45\", \"1:30:00\") to seconds\n * @param durationStr\n * @returns seconds\n */\nexport function parseDurationString(durationStr: string): number {\n\tconst parts = durationStr.split(':').map(Number)\n\tif (parts.length === 3) {\n\t\treturn parts[0] * 3600 + parts[1] * 60 + parts[2]\n\t} else if (parts.length === 2) {\n\t\treturn parts[0] * 60 + parts[1]\n\t}\n\treturn 0\n}\n\n/**\n * MM:SS 格式转换为秒数\n * @param duration\n * @returns\n * @deprecated Use parseDurationString instead\n */\nexport const formatMMSSToSeconds = parseDurationString\n"
  },
  {
    "path": "apps/mobile/src/utils/toast.ts",
    "content": "import { toast as sonner } from 'sonner-native'\n\nimport * as Haptics from './haptics'\n\ninterface Options {\n\tdescription?: string\n\tid?: string | number\n\tduration?: number\n\taction?: {\n\t\tlabel: string\n\t\tonClick: () => void\n\t}\n}\n\nconst show = (message: string, options?: Options) => {\n\treturn sonner(message, {\n\t\tdescription: options?.description,\n\t\tduration: options?.duration,\n\t\tid: options?.id,\n\t\taction: options?.action,\n\t})\n}\n\nconst success = (message: string, options?: Options) => {\n\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Confirm)\n\treturn sonner.success(message, {\n\t\tdescription: options?.description,\n\t\tduration: options?.duration,\n\t\tid: options?.id,\n\t\taction: options?.action,\n\t})\n}\n\nconst error = (message: string, options?: Options) => {\n\tvoid Haptics.performHaptics(Haptics.AndroidHaptics.Reject)\n\treturn sonner.error(message, {\n\t\tdescription: options?.description,\n\t\tduration: options?.duration,\n\t\tid: options?.id,\n\t\taction: options?.action,\n\t})\n}\n\nconst info = (message: string, options?: Options) => {\n\treturn sonner.info(message, {\n\t\tdescription: options?.description,\n\t\tduration: options?.duration,\n\t\tid: options?.id,\n\t\taction: options?.action,\n\t})\n}\n\nconst dismiss = (id?: string | number) => {\n\tif (id !== undefined && id !== null) {\n\t\tsonner.dismiss(id)\n\t} else {\n\t\tsonner.dismiss()\n\t}\n}\n\nconst loading = (message: string, options?: Options) => {\n\treturn sonner.loading(message, {\n\t\tdescription: options?.description,\n\t\tduration: options?.duration,\n\t\tid: options?.id,\n\t\taction: options?.action,\n\t})\n}\n\nconst toast = {\n\tshow,\n\tsuccess,\n\terror,\n\tinfo,\n\tloading,\n\tdismiss,\n}\n\nexport default toast\n"
  },
  {
    "path": "apps/mobile/tsconfig.json",
    "content": "{\n\t\"extends\": \"expo/tsconfig.base\",\n\t\"compilerOptions\": {\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"paths\": {\n\t\t\t\"@/*\": [\"./src/*\"],\n\t\t\t\"@/types/*\": [\"./src/types/*\"],\n\t\t\t\"@/lib/*\": [\"./src/lib/*\"],\n\t\t\t\"@/app/*\": [\"./src/app/*\"],\n\t\t\t\"@/components/*\": [\"./src/components/*\"],\n\t\t\t\"@/hooks/*\": [\"./src/hooks/*\"],\n\t\t\t\"@/utils/*\": [\"./src/utils/*\"],\n\t\t\t\"@/features/*\": [\"./src/features/*\"],\n\t\t\t\"@bbplayer/orpheus\": [\"../../../packages/orpheus/src/index.ts\"],\n\t\t\t\"@bbplayer/image-theme-colors\": [\n\t\t\t\t\"../../../packages/image-theme-colors/src/index.ts\"\n\t\t\t]\n\t\t}\n\t},\n\t\"include\": [\"**/*.ts\", \"**/*.tsx\", \".expo/types/**/*.ts\", \"expo-env.d.ts\"]\n}\n"
  },
  {
    "path": "apps/update-publisher/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/update-publisher\",\n\t\"version\": \"0.1.0\",\n\t\"private\": true,\n\t\"description\": \"TUI for preparing and publishing BBPlayer update metadata\",\n\t\"type\": \"module\",\n\t\"scripts\": {\n\t\t\"check\": \"tsc --noEmit\",\n\t\t\"start\": \"tsx src/index.ts\"\n\t},\n\t\"dependencies\": {\n\t\t\"@clack/prompts\": \"^1.3.0\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/node\": \"^25.2.3\",\n\t\t\"tsx\": \"^4.21.0\",\n\t\t\"typescript\": \"~5.9.3\"\n\t}\n}\n"
  },
  {
    "path": "apps/update-publisher/src/index.ts",
    "content": "#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { dirname, basename, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nimport {\n\tcancel,\n\tconfirm,\n\tintro,\n\tisCancel,\n\tlog,\n\tnote,\n\toutro,\n\tselect,\n\tspinner,\n} from '@clack/prompts'\n\ninterface GitHubAsset {\n\tname: string\n\tbrowser_download_url: string\n}\n\ninterface GitHubRelease {\n\ttag_name: string\n\tname: string | null\n\tbody: string | null\n\thtml_url: string\n\tdraft: boolean\n\tprerelease: boolean\n\tpublished_at: string | null\n\tassets: GitHubAsset[]\n}\n\ninterface UpdateManifest {\n\tversion: string\n\turl: string\n\tdownloads?: {\n\t\tandroid?: Record<string, string>\n\t}\n\tnotes: string\n\tlisted_notes?: string[]\n\tforced: boolean\n}\n\nconst REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')\nconst DEFAULT_REPO = 'bbplayer-app/BBPlayer'\nconst UPDATE_KEY = 'update_json'\nconst ANDROID_ABIS = ['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86'] as const\n\nasync function main() {\n\tintro('BBPlayer update publisher')\n\tconst repo = process.env.BBPLAYER_UPDATE_REPO ?? DEFAULT_REPO\n\tconst releaseSpinner = spinner()\n\treleaseSpinner.start(`Fetching recent releases from ${repo}`)\n\tconst releases = await fetchRecentReleases(repo)\n\treleaseSpinner.stop(`Fetched ${releases.length} releases`)\n\n\tconst selected = await selectRelease(releases)\n\tconst manifest = createManifest(selected)\n\tconst tempPath = await writeTempManifest(manifest)\n\n\tawait openInVSCode(tempPath)\n\n\tconst edited = await readManifest(tempPath)\n\tprintManifestSummary(edited)\n\n\tconst shouldPublish = await confirm({\n\t\tmessage: 'Publish this update.json to Cloudflare KV?',\n\t\tinitialValue: false,\n\t})\n\tif (isCancel(shouldPublish)) {\n\t\tcancel('Cancelled')\n\t\treturn\n\t}\n\tif (!shouldPublish) {\n\t\toutro(`Not published. Edited file remains at ${tempPath}`)\n\t\treturn\n\t}\n\n\tconst publishSpinner = spinner()\n\tpublishSpinner.start('Publishing update_json to Cloudflare Workers KV')\n\tawait publishToWorkersKv(tempPath)\n\tpublishSpinner.stop('Published update_json to Cloudflare Workers KV')\n\toutro('Done')\n}\n\nasync function fetchRecentReleases(repo: string): Promise<GitHubRelease[]> {\n\tconst headers: Record<string, string> = {\n\t\tAccept: 'application/vnd.github+json',\n\t\t'User-Agent': '@bbplayer/update-publisher',\n\t\t'X-GitHub-Api-Version': '2022-11-28',\n\t}\n\tif (process.env.GITHUB_TOKEN) {\n\t\theaders.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`\n\t}\n\n\tconst url = `https://api.github.com/repos/${repo}/releases?per_page=8`\n\tconst res = await fetch(url, { headers })\n\tif (!res.ok) {\n\t\tthrow new Error(\n\t\t\t`GitHub releases request failed: ${res.status} ${res.statusText}`,\n\t\t)\n\t}\n\n\tconst json: unknown = await res.json()\n\tif (!Array.isArray(json)) {\n\t\tthrow new Error('GitHub releases response is not an array')\n\t}\n\n\treturn json.map(parseRelease)\n}\n\nfunction parseRelease(value: unknown): GitHubRelease {\n\tif (typeof value !== 'object' || value === null) {\n\t\tthrow new Error('Invalid GitHub release item')\n\t}\n\tconst item = value as Record<string, unknown>\n\tconst assets = Array.isArray(item.assets) ? item.assets.map(parseAsset) : []\n\treturn {\n\t\ttag_name: requireString(item.tag_name, 'tag_name'),\n\t\tname: typeof item.name === 'string' ? item.name : null,\n\t\tbody: typeof item.body === 'string' ? item.body : null,\n\t\thtml_url: requireString(item.html_url, 'html_url'),\n\t\tdraft: item.draft === true,\n\t\tprerelease: item.prerelease === true,\n\t\tpublished_at:\n\t\t\ttypeof item.published_at === 'string' ? item.published_at : null,\n\t\tassets,\n\t}\n}\n\nfunction parseAsset(value: unknown): GitHubAsset {\n\tif (typeof value !== 'object' || value === null) {\n\t\tthrow new Error('Invalid GitHub release asset')\n\t}\n\tconst item = value as Record<string, unknown>\n\treturn {\n\t\tname: requireString(item.name, 'asset.name'),\n\t\tbrowser_download_url: requireString(\n\t\t\titem.browser_download_url,\n\t\t\t'asset.browser_download_url',\n\t\t),\n\t}\n}\n\nfunction requireString(value: unknown, field: string): string {\n\tif (typeof value !== 'string') {\n\t\tthrow new Error(`Missing string field: ${field}`)\n\t}\n\treturn value\n}\n\nasync function selectRelease(\n\treleases: GitHubRelease[],\n): Promise<GitHubRelease> {\n\tif (releases.length === 0) {\n\t\tthrow new Error('No GitHub releases found')\n\t}\n\n\tconst selected = await select({\n\t\tmessage: 'Select GitHub release',\n\t\toptions: releases.map((release) => ({\n\t\t\tvalue: release.tag_name,\n\t\t\tlabel: formatReleaseLabel(release),\n\t\t\thint: release.html_url,\n\t\t})),\n\t})\n\n\tif (isCancel(selected)) {\n\t\tcancel('Cancelled')\n\t\tprocess.exit(0)\n\t}\n\n\tconst release = releases.find((item) => item.tag_name === selected)\n\tif (!release) {\n\t\tthrow new Error(`Selected release not found: ${selected}`)\n\t}\n\treturn release\n}\n\nfunction formatReleaseLabel(release: GitHubRelease): string {\n\tconst flags = [release.draft ? 'draft' : '', release.prerelease ? 'pre' : '']\n\t\t.filter(Boolean)\n\t\t.join(', ')\n\tconst date = release.published_at?.slice(0, 10) ?? 'unpublished'\n\tconst name = release.name ? ` - ${release.name}` : ''\n\treturn `${release.tag_name}${name} (${date}${flags ? `, ${flags}` : ''})`\n}\n\nfunction createManifest(release: GitHubRelease): UpdateManifest {\n\tconst notes = release.body ?? ''\n\tconst android = collectAndroidDownloads(release.assets)\n\tconst downloads = Object.keys(android).length > 0 ? { android } : undefined\n\n\treturn {\n\t\tversion: normalizeVersion(release.tag_name),\n\t\turl: release.html_url,\n\t\tdownloads,\n\t\tnotes,\n\t\tlisted_notes: parseMarkdownListItems(notes),\n\t\tforced: false,\n\t}\n}\n\nfunction collectAndroidDownloads(\n\tassets: GitHubAsset[],\n): Record<string, string> {\n\tconst downloads: Record<string, string> = {}\n\tfor (const asset of assets) {\n\t\tif (!asset.name.toLowerCase().endsWith('.apk')) continue\n\t\tconst abi = inferAndroidAbi(asset.name)\n\t\tif (abi) downloads[abi] = asset.browser_download_url\n\t}\n\treturn downloads\n}\n\nfunction inferAndroidAbi(fileName: string): string | null {\n\tconst normalized = fileName.toLowerCase()\n\tfor (const abi of ANDROID_ABIS) {\n\t\tif (normalized.includes(abi)) return abi\n\t}\n\tif (normalized.includes('universal')) return 'universal'\n\treturn null\n}\n\nfunction normalizeVersion(tag: string): string {\n\treturn tag.startsWith('v') ? tag.slice(1) : tag\n}\n\nfunction parseMarkdownListItems(markdown: string): string[] | undefined {\n\tconst items = markdown\n\t\t.split(/\\r?\\n/)\n\t\t.map((line) => line.match(/^\\s*(?:[-*+]|\\d+\\.)\\s+(.+?)\\s*$/)?.[1])\n\t\t.filter((line): line is string => Boolean(line))\n\t\t.map((line) => line.replace(/\\s+/g, ' ').trim())\n\t\t.map((line, index) => `${index + 1}. ${line}`)\n\n\treturn items.length > 0 ? items : undefined\n}\n\nasync function writeTempManifest(manifest: UpdateManifest): Promise<string> {\n\tconst dir = resolve(REPO_ROOT, '.tmp/update-publisher')\n\tawait mkdir(dir, { recursive: true })\n\tconst path = resolve(dir, `update-${manifest.version}.json`)\n\tawait writeFile(path, `${JSON.stringify(manifest, null, '\\t')}\\n`)\n\treturn path\n}\n\nasync function openInVSCode(path: string): Promise<void> {\n\tlog.info(\n\t\t`Opening ${basename(path)} in VS Code. Save and close the editor tab/window to continue.`,\n\t)\n\tawait run('code', ['--wait', path], { cwd: REPO_ROOT })\n}\n\nasync function readManifest(path: string): Promise<UpdateManifest> {\n\tconst raw = await readFile(path, 'utf8')\n\tconst parsed: unknown = JSON.parse(raw)\n\tvalidateManifest(parsed)\n\treturn parsed\n}\n\nfunction validateManifest(value: unknown): asserts value is UpdateManifest {\n\tif (typeof value !== 'object' || value === null || Array.isArray(value)) {\n\t\tthrow new Error('Edited update manifest must be a JSON object')\n\t}\n\tconst manifest = value as Record<string, unknown>\n\tfor (const field of ['version', 'url', 'notes']) {\n\t\tif (typeof manifest[field] !== 'string') {\n\t\t\tthrow new Error(\n\t\t\t\t`Edited update manifest field \"${field}\" must be a string`,\n\t\t\t)\n\t\t}\n\t}\n\tif (typeof manifest.forced !== 'boolean') {\n\t\tthrow new Error('Edited update manifest field \"forced\" must be a boolean')\n\t}\n\tif (\n\t\tmanifest.listed_notes !== undefined &&\n\t\t(!Array.isArray(manifest.listed_notes) ||\n\t\t\t!manifest.listed_notes.every((item) => typeof item === 'string'))\n\t) {\n\t\tthrow new Error(\n\t\t\t'Edited update manifest field \"listed_notes\" must be a string array',\n\t\t)\n\t}\n}\n\nfunction printManifestSummary(manifest: UpdateManifest) {\n\tconst androidDownloads = manifest.downloads?.android\n\t\t? Object.keys(manifest.downloads.android)\n\t\t: []\n\tnote(\n\t\t[\n\t\t\t`Version: ${manifest.version}`,\n\t\t\t`URL: ${manifest.url}`,\n\t\t\t`Android downloads: ${androidDownloads.join(', ') || 'none'}`,\n\t\t\t`Listed notes: ${manifest.listed_notes?.length ?? 0}`,\n\t\t\t`Forced: ${manifest.forced}`,\n\t\t].join('\\n'),\n\t\t'Prepared update.json',\n\t)\n}\n\nasync function publishToWorkersKv(path: string) {\n\tawait run(\n\t\t'pnpm',\n\t\t[\n\t\t\t'--dir',\n\t\t\t'apps/backend',\n\t\t\t'exec',\n\t\t\t'wrangler',\n\t\t\t'kv',\n\t\t\t'key',\n\t\t\t'put',\n\t\t\tUPDATE_KEY,\n\t\t\t'--path',\n\t\t\tpath,\n\t\t\t'--binding',\n\t\t\t'KV',\n\t\t\t'--remote',\n\t\t],\n\t\t{ cwd: REPO_ROOT },\n\t)\n}\n\nasync function run(\n\tcommand: string,\n\targs: string[],\n\toptions: { cwd: string },\n): Promise<void> {\n\tawait new Promise<void>((resolveRun, reject) => {\n\t\tconst child = spawn(command, args, {\n\t\t\tcwd: options.cwd,\n\t\t\tstdio: 'inherit',\n\t\t\tshell: false,\n\t\t})\n\t\tchild.on('error', reject)\n\t\tchild.on('exit', (code) => {\n\t\t\tif (code === 0) {\n\t\t\t\tresolveRun()\n\t\t\t\treturn\n\t\t\t}\n\t\t\treject(new Error(`${command} ${args.join(' ')} exited with code ${code}`))\n\t\t})\n\t})\n}\n\nmain().catch((error: unknown) => {\n\t// oxlint-disable-next-line eslint(no-console)\n\tconsole.error(error instanceof Error ? error.message : String(error))\n\tprocess.exitCode = 1\n})\n"
  },
  {
    "path": "apps/update-publisher/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ES2022\",\n\t\t\"module\": \"NodeNext\",\n\t\t\"moduleResolution\": \"NodeNext\",\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"types\": [\"node\"]\n\t},\n\t\"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "commitlint.config.js",
    "content": "module.exports = {\n\textends: ['@commitlint/config-conventional'],\n\trules: {\n\t\t'scope-enum': [\n\t\t\t2,\n\t\t\t'always',\n\t\t\t[\n\t\t\t\t'mobile',\n\t\t\t\t'docs',\n\t\t\t\t'image-colors',\n\t\t\t\t'orpheus',\n\t\t\t\t'logs',\n\t\t\t\t'root',\n\t\t\t\t'splash',\n\t\t\t\t'backend',\n\t\t\t\t'heatmap',\n\t\t\t\t'native',\n\t\t\t],\n\t\t],\n\t\t'scope-empty': [2, 'never'],\n\t},\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import importAlias from '@dword-design/eslint-plugin-import-alias'\nimport oxlint from 'eslint-plugin-oxlint'\nimport { defineConfig } from 'eslint/config'\nimport tseslint from 'typescript-eslint'\n\nexport default defineConfig([\n\t{\n\t\tignores: ['dist/*', '**/dm.d.ts', '**/dm.js', '**/router.d.ts'],\n\t},\n\t{\n\t\tfiles: ['**/*.{ts,tsx,mts,cts}'],\n\t\tlanguageOptions: {\n\t\t\tparser: tseslint.parser,\n\t\t},\n\t},\n\t{\n\t\t...importAlias.configs.recommended,\n\t\tfiles: ['apps/mobile/src/**/*.{js,mjs,cjs,ts,jsx,tsx}'],\n\t\trules: {\n\t\t\t'@dword-design/import-alias/prefer-alias': [\n\t\t\t\t'error',\n\t\t\t\t{\n\t\t\t\t\talias: {\n\t\t\t\t\t\t'@': './apps/mobile/src',\n\t\t\t\t\t},\n\t\t\t\t\taliasForSubpaths: true,\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t},\n\t...oxlint.configs['flat/recommended'],\n])\n"
  },
  {
    "path": "lefthook.yml",
    "content": "pre-commit:\n  parallel: true\n  commands:\n    gitleaks:\n      run: |\n        if command -v gitleaks > /dev/null 2>&1; then\n          gitleaks protect --staged --verbose $([ -f .gitleaks-baseline.json ] && echo \"--baseline-path .gitleaks-baseline.json\")\n        else\n          echo \"⚠️ gitleaks is not installed, skipping secret scan. Install with: brew install gitleaks\"\n        fi\n    lint-and-format-codes:\n      glob: '*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,yml,yaml,toml}'\n      run: |\n        if [ -z \"{staged_files}\" ]; then\n          echo \"No staged files to lint or format.\"\n          exit 0\n        fi\n\n        pnpm oxfmt --write \"{staged_files}\" && pnpm oxlint --type-aware \"{staged_files}\" && pnpm eslint \"{staged_files}\"\n      stage_fixed: true\n    format-plain-text:\n      glob: '*.{md,mdx}'\n      run: pnpm oxfmt --write \"{staged_files}\"\n      stage_fixed: true\n\ncommit-msg:\n  commands:\n    commitlint:\n      run: pnpm commitlint --edit \"{1}\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"bbplayer-root\",\n\t\"private\": true,\n\t\"workspaces\": [\n\t\t\"apps/*\",\n\t\t\"packages/*\"\n\t],\n\t\"scripts\": {\n\t\t\"check:deps\": \"syncpack list-mismatches\",\n\t\t\"fix:deps\": \"syncpack fix-mismatches\",\n\t\t\"format\": \"oxfmt --write .\",\n\t\t\"lint\": \"oxlint --type-aware && eslint .\",\n\t\t\"lint:fix\": \"oxlint --type-aware --fix && eslint . --fix\",\n\t\t\"postinstall\": \"lefthook install\",\n\t\t\"publish:update\": \"pnpm --filter @bbplayer/update-publisher start\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@bbplayer/eslint-plugin\": \"workspace:*\",\n\t\t\"@commitlint/cli\": \"^20.4.1\",\n\t\t\"@commitlint/config-conventional\": \"^20.4.1\",\n\t\t\"@dword-design/eslint-plugin-import-alias\": \"^6.0.3\",\n\t\t\"@eslint/js\": \"^9.39.2\",\n\t\t\"@tanstack/eslint-plugin-query\": \"^5.91.4\",\n\t\t\"@types/node\": \"^25.2.3\",\n\t\t\"@typescript/native-preview\": \"beta\",\n\t\t\"eslint\": \"^9.39.2\",\n\t\t\"eslint-plugin-drizzle\": \"^0.2.3\",\n\t\t\"eslint-plugin-oxlint\": \"^1.46.0\",\n\t\t\"eslint-plugin-react-compiler\": \"19.1.0-rc.1\",\n\t\t\"eslint-plugin-react-hooks-extra\": \"^1.53.1\",\n\t\t\"eslint-plugin-react-you-might-not-need-an-effect\": \"^0.5.6\",\n\t\t\"lefthook\": \"^1.13.6\",\n\t\t\"oxfmt\": \"^0.27.0\",\n\t\t\"oxlint\": \"^1.47.0\",\n\t\t\"oxlint-tsgolint\": \"^0.12.1\",\n\t\t\"syncpack\": \"^13.0.4\",\n\t\t\"typescript\": \"~5.9.3\",\n\t\t\"typescript-eslint\": \"^8.55.0\"\n\t},\n\t\"packageManager\": \"pnpm@10.32.1\"\n}\n"
  },
  {
    "path": "packages/eslint-plugin/index.js",
    "content": "import noNavigateRule from './rules/no-navigate-after-modal-close.js'\n\nexport default {\n\trules: {\n\t\t'no-navigate-after-modal-close': noNavigateRule,\n\t},\n}\n"
  },
  {
    "path": "packages/eslint-plugin/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/eslint-plugin\",\n\t\"version\": \"0.1.0\",\n\t\"private\": true,\n\t\"type\": \"module\",\n\t\"main\": \"index.js\"\n}\n"
  },
  {
    "path": "packages/eslint-plugin/rules/no-navigate-after-modal-close.js",
    "content": "export default {\n\tmeta: {\n\t\ttype: 'problem',\n\t\tdocs: {\n\t\t\tdescription: '不允许在关闭 modal 后立即执行导航操作',\n\t\t\trecommended: false,\n\t\t},\n\t\tschema: [\n\t\t\t{\n\t\t\t\ttype: 'object',\n\t\t\t\tproperties: {\n\t\t\t\t\tcloseNames: {\n\t\t\t\t\t\ttype: 'array',\n\t\t\t\t\t\titems: { type: 'string' },\n\t\t\t\t\t\tdefault: ['close', 'closeAll'],\n\t\t\t\t\t},\n\t\t\t\t\tnavigateNames: {\n\t\t\t\t\t\ttype: 'array',\n\t\t\t\t\t\titems: { type: 'string' },\n\t\t\t\t\t\tdefault: [\n\t\t\t\t\t\t\t'navigate',\n\t\t\t\t\t\t\t'push',\n\t\t\t\t\t\t\t'replace',\n\t\t\t\t\t\t\t'reset',\n\t\t\t\t\t\t\t'goBack',\n\t\t\t\t\t\t\t'dispatch',\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tadditionalProperties: false,\n\t\t\t},\n\t\t],\n\t\tmessages: {\n\t\t\tavoid:\n\t\t\t\t'不要在 close/closeAll 调用后的同一执行域内直接调用 navigation.navigate，使用 useModalStore.doAfterModalHostClosed 来延迟导航',\n\t\t},\n\t},\n\n\tcreate(context) {\n\t\tconst opts = context.options[0] || {}\n\t\tconst CLOSE_NAMES = new Set(opts.closeNames || ['close', 'closeAll'])\n\t\tconst NAVIGATE_NAMES = new Set(\n\t\t\topts.navigateNames || [\n\t\t\t\t'navigate',\n\t\t\t\t'push',\n\t\t\t\t'replace',\n\t\t\t\t'reset',\n\t\t\t\t'goBack',\n\t\t\t\t'dispatch',\n\t\t\t],\n\t\t)\n\n\t\t// 判断是否是 close / closeAll 调用\n\t\tfunction isCloseCall(node) {\n\t\t\tif (node.type !== 'CallExpression') return false\n\t\t\tconst callee = node.callee\n\t\t\tif (!callee) return false\n\t\t\tif (callee.type === 'Identifier' && CLOSE_NAMES.has(callee.name))\n\t\t\t\treturn true\n\t\t\tif (\n\t\t\t\tcallee.type === 'MemberExpression' &&\n\t\t\t\tcallee.property?.type === 'Identifier' &&\n\t\t\t\tCLOSE_NAMES.has(callee.property.name)\n\t\t\t)\n\t\t\t\treturn true\n\t\t\treturn false\n\t\t}\n\n\t\t// 判断是否是 navigation.navigate(...) 的调用\n\t\tfunction isNavigateCall(node) {\n\t\t\tif (node.type !== 'CallExpression') return false\n\t\t\tconst callee = node.callee\n\t\t\tif (!callee) return false\n\t\t\treturn (\n\t\t\t\tcallee.type === 'MemberExpression' &&\n\t\t\t\tcallee.property?.type === 'Identifier' &&\n\t\t\t\tNAVIGATE_NAMES.has(callee.property.name)\n\t\t\t)\n\t\t}\n\n\t\t// 获取最近的函数或 Program 节点\n\t\tfunction getEnclosingFunction(node) {\n\t\t\tlet p = node.parent\n\t\t\twhile (p) {\n\t\t\t\tif (\n\t\t\t\t\t[\n\t\t\t\t\t\t'FunctionDeclaration',\n\t\t\t\t\t\t'FunctionExpression',\n\t\t\t\t\t\t'ArrowFunctionExpression',\n\t\t\t\t\t\t'Program',\n\t\t\t\t\t].includes(p.type)\n\t\t\t\t) {\n\t\t\t\t\treturn p\n\t\t\t\t}\n\t\t\t\tp = p.parent\n\t\t\t}\n\t\t\treturn null\n\t\t}\n\n\t\t// 遍历函数节点查找 close 后的 navigate\n\t\tfunction hasNavigateAfter(functionNode, afterPos) {\n\t\t\tconst visitedNodes = new Set()\n\t\t\tlet found = false\n\n\t\t\tfunction traverse(node) {\n\t\t\t\tif (!node || visitedNodes.has(node)) return\n\t\t\t\tvisitedNodes.add(node)\n\n\t\t\t\t// 跳过 parent 防止循环\n\t\t\t\tconst keys = Object.keys(node).filter((k) => k !== 'parent')\n\n\t\t\t\tfor (const key of keys) {\n\t\t\t\t\tconst child = node[key]\n\t\t\t\t\tif (!child) continue\n\n\t\t\t\t\tif (Array.isArray(child)) {\n\t\t\t\t\t\tfor (const c of child) {\n\t\t\t\t\t\t\tif (!c || typeof c.type !== 'string') continue\n\n\t\t\t\t\t\t\t// 跳过嵌套函数\n\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t'FunctionDeclaration',\n\t\t\t\t\t\t\t\t\t'FunctionExpression',\n\t\t\t\t\t\t\t\t\t'ArrowFunctionExpression',\n\t\t\t\t\t\t\t\t].includes(c.type) &&\n\t\t\t\t\t\t\t\tc !== functionNode\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tif (isNavigateCall(c) && (c.range?.[0] ?? 0) > afterPos) {\n\t\t\t\t\t\t\t\tfound = true\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\ttraverse(c)\n\t\t\t\t\t\t\tif (found) return\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (typeof child.type === 'string') {\n\t\t\t\t\t\t// 跳过嵌套函数\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t'FunctionDeclaration',\n\t\t\t\t\t\t\t\t'FunctionExpression',\n\t\t\t\t\t\t\t\t'ArrowFunctionExpression',\n\t\t\t\t\t\t\t].includes(child.type) &&\n\t\t\t\t\t\t\tchild !== functionNode\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (isNavigateCall(child) && (child.range?.[0] ?? 0) > afterPos) {\n\t\t\t\t\t\t\tfound = true\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttraverse(child)\n\t\t\t\t\t\tif (found) return\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttraverse(functionNode)\n\t\t\treturn found\n\t\t}\n\n\t\treturn {\n\t\t\tCallExpression(node) {\n\t\t\t\tif (!isCloseCall(node)) return\n\t\t\t\tconst afterPos = node.range?.[1] ?? 0\n\t\t\t\tconst func = getEnclosingFunction(node)\n\t\t\t\tif (!func) return\n\n\t\t\t\tif (hasNavigateAfter(func, afterPos)) {\n\t\t\t\t\tcontext.report({ node, messageId: 'avoid' })\n\t\t\t\t}\n\t\t\t},\n\t\t}\n\t},\n}\n"
  },
  {
    "path": "packages/heatmap/README.md",
    "content": "# @bbplayer/react-native-heatmap\n\nA customizable heatmap component for React Native, built with `react-native-svg` and `dayjs`. Reimplemented from `react-native-heatmap`.\n\n## Features\n\n- **MonthlyHeatMap**: Grid of months.\n- **WeeklyHeatMap**: Continuous activity graph (GitHub style).\n- Customizable colors, sizes, and themes.\n- Support for `light` and `dark` modes.\n- Support for RTL layouts.\n- Pressable cells with callbacks.\n\n## Installation\n\n```bash\npnpm add @bbplayer/react-native-heatmap\n```\n\nNote: You must also have `react-native-svg` and `dayjs` installed in your project.\n\n## Usage\n\n```tsx\nimport { WeeklyHeatMap } from '@bbplayer/react-native-heatmap'\n\nconst data = {\n\t'2024-01-01': 5,\n\t'2024-01-02': 10,\n}\n\n;<WeeklyHeatMap\n\tdata={data}\n\tscheme='dark'\n\tonCellPress={({ date, count }) => console.log(date, count)}\n/>\n```\n"
  },
  {
    "path": "packages/heatmap/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/heatmap\",\n\t\"version\": \"1.0.0\",\n\t\"main\": \"src/index.ts\",\n\t\"types\": \"src/index.ts\",\n\t\"peerDependencies\": {\n\t\t\"dayjs\": \"^1.11.19\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\",\n\t\t\"react-native-svg\": \"15.15.3\"\n\t}\n}\n"
  },
  {
    "path": "packages/heatmap/src/components/HeatMapCell.tsx",
    "content": "import React, { memo } from 'react'\nimport { Rect, Text as SvgText } from 'react-native-svg'\n\ninterface HeatMapCellProps {\n\tx: number\n\ty: number\n\tsize: number\n\tradius: number\n\tcolor: string\n\tcount: number\n\tdate: Date\n\tpressable?: boolean\n\tonPress?: (params: { date: Date; count: number }) => void\n\tonMouseEnter?: (params: {\n\t\tdate: Date\n\t\tx: number\n\t\ty: number\n\t\tcount: number\n\t}) => void\n\tonMouseLeave?: () => void\n\tcellText?: string\n\tcellTextColor?: string\n\tcellTextFontSize?: number\n}\n\nconst HeatMapCell = ({\n\tx,\n\ty,\n\tsize,\n\tradius,\n\tcolor,\n\tcount,\n\tdate,\n\tpressable,\n\tonPress,\n\tcellText,\n\tcellTextColor,\n\tcellTextFontSize = 10,\n}: HeatMapCellProps) => {\n\tconst handlePress = () => {\n\t\tif (pressable && onPress) {\n\t\t\tonPress({ date, count })\n\t\t}\n\t}\n\n\treturn (\n\t\t<React.Fragment>\n\t\t\t<Rect\n\t\t\t\tx={x}\n\t\t\t\ty={y}\n\t\t\t\twidth={size}\n\t\t\t\theight={size}\n\t\t\t\trx={radius}\n\t\t\t\try={radius}\n\t\t\t\tfill={color}\n\t\t\t\tonPress={handlePress}\n\t\t\t/>\n\t\t\t{cellText && (\n\t\t\t\t<SvgText\n\t\t\t\t\tx={x + size / 2}\n\t\t\t\t\ty={y + size / 2 + cellTextFontSize / 3}\n\t\t\t\t\tfill={cellTextColor}\n\t\t\t\t\tfontSize={cellTextFontSize}\n\t\t\t\t\ttextAnchor='middle'\n\t\t\t\t\tpointerEvents='none'\n\t\t\t\t>\n\t\t\t\t\t{cellText}\n\t\t\t\t</SvgText>\n\t\t\t)}\n\t\t</React.Fragment>\n\t)\n}\n\nexport default memo(HeatMapCell)\n"
  },
  {
    "path": "packages/heatmap/src/components/MonthlyHeatMap.tsx",
    "content": "import dayjs from 'dayjs'\nimport React, { useCallback, useRef } from 'react'\nimport { ScrollView, View } from 'react-native'\nimport Svg, { G, Text as SvgText } from 'react-native-svg'\n\nimport { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from '../constants/theme'\nimport { HeatMapProps } from '../types'\nimport { countData, getMonthlyData, getColor } from '../utils/calendar'\n\nimport HeatMapCell from './HeatMapCell'\n\nexport const MonthlyHeatMap = ({\n\tdata,\n\tstartDate,\n\tendDate,\n\tweekStartsOn = 0,\n\tcellSize = 20,\n\tcellRadius = 2,\n\tcellGap = 2,\n\tcellText,\n\tcellTextFontSize = 10,\n\theaderTextFontSize = 14,\n\theaderBottomSpace = 8,\n\tsideBarTextFontSize = 12,\n\tscheme = 'light',\n\tisHeaderVisible = true,\n\tisSidebarVisible = false,\n\tisCellTextVisible = true,\n\tpressable = true,\n\tonCellPress,\n\tonMouseEnter,\n\tonMouseLeave,\n\tscrollable = true,\n\trtl = false,\n\tinitialScrollEnd = false,\n\tlocale,\n\theaderTextFormat = 'MMMM YYYY',\n\tsidebarTextFormat = 'ddd',\n\t...props\n}: HeatMapProps) => {\n\tconst scrollViewRef = useRef<ScrollView>(null)\n\tconst scrolledRef = useRef(false)\n\n\tconst onLayout = useCallback(() => {\n\t\tif (!scrolledRef.current && (rtl || initialScrollEnd)) {\n\t\t\tscrolledRef.current = true\n\t\t\tscrollViewRef.current?.scrollToEnd({ animated: false })\n\t\t}\n\t}, [rtl, initialScrollEnd])\n\n\tconst resolvedStartDate = startDate || dayjs().startOf('year').toDate()\n\tconst resolvedEndDate = endDate || dayjs().endOf('year').toDate()\n\n\tconst baseTheme =\n\t\tscheme === 'light' ? DEFAULT_LIGHT_THEME : DEFAULT_DARK_THEME\n\tconst customTheme = props[scheme] || {}\n\tconst theme = { ...baseTheme, ...props, ...customTheme }\n\n\tconst counts = countData(data)\n\n\tconst localeName = typeof locale === 'string' ? locale : locale?.name || 'en'\n\n\tconst months = getMonthlyData(resolvedStartDate, resolvedEndDate)\n\n\tconst displayedMonths = rtl ? [...months].toReversed() : months\n\n\tconst monthWidth = (cellSize + cellGap) * 7\n\tconst monthHeight =\n\t\t(isHeaderVisible ? headerTextFontSize + headerBottomSpace : 0) +\n\t\t(cellSize + cellGap) * 6\n\n\tconst sidebarWidth = isSidebarVisible ? sideBarTextFontSize * 3 : 0\n\n\tconst renderMonth = (\n\t\tmonthData: { month: Date; days: Date[] },\n\t\tindex: number,\n\t) => {\n\t\tconst startOffset = (dayjs(monthData.month).day() - weekStartsOn + 7) % 7\n\t\tconst xBase = sidebarWidth + index * (monthWidth + cellSize) // monthWidth + spacing between months\n\n\t\treturn (\n\t\t\t<G\n\t\t\t\tkey={`month-${index}`}\n\t\t\t\tx={xBase}\n\t\t\t>\n\t\t\t\t{isHeaderVisible && (\n\t\t\t\t\t<SvgText\n\t\t\t\t\t\tx={0}\n\t\t\t\t\t\ty={headerTextFontSize}\n\t\t\t\t\t\tfill={theme.headerTextColor}\n\t\t\t\t\t\tfontSize={headerTextFontSize}\n\t\t\t\t\t\tfontWeight='bold'\n\t\t\t\t\t>\n\t\t\t\t\t\t{dayjs(monthData.month).locale(localeName).format(headerTextFormat)}\n\t\t\t\t\t</SvgText>\n\t\t\t\t)}\n\t\t\t\t<G y={isHeaderVisible ? headerTextFontSize + headerBottomSpace : 0}>\n\t\t\t\t\t{monthData.days.map((day, dayIndex) => {\n\t\t\t\t\t\tconst dateStr = dayjs(day).format('YYYY-MM-DD')\n\t\t\t\t\t\tconst count = counts[dateStr] || 0\n\t\t\t\t\t\tconst color = getColor(\n\t\t\t\t\t\t\tcount,\n\t\t\t\t\t\t\ttheme.cellColor,\n\t\t\t\t\t\t\ttheme.cellDefaultColor,\n\t\t\t\t\t\t)\n\n\t\t\t\t\t\tconst gridIndex = dayIndex + startOffset\n\t\t\t\t\t\tconst col = gridIndex % 7\n\t\t\t\t\t\tconst row = Math.floor(gridIndex / 7)\n\n\t\t\t\t\t\tlet text: string | undefined\n\t\t\t\t\t\tif (isCellTextVisible) {\n\t\t\t\t\t\t\tif (cellText === 'date') text = dayjs(day).format('D')\n\t\t\t\t\t\t\telse if (cellText === 'count')\n\t\t\t\t\t\t\t\ttext = count > 0 ? count.toString() : undefined\n\t\t\t\t\t\t\telse text = dayjs(day).format('D') // default to date for monthly view\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<HeatMapCell\n\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\tkey={`day-${dayIndex}`}\n\t\t\t\t\t\t\t\tx={col * (cellSize + cellGap)}\n\t\t\t\t\t\t\t\ty={row * (cellSize + cellGap)}\n\t\t\t\t\t\t\t\tsize={cellSize}\n\t\t\t\t\t\t\t\tradius={cellRadius}\n\t\t\t\t\t\t\t\tcolor={color}\n\t\t\t\t\t\t\t\tcount={count}\n\t\t\t\t\t\t\t\tdate={day}\n\t\t\t\t\t\t\t\tpressable={pressable}\n\t\t\t\t\t\t\t\tonPress={onCellPress}\n\t\t\t\t\t\t\t\tonMouseEnter={onMouseEnter}\n\t\t\t\t\t\t\t\tonMouseLeave={onMouseLeave}\n\t\t\t\t\t\t\t\tcellText={text}\n\t\t\t\t\t\t\t\tcellTextColor={theme.cellTextColor}\n\t\t\t\t\t\t\t\tcellTextFontSize={cellTextFontSize}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</G>\n\t\t\t</G>\n\t\t)\n\t}\n\n\tconst totalWidth =\n\t\tsidebarWidth + displayedMonths.length * (monthWidth + cellSize)\n\n\tconst content = (\n\t\t<Svg\n\t\t\twidth={totalWidth}\n\t\t\theight={monthHeight}\n\t\t>\n\t\t\t{isSidebarVisible && (\n\t\t\t\t<G y={isHeaderVisible ? headerTextFontSize + headerBottomSpace : 0}>\n\t\t\t\t\t{[0, 1, 2, 3, 4, 5, 6].map((i) => {\n\t\t\t\t\t\tconst day = dayjs().day((i + weekStartsOn) % 7)\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<SvgText\n\t\t\t\t\t\t\t\tkey={`sidebar-${i}`}\n\t\t\t\t\t\t\t\tx={sidebarWidth - 8}\n\t\t\t\t\t\t\t\ty={\n\t\t\t\t\t\t\t\t\ti * (cellSize + cellGap) +\n\t\t\t\t\t\t\t\t\tcellSize / 2 +\n\t\t\t\t\t\t\t\t\tsideBarTextFontSize / 3\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tfill={theme.sidebarTextColor}\n\t\t\t\t\t\t\t\tfontSize={sideBarTextFontSize}\n\t\t\t\t\t\t\t\ttextAnchor='end'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{day.locale(localeName).format(sidebarTextFormat)}\n\t\t\t\t\t\t\t</SvgText>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</G>\n\t\t\t)}\n\t\t\t{displayedMonths.map((month, index) => renderMonth(month, index))}\n\t\t</Svg>\n\t)\n\n\tif (scrollable) {\n\t\treturn (\n\t\t\t<ScrollView\n\t\t\t\thorizontal\n\t\t\t\tref={scrollViewRef}\n\t\t\t\tonLayout={onLayout}\n\t\t\t\tshowsHorizontalScrollIndicator={false}\n\t\t\t\tcontentOffset={rtl ? { x: totalWidth, y: 0 } : { x: 0, y: 0 }}\n\t\t\t\tstyle={props.scrollStyle}\n\t\t\t>\n\t\t\t\t{content}\n\t\t\t</ScrollView>\n\t\t)\n\t}\n\n\treturn <View style={props.scrollStyle}>{content}</View>\n}\n"
  },
  {
    "path": "packages/heatmap/src/components/WeeklyHeatMap.tsx",
    "content": "import dayjs from 'dayjs'\nimport { JSX, useCallback, useRef } from 'react'\nimport { ScrollView, View } from 'react-native'\nimport Svg, { G, Text as SvgText } from 'react-native-svg'\n\nimport { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from '../constants/theme'\nimport { HeatMapProps } from '../types'\nimport { countData, getWeeklyData, getColor } from '../utils/calendar'\n\nimport HeatMapCell from './HeatMapCell'\n\nexport const WeeklyHeatMap = ({\n\tdata,\n\tstartDate,\n\tendDate,\n\tweekStartsOn = 0,\n\tcellSize = 20,\n\tcellRadius = 2,\n\tcellGap = 2,\n\tcellText,\n\tcellTextFontSize = 10,\n\theaderTextFontSize = 12,\n\theaderBottomSpace = 8,\n\tsideBarTextFontSize = 12,\n\tscheme = 'light',\n\tisHeaderVisible = true,\n\tisSidebarVisible = true,\n\tisCellTextVisible = false,\n\tpressable = true,\n\tonCellPress,\n\tonMouseEnter,\n\tonMouseLeave,\n\tscrollable = true,\n\trtl = false,\n\tinitialScrollEnd = false,\n\tlocale,\n\theaderTextFormat = 'MMM',\n\tsidebarTextFormat = 'ddd',\n\t...props\n}: HeatMapProps) => {\n\tconst scrollViewRef = useRef<ScrollView>(null)\n\tconst scrolledRef = useRef(false)\n\n\tconst onLayout = useCallback(() => {\n\t\tif (!scrolledRef.current && (rtl || initialScrollEnd)) {\n\t\t\tscrolledRef.current = true\n\t\t\tscrollViewRef.current?.scrollToEnd({ animated: false })\n\t\t}\n\t}, [rtl, initialScrollEnd])\n\n\tconst resolvedStartDate = startDate || dayjs().subtract(1, 'year').toDate()\n\tconst resolvedEndDate = endDate || new Date()\n\n\tconst baseTheme =\n\t\tscheme === 'light' ? DEFAULT_LIGHT_THEME : DEFAULT_DARK_THEME\n\tconst customTheme = props[scheme] || {}\n\tconst theme = { ...baseTheme, ...props, ...customTheme }\n\n\tconst counts = countData(data)\n\n\tconst localeName = typeof locale === 'string' ? locale : locale?.name || 'en'\n\n\tconst weeks = getWeeklyData(resolvedStartDate, resolvedEndDate, weekStartsOn)\n\n\tconst displayedWeeks = rtl ? [...weeks].toReversed() : weeks\n\n\tconst sidebarWidth = isSidebarVisible ? sideBarTextFontSize * 3 : 0\n\tconst headerHeight = isHeaderVisible\n\t\t? headerTextFontSize + headerBottomSpace\n\t\t: 0\n\n\tconst width = sidebarWidth + (cellSize + cellGap) * weeks.length\n\tconst height = headerHeight + (cellSize + cellGap) * 7\n\n\tconst renderHeader = () => {\n\t\tif (!isHeaderVisible) return null\n\n\t\tconst monthLabels: JSX.Element[] = []\n\t\tlet lastMonth = -1\n\n\t\tdisplayedWeeks.forEach((week, index) => {\n\t\t\tconst month = dayjs(week.weekStart).month()\n\t\t\tif (month !== lastMonth) {\n\t\t\t\tconst x = sidebarWidth + index * (cellSize + cellGap)\n\t\t\t\tmonthLabels.push(\n\t\t\t\t\t<SvgText\n\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\tkey={`month-${index}`}\n\t\t\t\t\t\tx={x}\n\t\t\t\t\t\ty={headerTextFontSize}\n\t\t\t\t\t\tfill={theme.headerTextColor}\n\t\t\t\t\t\tfontSize={headerTextFontSize}\n\t\t\t\t\t>\n\t\t\t\t\t\t{dayjs(week.weekStart).locale(localeName).format(headerTextFormat)}\n\t\t\t\t\t</SvgText>,\n\t\t\t\t)\n\t\t\t\tlastMonth = month\n\t\t\t}\n\t\t})\n\n\t\treturn monthLabels\n\t}\n\n\tconst renderSidebar = () => {\n\t\tif (!isSidebarVisible) return null\n\n\t\tconst dayLabels: JSX.Element[] = []\n\t\tfor (let i = 0; i < 7; i++) {\n\t\t\tconst day = dayjs().day((i + weekStartsOn) % 7)\n\t\t\tdayLabels.push(\n\t\t\t\t<SvgText\n\t\t\t\t\tkey={`day-${i}`}\n\t\t\t\t\tx={sidebarWidth - 8}\n\t\t\t\t\ty={\n\t\t\t\t\t\theaderHeight +\n\t\t\t\t\t\ti * (cellSize + cellGap) +\n\t\t\t\t\t\tcellSize / 2 +\n\t\t\t\t\t\tsideBarTextFontSize / 3\n\t\t\t\t\t}\n\t\t\t\t\tfill={theme.sidebarTextColor}\n\t\t\t\t\tfontSize={sideBarTextFontSize}\n\t\t\t\t\ttextAnchor='end'\n\t\t\t\t>\n\t\t\t\t\t{day.locale(localeName).format(sidebarTextFormat)}\n\t\t\t\t</SvgText>,\n\t\t\t)\n\t\t}\n\t\treturn dayLabels\n\t}\n\n\tconst content = (\n\t\t<Svg\n\t\t\twidth={width}\n\t\t\theight={height}\n\t\t>\n\t\t\t{renderHeader()}\n\t\t\t{renderSidebar()}\n\t\t\t<G\n\t\t\t\tx={sidebarWidth}\n\t\t\t\ty={headerHeight}\n\t\t\t>\n\t\t\t\t{displayedWeeks.map((week, weekIndex) => (\n\t\t\t\t\t<G\n\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\tkey={`week-${weekIndex}`}\n\t\t\t\t\t\tx={weekIndex * (cellSize + cellGap)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{week.days.map((day, dayIndex) => {\n\t\t\t\t\t\t\tconst dateStr = dayjs(day).format('YYYY-MM-DD')\n\t\t\t\t\t\t\tconst count = counts[dateStr] || 0\n\t\t\t\t\t\t\tconst color = getColor(\n\t\t\t\t\t\t\t\tcount,\n\t\t\t\t\t\t\t\ttheme.cellColor,\n\t\t\t\t\t\t\t\ttheme.cellDefaultColor,\n\t\t\t\t\t\t\t)\n\n\t\t\t\t\t\t\tlet text: string | undefined\n\t\t\t\t\t\t\tif (isCellTextVisible) {\n\t\t\t\t\t\t\t\tif (cellText === 'date') text = dayjs(day).format('D')\n\t\t\t\t\t\t\t\telse if (cellText === 'count')\n\t\t\t\t\t\t\t\t\ttext = count > 0 ? count.toString() : undefined\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<HeatMapCell\n\t\t\t\t\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\t\t\t\t\tkey={`day-${dayIndex}`}\n\t\t\t\t\t\t\t\t\tx={0}\n\t\t\t\t\t\t\t\t\ty={dayIndex * (cellSize + cellGap)}\n\t\t\t\t\t\t\t\t\tsize={cellSize}\n\t\t\t\t\t\t\t\t\tradius={cellRadius}\n\t\t\t\t\t\t\t\t\tcolor={color}\n\t\t\t\t\t\t\t\t\tcount={count}\n\t\t\t\t\t\t\t\t\tdate={day}\n\t\t\t\t\t\t\t\t\tpressable={pressable}\n\t\t\t\t\t\t\t\t\tonPress={onCellPress}\n\t\t\t\t\t\t\t\t\tonMouseEnter={onMouseEnter}\n\t\t\t\t\t\t\t\t\tonMouseLeave={onMouseLeave}\n\t\t\t\t\t\t\t\t\tcellText={text}\n\t\t\t\t\t\t\t\t\tcellTextColor={theme.cellTextColor}\n\t\t\t\t\t\t\t\t\tcellTextFontSize={cellTextFontSize}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t})}\n\t\t\t\t\t</G>\n\t\t\t\t))}\n\t\t\t</G>\n\t\t</Svg>\n\t)\n\n\tif (scrollable) {\n\t\treturn (\n\t\t\t<ScrollView\n\t\t\t\thorizontal\n\t\t\t\tref={scrollViewRef}\n\t\t\t\tonLayout={onLayout}\n\t\t\t\tshowsHorizontalScrollIndicator={false}\n\t\t\t\tcontentOffset={rtl ? { x: width, y: 0 } : { x: 0, y: 0 }}\n\t\t\t\tstyle={props.scrollStyle}\n\t\t\t>\n\t\t\t\t{content}\n\t\t\t</ScrollView>\n\t\t)\n\t}\n\n\treturn <View style={props.scrollStyle}>{content}</View>\n}\n"
  },
  {
    "path": "packages/heatmap/src/constants/theme.ts",
    "content": "import type { HeatMapColor } from '../types'\n\nexport const DEFAULT_LIGHT_THEME: Required<HeatMapColor> = {\n\theaderTextColor: '#666666',\n\tcellDefaultColor: '#ebedf0',\n\tcellTextColor: '#ffffff',\n\tcellColor: {\n\t\t1: '#9be9a8',\n\t\t2: '#40c463',\n\t\t3: '#30a14e',\n\t\t4: '#216e39',\n\t},\n\tsidebarTextColor: '#666666',\n}\n\nexport const DEFAULT_DARK_THEME: Required<HeatMapColor> = {\n\theaderTextColor: '#8b949e',\n\tcellDefaultColor: '#161b22',\n\tcellTextColor: '#ffffff',\n\tcellColor: {\n\t\t1: '#0e4429',\n\t\t2: '#006d32',\n\t\t3: '#26a641',\n\t\t4: '#39d353',\n\t},\n\tsidebarTextColor: '#8b949e',\n}\n"
  },
  {
    "path": "packages/heatmap/src/index.ts",
    "content": "export * from './components/MonthlyHeatMap'\nexport * from './components/WeeklyHeatMap'\nexport * from './types'\nexport * from './constants/theme'\n"
  },
  {
    "path": "packages/heatmap/src/types.ts",
    "content": "import type { StyleProp, TextStyle, ViewStyle } from 'react-native'\n\nexport type Day = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\nexport type HeatMapDailyProps = {\n\tdata: (Date | string)[] | Record<string, number>\n}\n\nexport type HeatMapWeeklyProps = {\n\tweekStartsOn?: Day\n\tcellText?: 'date' | 'count'\n}\n\nexport type HeatMapScheme = 'light' | 'dark'\n\nexport type HeatMapColor = {\n\theaderTextColor?: string\n\tcellDefaultColor?: string\n\tcellTextColor?: string\n\tcellColor?: Record<number, string>\n\tsidebarTextColor?: string\n}\n\nexport type HeatMapThemeProps = HeatMapColor & {\n\tscheme?: HeatMapScheme\n} & {\n\t[key in HeatMapScheme]?: HeatMapColor\n}\n\nexport type HeatMapDimensionsProps = {\n\theaderTextFontSize?: number\n\theaderBottomSpace?: number\n\tcellSize?: number\n\tcellRadius?: number\n\tcellGap?: number\n\tcellTextFontSize?: number\n\tsideBarTextFontSize?: number\n}\n\nexport type HeatMapStyle = {\n\tscrollStyle?: StyleProp<ViewStyle>\n\theaderTextAlign?: TextStyle['textAlign']\n}\n\nexport type HeatMapControllerProps = {\n\tpressable?: boolean\n\thoverable?: boolean\n\tscrollable?: boolean\n\trtl?: boolean\n\tinitialScrollEnd?: boolean\n\tisHeaderVisible?: boolean\n\tisCellTextVisible?: boolean\n\tisSidebarVisible?: boolean\n}\n\nexport type HeatMapFormatterProps = {\n\theaderTextFormat?: string\n\tsidebarTextFormat?: string\n\t/** Locale name or object */\n\tlocale?: string | { name: string }\n}\n\nexport type HeatMapDatetimeProps = {\n\tstartDate?: Date\n\tendDate?: Date\n\thiddenDays?: Day[]\n}\n\nexport type HeatMapActionsProps = {\n\tonCellPress?: (params: { date: Date; count: number }) => void\n\tonMouseEnter?: (params: {\n\t\tdate: Date\n\t\tx: number\n\t\ty: number\n\t\tcount: number\n\t}) => void\n\tonMouseLeave?: () => void\n}\n\nexport type HeatMapProps = HeatMapDailyProps &\n\tHeatMapWeeklyProps &\n\tHeatMapThemeProps &\n\tHeatMapDimensionsProps &\n\tHeatMapControllerProps &\n\tHeatMapFormatterProps &\n\tHeatMapDatetimeProps &\n\tHeatMapActionsProps &\n\tHeatMapStyle\n"
  },
  {
    "path": "packages/heatmap/src/utils/calendar.ts",
    "content": "import dayjs, { type Dayjs } from 'dayjs'\nimport localeData from 'dayjs/plugin/localeData'\nimport weekday from 'dayjs/plugin/weekday'\n\nimport type { Day } from '../types'\n\ndayjs.extend(localeData)\ndayjs.extend(weekday)\n\nexport function getDaysInRange(startDate: Date, endDate: Date): Date[] {\n\tconst start = dayjs(startDate).startOf('day')\n\tconst end = dayjs(endDate).startOf('day')\n\tconst days: Date[] = []\n\tlet current = start\n\n\twhile (current.isBefore(end) || current.isSame(end)) {\n\t\tdays.push(current.toDate())\n\t\tcurrent = current.add(1, 'day')\n\t}\n\n\treturn days\n}\n\nexport function getMonthlyData(startDate: Date, endDate: Date) {\n\tconst start = dayjs(startDate).startOf('month')\n\tconst end = dayjs(endDate).endOf('month')\n\n\t// Group days by month\n\tconst months: { month: Date; days: Date[] }[] = []\n\tlet current = start\n\n\twhile (current.isBefore(end)) {\n\t\tconst monthStart = current.startOf('month')\n\t\tconst monthEnd = current.endOf('month')\n\t\tconst days = getDaysInRange(monthStart.toDate(), monthEnd.toDate())\n\t\tmonths.push({ month: monthStart.toDate(), days })\n\t\tcurrent = current.add(1, 'month')\n\t}\n\n\treturn months\n}\n\nconst getStartOfWeek = (date: Dayjs, startDay: Day) => {\n\tconst day = date.day()\n\tconst diff = (day < startDay ? 7 : 0) + day - startDay\n\treturn date.subtract(diff, 'day').startOf('day')\n}\n\nexport function getWeeklyData(\n\tstartDate: Date,\n\tendDate: Date,\n\tweekStartsOn: Day = 0,\n) {\n\tconst startOfWeek = getStartOfWeek(dayjs(startDate), weekStartsOn)\n\tconst end = dayjs(endDate).endOf('day')\n\n\tconst weeks: { weekStart: Date; days: Date[] }[] = []\n\tlet current = startOfWeek\n\n\twhile (current.isBefore(end)) {\n\t\tconst weekDays: Date[] = []\n\t\tfor (let i = 0; i < 7; i++) {\n\t\t\tweekDays.push(current.add(i, 'day').toDate())\n\t\t}\n\t\tweeks.push({ weekStart: current.toDate(), days: weekDays })\n\t\tcurrent = current.add(1, 'week')\n\t}\n\n\treturn weeks\n}\n\nexport function countData(\n\tdata: (Date | string)[] | Record<string, number>,\n): Record<string, number> {\n\tif (!Array.isArray(data)) {\n\t\treturn data\n\t}\n\n\tconst counts: Record<string, number> = {}\n\tdata.forEach((item) => {\n\t\tconst dateStr = dayjs(item).format('YYYY-MM-DD')\n\t\tcounts[dateStr] = (counts[dateStr] || 0) + 1\n\t})\n\n\treturn counts\n}\n\nexport function getLevel(\n\tcount: number,\n\tcellColor?: Record<number, string>,\n): number {\n\tif (!cellColor) return 0\n\tconst levels = Object.keys(cellColor)\n\t\t.map(Number)\n\t\t.sort((a, b) => b - a)\n\tfor (const level of levels) {\n\t\tif (count >= level) {\n\t\t\treturn level\n\t\t}\n\t}\n\treturn 0\n}\n\nexport function getColor(\n\tcount: number,\n\tcellColor: Record<number, string>,\n\tdefaultColor: string,\n): string {\n\tconst level = getLevel(count, cellColor)\n\treturn level > 0 ? cellColor[level] : defaultColor\n}\n"
  },
  {
    "path": "packages/heatmap/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"module\": \"CommonJS\",\n\t\t\"strict\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"jsx\": \"react-jsx\"\n\t},\n\t\"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "packages/image-theme-colors/.gitignore",
    "content": "# OSX\n#\n.DS_Store\n\n# VSCode\n.vscode/\njsconfig.json\n\n# Xcode\n#\nbuild/\n*.pbxuser\n!default.pbxuser\n*.mode1v3\n!default.mode1v3\n*.mode2v3\n!default.mode2v3\n*.perspectivev3\n!default.perspectivev3\nxcuserdata\n*.xccheckout\n*.moved-aside\nDerivedData\n*.hmap\n*.ipa\n*.xcuserstate\nproject.xcworkspace\n\n# Android/IJ\n#\n.classpath\n.cxx\n.gradle\n.idea\n.project\n.settings\nlocal.properties\nandroid.iml\nandroid/app/libs\nandroid/keystores/debug.keystore\n\n# Cocoapods\n#\nexample/ios/Pods\n\n# Ruby\nexample/vendor/\n\n# node.js\n#\nnode_modules/\nnpm-debug.log\nyarn-debug.log\nyarn-error.log\n\n# Expo\n.expo/*\n"
  },
  {
    "path": "packages/image-theme-colors/.npmignore",
    "content": "# Exclude all top-level hidden directories by convention\n/.*/\n\n# Exclude tarballs generated by `npm pack`\n/*.tgz\n\n__mocks__\n__tests__\n\n/babel.config.js\n/android/src/androidTest/\n/android/src/test/\n/android/build/\n/example/\n"
  },
  {
    "path": "packages/image-theme-colors/README.md",
    "content": "# @bbplayer/image-theme-colors\n\n基于 Expo ImageRef 的高性能图片主题色提取工具。\n\n## 简介\n\n这是一个专门为 BBPlayer 开发的主题色提取模块。它基于 Android Palette 实现，直接传入 Expo 的 `ImageRef` 对象，实现零拷贝提取，极大地提升了在 React Native 环境下处理大尺寸封面的性能。\n\n## 功能特性\n\n- **零拷贝**：直接操作原生内存引用的图片对象，避免了 Base64 转换带来的开销。\n- **性能卓越**：针对 Android 设备进行了深度优化。\n- **Material 3 适配**：提取的颜色可直接用于生成 Material Design 3 配色方案。\n\n## 安装\n\n```bash\npnpm add @bbplayer/image-theme-colors\n```\n\n## 使用说明\n\n本模块由于依赖 `expo-image` 的内部引用，目前主要建议在 BBPlayer 及其关联组件中使用。\n\n```typescript\nimport { getThemeColors } from '@bbplayer/image-theme-colors'\n\nconst colors = await getThemeColors(imageRef)\n```\n"
  },
  {
    "path": "packages/image-theme-colors/android/build.gradle",
    "content": "apply plugin: 'com.android.library'\n\nimport groovy.json.JsonSlurper\n\ndef packageJsonFile = new File(projectDir, '../package.json')\ndef packageJson = new JsonSlurper().parseText(packageJsonFile.text)\n\ngroup = 'expo.modules.imagethemecolors'\nversion = packageJson.version\n\ndef expoModulesCorePlugin = new File(project(\":expo-modules-core\").projectDir.absolutePath, \"ExpoModulesCorePlugin.gradle\")\napply from: expoModulesCorePlugin\napplyKotlinExpoModulesCorePlugin()\nuseCoreDependencies()\nuseExpoPublishing()\n\n// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.\n// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.\n// Most of the time, you may like to manage the Android SDK versions yourself.\ndef useManagedAndroidSdkVersions = false\nif (useManagedAndroidSdkVersions) {\n    useDefaultAndroidSdkVersions()\n} else {\n    buildscript {\n        // Simple helper that allows the root project to override versions declared by this library.\n        ext.safeExtGet = { prop, fallback ->\n            rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback\n        }\n    }\n    project.android {\n        compileSdkVersion safeExtGet(\"compileSdkVersion\", 36)\n        defaultConfig {\n            minSdkVersion safeExtGet(\"minSdkVersion\", 24)\n            targetSdkVersion safeExtGet(\"targetSdkVersion\", 36)\n        }\n    }\n}\n\nandroid {\n    namespace \"expo.modules.imagethemecolors\"\n    defaultConfig {\n        versionCode 1\n        versionName packageJson.version\n    }\n    lintOptions {\n        abortOnError false\n    }\n}\n\ndependencies {\n    implementation \"androidx.palette:palette-ktx:1.0.0\"\n}"
  },
  {
    "path": "packages/image-theme-colors/android/src/main/AndroidManifest.xml",
    "content": "<manifest></manifest>\n"
  },
  {
    "path": "packages/image-theme-colors/android/src/main/java/expo/modules/imagethemecolors/ExpoImageThemeColorsModule.kt",
    "content": "@file:OptIn(EitherType::class)\n\npackage expo.modules.imagethemecolors\n\nimport android.graphics.Bitmap\nimport android.graphics.drawable.BitmapDrawable\nimport android.graphics.drawable.Drawable\nimport androidx.palette.graphics.Palette\nimport expo.modules.kotlin.apifeatures.EitherType\nimport expo.modules.kotlin.exception.CodedException\nimport expo.modules.kotlin.exception.Exceptions\nimport expo.modules.kotlin.exception.toCodedException\nimport expo.modules.kotlin.functions.Coroutine\nimport expo.modules.kotlin.modules.Module\nimport expo.modules.kotlin.modules.ModuleDefinition\nimport expo.modules.kotlin.sharedobjects.SharedRef\nimport expo.modules.kotlin.types.EitherOfThree\nimport expo.modules.kotlin.types.toKClass\n\ninternal class ImageLoadingFailedException(cause: CodedException?) :\n    CodedException(message = \"Could not load the image from sharedRef\", cause)\n\nclass ExpoImageThemeColorsModule : Module() {\n    companion object {\n        private const val TAG = \"ExpoImageThemeColor\"\n    }\n\n    override fun definition() = ModuleDefinition {\n        Name(\"ExpoImageThemeColors\")\n\n        AsyncFunction(\"extractThemeColorAsync\") Coroutine { imageSource: EitherOfThree<String, SharedRef<Bitmap>, SharedRef<Drawable>>\n            ->\n            val bitmap = when {\n                imageSource.`is`(String::class) -> getBitmapFromUrl(imageSource.get(String::class))\n                imageSource.`is`(toKClass<SharedRef<Bitmap>>()) -> imageSource.get(toKClass<SharedRef<Bitmap>>()).ref\n                else -> (imageSource.get(toKClass<SharedRef<Drawable>>()).ref as? BitmapDrawable)?.bitmap\n                    ?: throw Exceptions.IllegalArgument(\"Shared drawable cannot be converted to a bitmap.\")\n            }\n            android.util.Log.d(TAG, \"get bitmap\")\n\n            val palette = Palette.from(bitmap).generate()\n\n            return@Coroutine mapOf(\n                \"width\" to bitmap.width,\n                \"height\" to bitmap.height,\n                \"dominant\" to palette.dominantSwatch.toSwatchMap(),\n                \"vibrant\" to palette.vibrantSwatch.toSwatchMap(),\n                \"lightVibrant\" to palette.lightVibrantSwatch.toSwatchMap(),\n                \"darkVibrant\" to palette.darkVibrantSwatch.toSwatchMap(),\n                \"muted\" to palette.mutedSwatch.toSwatchMap(),\n                \"lightMuted\" to palette.lightMutedSwatch.toSwatchMap(),\n                \"darkMuted\" to palette.darkMutedSwatch.toSwatchMap()\n            )\n        }\n    }\n\n    private fun getBitmapFromUrl(urlString: String): Bitmap {\n        try {\n            val url = java.net.URL(urlString)\n            return android.graphics.BitmapFactory.decodeStream(url.openStream())\n        } catch (e: Exception) {\n            throw ImageLoadingFailedException(e.toCodedException())\n        }\n    }\n\n\n    private fun Int.toHexColor(): String {\n        return String.format(\"#%06X\", (0xFFFFFF and this))\n    }\n\n    private fun Palette.Swatch?.toSwatchMap(): Map<String, Any>? {\n        if (this == null) {\n            return null\n        }\n\n        return mapOf(\n            \"hex\" to this.rgb.toHexColor(),\n            \"titleTextColor\" to this.titleTextColor.toHexColor(),\n            \"bodyTextColor\" to this.bodyTextColor.toHexColor(),\n            \"population\" to this.population\n        )\n    }\n}"
  },
  {
    "path": "packages/image-theme-colors/example/.gitignore",
    "content": "# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files\n\n# dependencies\nnode_modules/\n\n# Expo\n.expo/\ndist/\nweb-build/\nexpo-env.d.ts\n\n# Native\n.kotlin/\n*.orig.*\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n\n# Metro\n.metro-health-check*\n\n# debug\nnpm-debug.*\nyarn-debug.*\nyarn-error.*\n\n# macOS\n.DS_Store\n*.pem\n\n# local env files\n.env*.local\n\n# typescript\n*.tsbuildinfo\n\n# generated native folders\n/ios\n/android\n"
  },
  {
    "path": "packages/image-theme-colors/example/App.tsx",
    "content": "import ExpoImageThemeColors from '@bbplayer/image-theme-colors'\nimport { useImage } from 'expo-image'\nimport {\n\tButton,\n\tSafeAreaView,\n\tScrollView,\n\tText,\n\tView,\n\tAlert,\n\tStyleSheet,\n} from 'react-native'\n\nexport default function App() {\n\tconst imageUrl =\n\t\t'https://i2.hdslb.com/bfs/archive/aa7b946340dc5834309b4f529a5d3b52c69cfac8.jpg'\n\tconst imageRef = useImage(imageUrl, {\n\t\tmaxWidth: 200,\n\t\tmaxHeight: 200,\n\t})\n\n\tconst handlePress = async () => {\n\t\tif (!imageRef) {\n\t\t\tAlert.alert('Error', 'Image not loaded yet')\n\t\t\treturn\n\t\t}\n\n\t\ttry {\n\t\t\tconsole.log('Extracting colors...')\n\t\t\tconst result = await ExpoImageThemeColors.extractThemeColorAsync(imageRef)\n\t\t\tconsole.log('Extraction Result:', JSON.stringify(result, null, 2))\n\t\t\tAlert.alert('Result', JSON.stringify(result, null, 2))\n\t\t} catch (e) {\n\t\t\tconsole.error(e)\n\t\t\tif (e instanceof Error) {\n\t\t\t\tAlert.alert('Error', e.message ?? 'Unknown error')\n\t\t\t}\n\t\t}\n\t}\n\n\treturn (\n\t\t<SafeAreaView style={styles.container}>\n\t\t\t<ScrollView contentContainerStyle={styles.scrollContainer}>\n\t\t\t\t<Text style={styles.header}>Expo Image Theme Colors</Text>\n\n\t\t\t\t<View style={styles.card}>\n\t\t\t\t\t<Text style={styles.label}>Target Image:</Text>\n\t\t\t\t\t<Text style={styles.value}>{imageUrl}</Text>\n\t\t\t\t</View>\n\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Extract Colors'\n\t\t\t\t\tonPress={handlePress}\n\t\t\t\t/>\n\t\t\t</ScrollView>\n\t\t</SafeAreaView>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\tbackgroundColor: '#fff',\n\t},\n\tscrollContainer: {\n\t\tpadding: 20,\n\t\talignItems: 'center',\n\t},\n\theader: {\n\t\tfontSize: 20,\n\t\tfontWeight: 'bold',\n\t\tmarginBottom: 20,\n\t\tmarginTop: 10,\n\t},\n\tcard: {\n\t\tpadding: 15,\n\t\tborderRadius: 10,\n\t\tbackgroundColor: '#f0f0f0',\n\t\twidth: '100%',\n\t\tmarginBottom: 20,\n\t},\n\tlabel: {\n\t\tfontWeight: 'bold',\n\t\tmarginBottom: 5,\n\t},\n\tvalue: {\n\t\tfontSize: 12,\n\t\tcolor: '#333',\n\t},\n})\n"
  },
  {
    "path": "packages/image-theme-colors/example/app.json",
    "content": "{\n\t\"expo\": {\n\t\t\"name\": \"expo-image-theme-colors-example\",\n\t\t\"slug\": \"expo-image-theme-colors-example\",\n\t\t\"version\": \"1.0.0\",\n\t\t\"orientation\": \"portrait\",\n\t\t\"icon\": \"./assets/icon.png\",\n\t\t\"userInterfaceStyle\": \"light\",\n\t\t\"newArchEnabled\": true,\n\t\t\"splash\": {\n\t\t\t\"image\": \"./assets/splash-icon.png\",\n\t\t\t\"resizeMode\": \"contain\",\n\t\t\t\"backgroundColor\": \"#ffffff\"\n\t\t},\n\t\t\"ios\": {\n\t\t\t\"supportsTablet\": true,\n\t\t\t\"bundleIdentifier\": \"expo.modules.imagethemecolors.example\"\n\t\t},\n\t\t\"android\": {\n\t\t\t\"adaptiveIcon\": {\n\t\t\t\t\"foregroundImage\": \"./assets/adaptive-icon.png\",\n\t\t\t\t\"backgroundColor\": \"#ffffff\"\n\t\t\t},\n\t\t\t\"edgeToEdgeEnabled\": true,\n\t\t\t\"predictiveBackGestureEnabled\": false,\n\t\t\t\"package\": \"expo.modules.imagethemecolors.example\"\n\t\t},\n\t\t\"web\": {\n\t\t\t\"favicon\": \"./assets/favicon.png\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/image-theme-colors/example/babel.config.js",
    "content": "module.exports = function (api) {\n\tapi.cache(true)\n\treturn {\n\t\tpresets: ['babel-preset-expo'],\n\t}\n}\n"
  },
  {
    "path": "packages/image-theme-colors/example/index.ts",
    "content": "import { registerRootComponent } from 'expo'\n\nimport App from './App'\n\n// registerRootComponent calls AppRegistry.registerComponent('main', () => App);\n// It also ensures that whether you load the app in Expo Go or in a native build,\n// the environment is set up appropriately\nregisterRootComponent(App)\n"
  },
  {
    "path": "packages/image-theme-colors/example/metro.config.js",
    "content": "// Learn more https://docs.expo.io/guides/customizing-metro\nconst { getDefaultConfig } = require('expo/metro-config')\nconst path = require('path')\n\nconst config = getDefaultConfig(__dirname)\n\n// npm v7+ will install ../node_modules/react and ../node_modules/react-native because of peerDependencies.\n// To prevent the incompatible react-native between ./node_modules/react-native and ../node_modules/react-native,\n// excludes the one from the parent folder when bundling.\nconfig.resolver.blockList = [\n\t...Array.from(config.resolver.blockList ?? []),\n\tnew RegExp(path.resolve('..', 'node_modules', 'react')),\n\tnew RegExp(path.resolve('..', 'node_modules', 'react-native')),\n]\n\nconfig.resolver.nodeModulesPaths = [\n\tpath.resolve(__dirname, './node_modules'),\n\tpath.resolve(__dirname, '../node_modules'),\n]\n\nconfig.resolver.extraNodeModules = {\n\t'expo-image-theme-colors': '..',\n}\n\nconfig.watchFolders = [path.resolve(__dirname, '..')]\n\nconfig.transformer.getTransformOptions = async () => ({\n\ttransform: {\n\t\texperimentalImportSupport: false,\n\t\tinlineRequires: true,\n\t},\n})\n\nmodule.exports = config\n"
  },
  {
    "path": "packages/image-theme-colors/example/package.json",
    "content": "{\n\t\"name\": \"expo-image-theme-colors-example\",\n\t\"version\": \"1.0.0\",\n\t\"private\": true,\n\t\"main\": \"index.ts\",\n\t\"scripts\": {\n\t\t\"android\": \"expo run:android\",\n\t\t\"ios\": \"expo run:ios\",\n\t\t\"start\": \"expo start\"\n\t},\n\t\"dependencies\": {\n\t\t\"expo\": \"~54.0.22\",\n\t\t\"expo-image\": \"~3.0.10\",\n\t\t\"react\": \"19.1.0\",\n\t\t\"react-native\": \"0.81.5\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/react\": \"~19.1.0\"\n\t},\n\t\"expo\": {\n\t\t\"autolinking\": {\n\t\t\t\"nativeModulesDir\": \"..\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/image-theme-colors/example/tsconfig.json",
    "content": "{\n\t\"extends\": \"expo/tsconfig.base\",\n\t\"compilerOptions\": {\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"paths\": {\n\t\t\t\"@bbplayer/image-theme-colors\": [\"../src/index\"],\n\t\t\t\"@bbplayer/image-theme-colors/*\": [\"../src/*\"]\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/image-theme-colors/expo-module.config.json",
    "content": "{\n\t\"platforms\": [\"android\", \"ios\"],\n\t\"android\": {\n\t\t\"modules\": [\"expo.modules.imagethemecolors.ExpoImageThemeColorsModule\"]\n\t},\n\t\"ios\": {\n\t\t\"modules\": [\"ExpoImageThemeColorsModule\"]\n\t}\n}\n"
  },
  {
    "path": "packages/image-theme-colors/ios/ExpoImageThemeColors.podspec",
    "content": "require 'json'\n\npackage = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))\n\nPod::Spec.new do |s|\n  s.name           = 'ExpoImageThemeColors'\n  s.version        = package['version']\n  s.summary        = package['description']\n  s.description    = package['description']\n  s.license        = package['license']\n  s.author         = package['author']\n  s.homepage       = package['homepage']\n  s.platforms      = {\n    :ios => '15.1',\n    :tvos => '15.1'\n  }\n  s.swift_version  = '5.9'\n  s.source         = { git: 'https://github.com/bbplayer-app/bbplayer.git' }\n  s.static_framework = true\n\n  s.dependency 'ExpoModulesCore'\n  s.dependency 'swift-vibrant' \n  \n  # Swift/Objective-C compatibility\n  s.pod_target_xcconfig = {\n    'DEFINES_MODULE' => 'YES',\n  }\n\n  s.source_files = \"**/*.{h,m,swift}\"\nend\n"
  },
  {
    "path": "packages/image-theme-colors/ios/ExpoImageThemeColorsModule.swift",
    "content": "import ExpoModulesCore\nimport swift_vibrant\nimport UIKit\n\npublic class ExpoImageThemeColorsModule: Module {\n  public func definition() -> ModuleDefinition {\n    Name(\"ExpoImageThemeColors\")\n\n    AsyncFunction(\"extractThemeColorAsync\") { (source: Either<URL, SharedRef<UIImage>>) -> [String: Any] in\n        let image: UIImage\n        \n        if let url: URL = source.get() {\n            // Load image from URL\n            let data = try Data(contentsOf: url)\n            guard let img = UIImage(data: data) else {\n                throw Exception(name: \"ImageLoadingFailed\", description: \"Could not load image from URL\")\n            }\n            image = img\n        } else if let sharedRef: SharedRef<UIImage> = source.get() {\n            image = sharedRef.ref\n        } else {\n             throw Exception(name: \"InvalidSource\", description: \"Invalid image source provided\")\n        }\n\n        // Generate palette\n        let palette = Vibrant.from(image).getPalette()\n        \n        return [\n            \"width\": image.size.width,\n            \"height\": image.size.height,\n            \"dominant\": (palette.Vibrant ?? palette.Muted)?.toDictionary() ?? [:],\n            \"vibrant\": palette.Vibrant?.toDictionary() ?? [:],\n            \"lightVibrant\": palette.LightVibrant?.toDictionary() ?? [:],\n            \"darkVibrant\": palette.DarkVibrant?.toDictionary() ?? [:],\n            \"muted\": palette.Muted?.toDictionary() ?? [:],\n            \"lightMuted\": palette.LightMuted?.toDictionary() ?? [:],\n            \"darkMuted\": palette.DarkMuted?.toDictionary() ?? [:]\n        ]\n    }\n  }\n}\n\nextension Swatch {\n    func toDictionary() -> [String: Any] {\n        return [\n            \"hex\": self.uiColor.toHexString(),\n            \"titleTextColor\": self.titleTextColor.toHexString(),\n            \"bodyTextColor\": self.bodyTextColor.toHexString(),\n            \"population\": self.population\n        ]\n    }\n}\n\nextension UIColor {\n    func toHexString() -> String {\n        var r: CGFloat = 0\n        var g: CGFloat = 0\n        var b: CGFloat = 0\n        var a: CGFloat = 0\n        // Use getRed to handle different color spaces (like Display P3)\n        if self.getRed(&r, green: &g, blue: &b, alpha: &a) {\n            let rgb: Int = (Int)(r*255)<<16 | (Int)(g*255)<<8 | (Int)(b*255)<<0\n            return String(format:\"#%06X\", rgb)\n        }\n        return \"#000000\"\n    }\n}\n"
  },
  {
    "path": "packages/image-theme-colors/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/image-theme-colors\",\n\t\"version\": \"0.3.0\",\n\t\"description\": \"A module to extract theme colors using expo ImageRef\",\n\t\"keywords\": [\n\t\t\"ExpoImageThemeColors\",\n\t\t\"color-palette\",\n\t\t\"expo\",\n\t\t\"expo-image-theme-colors\",\n\t\t\"react-native\",\n\t\t\"theme-extraction\"\n\t],\n\t\"homepage\": \"https://github.com/bbplayer-app/bbplayer/tree/dev/packages/expo-image-theme-colors\",\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer/issues\"\n\t},\n\t\"license\": \"MIT\",\n\t\"author\": \"Roitium <65794453+roitium@users.noreply.github.com> (https://github.com/roitium)\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer.git\",\n\t\t\"directory\": \"packages/expo-image-theme-colors\"\n\t},\n\t\"files\": [\n\t\t\"build\",\n\t\t\"src\",\n\t\t\"android\",\n\t\t\"ios\",\n\t\t\"expo-module.config.json\"\n\t],\n\t\"sideEffects\": false,\n\t\"main\": \"src/index.ts\",\n\t\"types\": \"src/index.ts\",\n\t\"scripts\": {\n\t\t\"build\": \"expo-module build\",\n\t\t\"clean\": \"expo-module clean\",\n\t\t\"expo-module\": \"expo-module\",\n\t\t\"lint\": \"expo-module lint\",\n\t\t\"open:android\": \"open -a \\\"Android Studio\\\" example/android\",\n\t\t\"open:ios\": \"xed example/ios\",\n\t\t\"prepublishOnly\": \"expo-module prepublishOnly\",\n\t\t\"test\": \"expo-module test\"\n\t},\n\t\"devDependencies\": {\n\t\t\"expo\": \"55.0.4\",\n\t\t\"expo-module-scripts\": \"^5.0.7\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\",\n\t\t\"react-native-worklets\": \"0.7.4\"\n\t},\n\t\"peerDependencies\": {\n\t\t\"expo\": \"55.0.4\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\",\n\t\t\"react-native-worklets\": \"0.7.4\"\n\t}\n}\n"
  },
  {
    "path": "packages/image-theme-colors/src/ExpoImageThemeColors.types.ts",
    "content": "/**\n * 代表一个 swatch 的所有信息\n */\nexport interface ColorInfo {\n\t/** 颜色的 6 位 Hex 值 (e.g., \"#FF0000\") */\n\thex: string\n\n\t/** 推荐的标题文本颜色 (e.g., \"#FFFFFF\") */\n\ttitleTextColor: string\n\n\t/** 推荐的正文文本颜色 (e.g., \"#000000\") */\n\tbodyTextColor: string\n\n\t/** 这个颜色在图片中占了多少像素点 */\n\tpopulation: number\n}\n\ntype SwatchName =\n\t| 'dominant'\n\t| 'vibrant'\n\t| 'lightVibrant'\n\t| 'darkVibrant'\n\t| 'muted'\n\t| 'lightMuted'\n\t| 'darkMuted'\n\ntype PaletteSwatches = Record<SwatchName, ColorInfo | null>\n\nexport type ExtractedPalette = {\n\t/** 图片宽度 (px) */\n\twidth: number\n\t/** 图片高度 (px) */\n\theight: number\n} & PaletteSwatches\n"
  },
  {
    "path": "packages/image-theme-colors/src/ExpoImageThemeColorsModule.ts",
    "content": "import { NativeModule, requireNativeModule, type SharedRef } from 'expo'\n\nimport type { ExtractedPalette } from './ExpoImageThemeColors.types'\nimport type { ImageRef } from './ImageRef'\n\ndeclare class ExpoImageThemeColorsModule extends NativeModule {\n\textractThemeColorAsync(\n\t\tsource: string | SharedRef<'image'> | ImageRef,\n\t): Promise<ExtractedPalette | null>\n}\n\nexport default requireNativeModule<ExpoImageThemeColorsModule>(\n\t'ExpoImageThemeColors',\n)\n"
  },
  {
    "path": "packages/image-theme-colors/src/ImageRef.ts",
    "content": "import { SharedRef } from 'expo'\n\n/**\n * A reference to a native instance of the image.\n */\nexport declare class ImageRef extends SharedRef<'image'> {\n\t/**\n\t * Width of the image.\n\t */\n\twidth: number\n\n\t/**\n\t * Height of the image.\n\t */\n\theight: number\n}\n"
  },
  {
    "path": "packages/image-theme-colors/src/index.ts",
    "content": "export { default } from './ExpoImageThemeColorsModule'\nexport * from './ExpoImageThemeColors.types'\n"
  },
  {
    "path": "packages/image-theme-colors/tsconfig.json",
    "content": "// @generated by expo-module-scripts\n{\n\t\"extends\": \"expo-module-scripts/tsconfig.base\",\n\t\"compilerOptions\": {\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"outDir\": \"./build\"\n\t},\n\t\"include\": [\"./src\"],\n\t\"exclude\": [\"**/__mocks__/*\", \"**/__tests__/*\", \"**/__rsc_tests__/*\"]\n}\n"
  },
  {
    "path": "packages/logs/.gitignore",
    "content": "node_modules\ncoverage\n.vscode\n\n.DS_store\n.idea\n\n.yarn\n.pnp.*\n"
  },
  {
    "path": "packages/logs/.travis.yml",
    "content": "language: node_js\nnode_js:\n  - stable\ninstall:\n  - npm install\n  - npm run build\nscript:\n  - npm run test\nafter_success:\n  - npm run test:cov && bash <(curl -s https://codecov.io/bash) -e TRAVIS_NODE_VERSION\n"
  },
  {
    "path": "packages/logs/CHANGELOG.md",
    "content": "## [5.5.0] - 07-09-2025\n\n- Add extension on Crashlytics errors (as fileName)\n\n## [5.4.0] - 06-09-2025\n\n- Fix Readme (issue #115)\n- Fix Sentry compatibility (issue #118)\n- Fix Crashlytics error report (issue #110)\n- Crashlytics: a log is only recorded as an error if its level matches a level defined in the `errorLevels` option\n- Improve object serialization logic\n- Change FS packages type to any (issue #112)\n- Minor bugfix\n\n## [5.3.0] - 25-10-2024\n\n- Improve type definitions (pr #109 by @DanielSRS)\n- Minor bugfix\n\n#### BREAKING CHANGES (only for typescript config)\n\nStarting from version v 5.3.0, the definition of types has been improved: transportOptions are now strongly typed based on the specific transport specified in the configuration, and it is no longer necessary to specify log level types, as these are also taken directly from the configuration.\n\nThe configuration must be passed inline for it to work correctly and the log level type definitions that needed to be set up until version 5.2.2 must now be removed:\n\n```typescript\nimport { logger } from 'react-native-logs'\n\nvar log = logger.createLogger({\n\tlevels: {\n\t\ttrace: 0,\n\t\tinfo: 1,\n\t\terror: 2,\n\t},\n})\n\nlog.trace('message') // correct log call\nlog.silly('message') // typescript error, \"silly\" method does not exist\n```\n\nAdditionally, it is now possible to specify custom options in your custom transport:\n\n```typescript\nconst customTransport: transportFunctionType<{ myCustomOption: string }> = (\n\tprops,\n) => {\n\t// ...\n}\n```\n\n## [5.2.2] - 21-10-2024\n\n- Reverting to the old merge config function\n\n## [5.2.1] - 18-10-2024\n\n- Minor bugfix\n\n## [5.2.0] - 17-10-2024\n\n- Ensures JSON.stringify print nested objects correctly (issue #97)\n- Only merge non undefined config values (pr #105 by @SYoder1)\n- Correct README for Sentry logging (pr #104 by @ssorallen)\n- Add crashlytics transport (pr #91 by @chad-aijinet)\n- Added fileNameDateType option to the file transport for selecting the date format\n- Minor bugfix\n\n## [5.1.0] - 26-01-2024\n\n- Ensures JSON.stringify correctly (Thanks @iago-f-s-e)\n- Added formatFunc option (Thanks @chmac)\n- Added ability to set errorLevels on sentry transport\n- Correct format function type name in default stringify func\n- Added the confg option fixedExtLvlLength, allowing for uniform extension and level lengths by adding spaces, ensuring aligned logs\n- Minor bugfix\n\n## [5.0.1] - 04-07-2022\n\n- Fixed fileName in fileAsyncTranport\n- in fileName now you can pass {date-today}\n\n## [5.0.0] - 30-06-2022\n\n- Simplified init configuration (thanks to @Harjot1Singh)\n- Added levels typing\n- Customizable stringify function\n- Transport config option now accept array of transports\n- fileAsyncTransport can be configured to create a new file everyday\n- customizable console.log function in consoleTrasport\n- Added patchConsole method\n- dateFormat now accept a custom function\n\n#### BREAKING CHANGES\n\nThere are no real breaking changes in this version, only the default async function has been changed, which is now a simple setTimeout to 0 ms.\n\n## [4.0.1] - 15-01-2022\n\n- enable() and disable() methods can now enable or disable extensions\n\n## [4.0.0] - 03-01-2022\n\nIn this new major update many of the features requested in the previous issues have been fixed, introduced or improved:\n\n- reversed the extension mechanism, now if they are not specified, they will all be displayed\n- added the ability to choose the colors of the levels for the consoleTransport\n- added the ability to choose the colors of extensions in consoleTransport\n- added a transport that prints logs with the native console methods (log, info, error, etc ...)\n- fixed type exports\n- minor bugfix\n\n#### BREAKING CHANGES\n\n- from this version if no extensions are specified in the configuration then all are printed, otherwise only the specified ones\n- the colors option for the consoleTransport must now be set with the desired colors for each level (see the readme), if not set the logs will not be colored\n- removed css web color support (latest chrome versions support ansi codes)\n\n## [3.0.4] - 04-06-2021\n\n- queue management to avoid race conditions problems with ExpoFS\n- minor bugfix\n\n## [3.0.3] - 12-02-2021\n\n- removed EncodingType reference on fileAsyncTransport\n\n## [3.0.2] - 27-01-2021\n\n- fixed web colors in console transport\n\n## [3.0.1] - 27-01-2021\n\n- fixed ansi colors in console transport\n\n## [3.0.0] - 26-01-2021\n\nThis new version introduces many changes, the log management has been modified to allow the creation of namespaced loggers and to simplify the creation of custom transports.\nThe creation of namespaced loggers is done via the \"extend\" function on the main logger. This makes it possible to enable or disable logging only for certain parts of the app. The extend function is for now only enabled at the first level, it is not possible to extend an already extended logger in order to avoid loops in the controls that would affect performance.\n\n- complete refactoring\n- added namespaced logs via extend function!\n- expofs support for file transport (beta)\n- sentry transport\n- logs concatenation on single line\n- bugfix\n\n#### BREAKING CHANGES\n\nTo upgrade to version 3 you need to change the logger creation. The default transports have now been reduced, but they support the same functions as before but through options, e.g. to get asynchronous logs you can set the async:true option instead of importing a special transport.\nCustom transports also need to change, they now receive a single \"props\" parameter containing everything you need, the message formatting has been moved out of the transport so you can just output it. It is still possible to format the logs at will. Please refer to the new documentation for details.\n\n## [2.2.1] - 23-05-2020\n\n- added \"ansiColorConsoleSync\" transport to color logs on terminal (and VScode terminal)\n\n## [2.2.0] - 11-05-2020\n\n- added log messages concatenation \"log(msg1,msg2,etc...)\"\n- added dataFormat transportOptions (thanks @baldur)\n- bugfix\n\n## [2.1.2] - 14-04-2020\n\n- fixed bug RNFS wrong require line (thanks @jbreuer95)\n\n## [2.1.0] - 08-04-2020\n\n- added possibility to pass options to transport with transportOptions property\n\n## [2.0.2] - 13-03-2020\n\n- bugfix\n\n## [2.0.1] - 04-03-2020\n\n- remove transport export from main index module to avoid require errors\n\n## [2.0.0] - 23-02-2020\n\n- added preset file transport based on react-native-fs\n- added preset transport with react-native AfterInteractions\n- bugfix\n\n### Breaking Changes\n\n- removed parameter cb() from transport functions\n- preset transport renamed\n\n## [1.0.2] - 12-07-2020\n\n- bugfix\n\n## [1.0.1] - 12-07-2020\n\n- npm release\n\n## [1.0.0] - 12-07-2020\n\n- initial commit\n"
  },
  {
    "path": "packages/logs/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Alessandro Bottamedi.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "packages/logs/README.md",
    "content": "# @bbplayer/logs\n\n为 React Native 和 Expo 优化的高性能日志管理库。\n\n## 简介\n\n这是 `react-native-logs` 的一个分支版本，专门针对 BBPlayer 的需求进行了定制，特别是增加了对 `expo-file-system` **next** API 的支持，确保在现代 Expo 环境下拥有更佳的日志持久化性能。\n\n## 功能特性\n\n- **多端支持**：兼容 React Native (Bare/Managed)、Expo 以及 Web。\n- **自定义传输**：支持控制台色彩输出、异步文件写入、Sentry 集成等。\n- **高性能**：支持异步日志记录，最小化对 UI 渲染线程的影响。\n- **命名空间**：支持建立不同的 Log 实例，便于模块化开发和调试。\n\n## 安装\n\n```bash\npnpm add @bbplayer/logs\n```\n\n## 快速上手\n\n```typescript\nimport { logger } from '@bbplayer/logs'\n\nconst log = logger.createLogger()\n\nlog.debug('这是一条调试信息')\nlog.info('这是一条普通信息')\nlog.error('这是一条错误信息')\n```\n\n## 配置\n\n你可以根据需要自定义日志级别、日期格式以及传输方式：\n\n| 参数      | 类型     | 说明             | 默认值             |\n| :-------- | :------- | :--------------- | :----------------- |\n| severity  | string   | 最低记录级别     | `debug`            |\n| transport | function | 日志传输函数     | `consoleTransport` |\n| async     | boolean  | 是否开启异步记录 | `false`            |\n| printDate | boolean  | 是否打印日期时间 | `true`             |\n"
  },
  {
    "path": "packages/logs/demo/ComponentReadLogsRN.tsx",
    "content": "import React, { useEffect, useRef, useState } from 'react'\nimport {\n\tStyleSheet,\n\tView,\n\tTouchableOpacity,\n\tScrollView,\n\tText,\n} from 'react-native'\nimport RNFS from 'react-native-fs'\n\nconst DebugLogs = () => {\n\tconst [files, setFiles] = useState([])\n\tconst [file, setFile] = useState(null)\n\tconst [logs, setLogs] = useState(null)\n\n\tconst fileViewRef = useRef(null)\n\n\tuseEffect(() => {\n\t\tRNFS.readdir(RNFS.DocumentDirectoryPath + '/logs').then((result) => {\n\t\t\tif (result) {\n\t\t\t\tsetFiles(result)\n\t\t\t}\n\t\t})\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (file) {\n\t\t\tRNFS.readFile(RNFS.DocumentDirectoryPath + '/logs/' + file, 'utf8').then(\n\t\t\t\t(result) => {\n\t\t\t\t\tsetLogs(result)\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\t}, [file])\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t<View style={{ flex: 1, paddingHorizontal: 5, width: '100%' }}>\n\t\t\t\t<ScrollView\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: '20%',\n\t\t\t\t\t\tborderRadius: 10,\n\t\t\t\t\t\tborderBottomWidth: 2,\n\t\t\t\t\t}}\n\t\t\t\t\tcontentContainerStyle={{ padding: 10 }}\n\t\t\t\t\tref={fileViewRef}\n\t\t\t\t\tonContentSizeChange={() =>\n\t\t\t\t\t\tfileViewRef.current &&\n\t\t\t\t\t\tfileViewRef.current.scrollToEnd({ animated: true })\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t{files.map((item: string, index: number) => {\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<TouchableOpacity\n\t\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\t\t\t\tsetFile(item)\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Text>- {item}</Text>\n\t\t\t\t\t\t\t</TouchableOpacity>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</ScrollView>\n\t\t\t\t<ScrollView\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: '65%',\n\t\t\t\t\t\tmarginTop: '2%',\n\t\t\t\t\t\tmarginBottom: 10,\n\t\t\t\t\t\tborderRadius: 10,\n\t\t\t\t\t}}\n\t\t\t\t\tcontentContainerStyle={{ padding: 10 }}\n\t\t\t\t\tref={fileViewRef}\n\t\t\t\t\tonContentSizeChange={() =>\n\t\t\t\t\t\tfileViewRef.current &&\n\t\t\t\t\t\tfileViewRef.current.scrollToEnd({ animated: true })\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t{logs ? <Text>{logs}</Text> : <Text>SELECT LOG FILE...</Text>}\n\t\t\t\t</ScrollView>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflex: 1,\n\t\tpaddingTop: 10,\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\ttitle: {\n\t\tmarginTop: 10,\n\t\tmarginBottom: 10,\n\t\tmaxWidth: '50%',\n\t},\n})\n\nexport { DebugLogs }\n"
  },
  {
    "path": "packages/logs/demo/demo.ts",
    "content": "import {\n\tlogger,\n\tconsoleTransport,\n\tmapConsoleTransport,\n\tconfigLoggerType,\n\tdefLvlType,\n} from '../src'\n\nvar log = logger.createLogger({\n\tlevels: {\n\t\tdebug: 0,\n\t\tinfo: 1,\n\t\twarn: 2,\n\t\terror: 3,\n\t},\n\ttransport: consoleTransport,\n\ttransportOptions: {\n\t\tcolors: {\n\t\t\tinfo: 'blueBright',\n\t\t\twarn: 'yellowBright',\n\t\t\terror: 'redBright',\n\t\t},\n\t\textensionColors: {\n\t\t\troot: 'magenta',\n\t\t\thome: 'grey',\n\t\t\tuser: 'blue',\n\t\t},\n\t},\n})\n\nvar rootLog = log.extend('root')\nvar homeLog = log.extend('home')\nvar userLog = log.extend('user')\n\nlog.debug('Simple log')\n\nrootLog.warn('Magenta extension and bright yellow message')\nhomeLog.error('Gray extension and bright red message')\n\nrootLog.error('Root error log message')\n\nuserLog.debug('User logged in correctly')\nuserLog.error('User wrong password')\n\nrootLog.info('Log Object:', { a: 1, b: 2 })\n\nrootLog.info('Log nested Object:', {\n\ta: 1,\n\tb: [{ name: 'test', id: 1, arr: [{ arrId: 1 }] }],\n})\n\nrootLog.info('Multiple', 'strings', ['array1', 'array2'])\n"
  },
  {
    "path": "packages/logs/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/logs\",\n\t\"version\": \"5.6.2\",\n\t\"description\": \"Performance-aware simple logger for React-Native with namespaces, custom levels and custom transports (colored console, file writing, etc.)\",\n\t\"keywords\": [\n\t\t\"colors\",\n\t\t\"console\",\n\t\t\"custom\",\n\t\t\"debug\",\n\t\t\"error\",\n\t\t\"expo\",\n\t\t\"file\",\n\t\t\"levels\",\n\t\t\"log\",\n\t\t\"logger\",\n\t\t\"logs\",\n\t\t\"namespace\",\n\t\t\"react-native\"\n\t],\n\t\"homepage\": \"https://github.com/bbplayer-app/bbplayer/tree/dev/packages/react-native-logs\",\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer/issues\"\n\t},\n\t\"license\": \"MIT\",\n\t\"author\": \"Alessandro Bottamedi - a.bottamedi@me.com\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer.git\",\n\t\t\"directory\": \"packages/react-native-logs\"\n\t},\n\t\"files\": [\n\t\t\"src\"\n\t],\n\t\"sideEffects\": false,\n\t\"main\": \"src/index.ts\",\n\t\"types\": \"src/index.ts\",\n\t\"scripts\": {\n\t\t\"build\": \"rm -rf dist && tsc\",\n\t\t\"test\": \"jest\",\n\t\t\"test:cov\": \"jest --coverage\",\n\t\t\"test:verbose\": \"jest --verbose\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/react\": \"~19.2.9\",\n\t\t\"jest\": \"^30.2.0\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\"\n\t},\n\t\"peerDependencies\": {\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\"\n\t}\n}\n"
  },
  {
    "path": "packages/logs/src/index.ts",
    "content": "/**\n * REACT-NATIVE-LOGS\n * Alessandro Bottamedi - a.bottamedi@me.com\n *\n * Performance-aware simple logger for React-Native with custom levels and transports (colored console, file writing, etc.)\n *\n * MIT license\n *\n * Copyright (c) 2021 Alessandro Bottamedi.\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\n/** Import preset transports */\nimport { consoleTransport } from './transports/consoleTransport'\nimport { crashlyticsTransport } from './transports/crashlyticsTransport'\nimport { fileAsyncTransport } from './transports/fileAsyncTransport'\nimport { mapConsoleTransport } from './transports/mapConsoleTransport'\nimport { sentryTransport } from './transports/sentryTransport'\n\nlet asyncFunc = (cb: Function) => {\n\tsetTimeout(() => {\n\t\treturn cb()\n\t}, 0)\n}\n\nconst safeStringify = (value: unknown): string => {\n\tif (typeof value === 'string') return value\n\tif (typeof value === 'function')\n\t\treturn `[function ${value.name || 'anonymous'}()]`\n\tif (value instanceof Error) return value.stack || value.message\n\n\tconst cache = new Set()\n\ttry {\n\t\treturn JSON.stringify(\n\t\t\tvalue,\n\t\t\t(key, val) => {\n\t\t\t\tif (typeof val === 'object' && val !== null) {\n\t\t\t\t\tif (cache.has(val)) {\n\t\t\t\t\t\treturn '[Circular Reference]'\n\t\t\t\t\t}\n\t\t\t\t\tcache.add(val)\n\t\t\t\t}\n\t\t\t\treturn val\n\t\t\t},\n\t\t\t2,\n\t\t)\n\t} catch (error) {\n\t\treturn '[[Unserializable Value]]'\n\t}\n}\n\nlet stringifyFunc = (msg: any): string => {\n\tlet stringMsg = ''\n\tif (typeof msg === 'string') {\n\t\tstringMsg = msg + ' '\n\t} else if (typeof msg === 'function') {\n\t\tstringMsg = '[function ' + msg.name + '()] '\n\t} else if (msg && msg.stack && msg.message) {\n\t\tstringMsg = msg.message + ' '\n\t} else {\n\t\ttry {\n\t\t\tstringMsg = '\\n' + safeStringify(msg) + '\\n'\n\t\t} catch (error) {\n\t\t\tstringMsg += 'Undefined Message'\n\t\t}\n\t}\n\treturn stringMsg\n}\n\n/** Types Declaration */\ntype transportFunctionType<T extends object> = (props: {\n\tmsg: string\n\trawMsg: unknown\n\tlevel: { severity: number; text: string }\n\textension?: string | null\n\toptions?: T\n}) => void\n\ntype levelsType = { [key: string]: number }\n\ntype logMethodType = (\n\tlevel: string,\n\textension: string | null,\n\t...msgs: any[]\n) => boolean\ntype levelLogMethodType = (...msgs: any[]) => boolean\n\ntype extendedLogType = { [key: string]: levelLogMethodType | any }\n\ntype ExtractOptions<T> = T extends transportFunctionType<infer U> ? U : never\n\ntype MergeTransportOptions<T> = T extends (infer U)[]\n\t? ExtractOptions<U>\n\t: ExtractOptions<T>\n\ntype UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (\n\tk: infer I extends U,\n) => void\n\t? I\n\t: never\n\ntype configLoggerType<\n\tT extends transportFunctionType<object> | transportFunctionType<object>[],\n\tLevel extends string,\n> = {\n\tseverity?: string\n\ttransport?: T\n\ttransportOptions?: UnionToIntersection<MergeTransportOptions<T>>\n\tlevels?: Record<Level, number>\n\tasync?: boolean\n\tasyncFunc?: Function\n\tstringifyFunc?: (msg: any) => string\n\tformatFunc?:\n\t\t| null\n\t\t| ((level: string, extension: string | null, msgs: any) => string)\n\tdateFormat?: string | ((date: Date) => string) //\"time\" | \"local\" | \"utc\" | \"iso\" | \"function\";\n\tprintLevel?: boolean\n\tprintDate?: boolean\n\tfixedExtLvlLength?: boolean\n\tenabled?: boolean\n\tenabledExtensions?: string[] | string | null\n}\n\n/** Reserved key log string to avoid overwriting other methods or properties */\nconst reservedKey: string[] = [\n\t'extend',\n\t'enable',\n\t'disable',\n\t'getExtensions',\n\t'setSeverity',\n\t'getSeverity',\n\t'patchConsole',\n\t'getOriginalConsole',\n]\n\n/** Default configuration parameters for logger */\nconst defaultLogger = {\n\tseverity: 'debug',\n\ttransport: consoleTransport,\n\ttransportOptions: {},\n\tlevels: {\n\t\tdebug: 0,\n\t\tinfo: 1,\n\t\twarn: 2,\n\t\terror: 3,\n\t},\n\tasync: false,\n\tasyncFunc: asyncFunc,\n\tstringifyFunc: stringifyFunc,\n\tformatFunc: null,\n\tprintLevel: true,\n\tprintDate: true,\n\tdateFormat: 'time',\n\tfixedExtLvlLength: false,\n\tenabled: true,\n\tenabledExtensions: null,\n\tprintFileLine: false,\n\tfileLineOffset: 0,\n} as const\n\ntype OptionsWithConsoleFunc = {\n\tconsoleFunc?: (msg: string) => void\n}\n\n/** Logger Main Class */\nclass logs<\n\tT extends\n\t\t| transportFunctionType<OptionsWithConsoleFunc>\n\t\t| transportFunctionType<OptionsWithConsoleFunc>[],\n\tK extends string,\n> {\n\tprivate _levels: levelsType\n\tprivate _level: string\n\tprivate _transport: T\n\tprivate _transportOptions: UnionToIntersection<MergeTransportOptions<T>>\n\tprivate _async: boolean\n\tprivate _asyncFunc: Function\n\tprivate _stringifyFunc: (msg: any) => string\n\tprivate _formatFunc?:\n\t\t| null\n\t\t| ((level: string, extension: string | null, msgs: any) => string)\n\tprivate _dateFormat: string | ((date: Date) => string)\n\tprivate _printLevel: boolean\n\tprivate _printDate: boolean\n\tprivate _fixedExtLvlLength: boolean\n\tprivate _enabled: boolean\n\tprivate _enabledExtensions: string[] | null = null\n\tprivate _disabledExtensions: string[] | null = null\n\tprivate _extensions: string[] = []\n\tprivate _extendedLogs: { [key: string]: extendedLogType } = {}\n\tprivate _originalConsole?: typeof console\n\tprivate _maxLevelsChars: number = 0\n\tprivate _maxExtensionsChars: number = 0\n\n\tconstructor(config: Required<configLoggerType<T, K>>) {\n\t\tthis._levels = config.levels\n\t\tthis._level = config.severity ?? Object.keys(this._levels)[0]\n\n\t\tthis._transport = config.transport\n\t\tthis._transportOptions = config.transportOptions\n\n\t\tthis._asyncFunc = config.asyncFunc\n\t\tthis._async = config.async\n\n\t\tthis._stringifyFunc = config.stringifyFunc\n\t\tthis._formatFunc = config.formatFunc\n\t\tthis._dateFormat = config.dateFormat\n\t\tthis._printLevel = config.printLevel\n\t\tthis._printDate = config.printDate\n\t\tthis._fixedExtLvlLength = config.fixedExtLvlLength\n\n\t\tthis._enabled = config.enabled\n\n\t\tif (Array.isArray(config.enabledExtensions)) {\n\t\t\tthis._enabledExtensions = config.enabledExtensions\n\t\t} else if (typeof config.enabledExtensions === 'string') {\n\t\t\tthis._enabledExtensions = [config.enabledExtensions]\n\t\t}\n\n\t\t/** find max levels characters */\n\t\tif (this._fixedExtLvlLength) {\n\t\t\tthis._maxLevelsChars = Math.max(\n\t\t\t\t...Object.keys(this._levels).map((k) => k.length),\n\t\t\t)\n\t\t}\n\n\t\t/** Bind correct log levels methods */\n\t\tlet _this: any = this\n\t\tObject.keys(this._levels).forEach((level: string) => {\n\t\t\tif (typeof level !== 'string') {\n\t\t\t\tthrow Error(`[react-native-logs] ERROR: levels must be strings`)\n\t\t\t}\n\t\t\tif (level[0] === '_') {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: keys with first char \"_\" is reserved and cannot be used as levels`,\n\t\t\t\t)\n\t\t\t}\n\t\t\tif (reservedKey.indexOf(level) !== -1) {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: [${level}] is a reserved key, you cannot set it as custom level`,\n\t\t\t\t)\n\t\t\t}\n\t\t\tif (typeof this._levels[level] === 'number') {\n\t\t\t\t_this[level] = this._log.bind(this, level, null)\n\t\t\t} else {\n\t\t\t\tthrow Error(`[react-native-logs] ERROR: [${level}] wrong level config`)\n\t\t\t}\n\t\t}, this)\n\t}\n\n\t/** Log messages methods and level filter */\n\tprivate _log: logMethodType = (level, extension, ...msgs) => {\n\t\tif (this._async) {\n\t\t\treturn this._asyncFunc(() => {\n\t\t\t\tthis._sendToTransport(level, extension, msgs)\n\t\t\t})\n\t\t} else {\n\t\t\treturn this._sendToTransport(level, extension, msgs)\n\t\t}\n\t}\n\n\tprivate _sendToTransport = (\n\t\tlevel: string,\n\t\textension: string | null,\n\t\tmsgs: any,\n\t) => {\n\t\tif (!this._enabled) return false\n\t\tif (!this._isLevelEnabled(level)) {\n\t\t\treturn false\n\t\t}\n\t\tif (extension && !this._isExtensionEnabled(extension)) {\n\t\t\treturn false\n\t\t}\n\t\tlet msg = this._formatMsg(level, extension, msgs)\n\t\tlet transportProps = {\n\t\t\tmsg: msg,\n\t\t\trawMsg: msgs,\n\t\t\tlevel: { severity: this._levels[level], text: level },\n\t\t\textension: extension,\n\t\t\toptions: this._transportOptions,\n\t\t}\n\t\tif (Array.isArray(this._transport)) {\n\t\t\tfor (let i = 0; i < this._transport.length; i++) {\n\t\t\t\tif (typeof this._transport[i] !== 'function') {\n\t\t\t\t\tthrow Error(`[react-native-logs] ERROR: transport is not a function`)\n\t\t\t\t} else {\n\t\t\t\t\tthis._transport[i](transportProps)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tif (typeof this._transport !== 'function') {\n\t\t\t\tthrow Error(`[react-native-logs] ERROR: transport is not a function`)\n\t\t\t} else {\n\t\t\t\tthis._transport(transportProps)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\tprivate _stringifyMsg = (msg: any): string => {\n\t\treturn this._stringifyFunc(msg)\n\t}\n\n\tprivate _formatMsg = (\n\t\tlevel: string,\n\t\textension: string | null,\n\t\tmsgs: any,\n\t): string => {\n\t\tif (typeof this._formatFunc === 'function') {\n\t\t\treturn this._formatFunc(level, extension, msgs)\n\t\t}\n\n\t\tlet nameTxt: string = ''\n\t\tif (extension) {\n\t\t\tlet extStr = this._fixedExtLvlLength\n\t\t\t\t? extension?.padEnd(this._maxExtensionsChars, ' ')\n\t\t\t\t: extension\n\t\t\tnameTxt = `${extStr} | `\n\t\t}\n\n\t\tlet dateTxt: string = ''\n\t\tif (this._printDate) {\n\t\t\tif (typeof this._dateFormat === 'string') {\n\t\t\t\tswitch (this._dateFormat) {\n\t\t\t\t\tcase 'time':\n\t\t\t\t\t\tdateTxt = `${new Date().toLocaleTimeString()} | `\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase 'local':\n\t\t\t\t\t\tdateTxt = `${new Date().toLocaleString()} | `\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase 'utc':\n\t\t\t\t\t\tdateTxt = `${new Date().toUTCString()} | `\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase 'iso':\n\t\t\t\t\t\tdateTxt = `${new Date().toISOString()} | `\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t} else if (typeof this._dateFormat === 'function') {\n\t\t\t\tdateTxt = this._dateFormat(new Date())\n\t\t\t}\n\t\t}\n\n\t\tlet levelTxt = ''\n\t\tif (this._printLevel) {\n\t\t\tlevelTxt = this._fixedExtLvlLength\n\t\t\t\t? level.padEnd(this._maxLevelsChars, ' ')\n\t\t\t\t: level\n\t\t\tlevelTxt = `${levelTxt.toUpperCase()} : `\n\t\t}\n\n\t\tlet stringMsg: string = dateTxt + nameTxt + levelTxt\n\n\t\tif (Array.isArray(msgs)) {\n\t\t\tfor (let i = 0; i < msgs.length; ++i) {\n\t\t\t\tstringMsg += this._stringifyMsg(msgs[i])\n\t\t\t}\n\t\t} else {\n\t\t\tstringMsg += this._stringifyMsg(msgs)\n\t\t}\n\n\t\treturn stringMsg\n\t}\n\n\t/** Return true if level is enabled */\n\tprivate _isLevelEnabled = (level: string): boolean => {\n\t\tif (this._levels[level] < this._levels[this._level]) {\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t}\n\n\t/** Return true if extension is enabled */\n\tprivate _isExtensionEnabled = (extension: string): boolean => {\n\t\tif (this._disabledExtensions?.length) {\n\t\t\treturn !this._disabledExtensions.includes(extension)\n\t\t}\n\t\tif (\n\t\t\t!this._enabledExtensions ||\n\t\t\tthis._enabledExtensions.includes(extension)\n\t\t) {\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\t/** Extend logger with a new extension */\n\textend = (extension: string): extendedLogType => {\n\t\tif (extension === 'console') {\n\t\t\tthrow Error(\n\t\t\t\t`[react-native-logs:extend] ERROR: you cannot set [console] as extension, use patchConsole instead`,\n\t\t\t)\n\t\t}\n\t\tif (this._extensions.includes(extension)) {\n\t\t\treturn this._extendedLogs[extension]\n\t\t}\n\t\tthis._extendedLogs[extension] = {}\n\t\tthis._extensions.push(extension)\n\t\tlet extendedLog = this._extendedLogs[extension]\n\t\tObject.keys(this._levels).forEach((level: string) => {\n\t\t\textendedLog[level] = (...msgs: any) => {\n\t\t\t\tthis._log(level, extension, ...msgs)\n\t\t\t}\n\t\t\textendedLog['extend'] = (extension: string) => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: you cannot extend a logger from an already extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['enable'] = () => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot enable a logger from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['disable'] = () => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot disable a logger from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['getExtensions'] = () => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot get extensions from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['setSeverity'] = (level: string) => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot set severity from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['getSeverity'] = () => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot get severity from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['patchConsole'] = () => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot patch console from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t\textendedLog['getOriginalConsole'] = () => {\n\t\t\t\tthrow Error(\n\t\t\t\t\t`[react-native-logs] ERROR: You cannot get original console from extended logger`,\n\t\t\t\t)\n\t\t\t}\n\t\t})\n\t\tthis._maxExtensionsChars = Math.max(\n\t\t\t...this._extensions.map((ext: string) => ext.length),\n\t\t)\n\t\treturn extendedLog\n\t}\n\n\t/** Enable logger or extension */\n\tenable = (extension?: string): boolean => {\n\t\tif (!extension) {\n\t\t\tthis._enabled = true\n\t\t\treturn true\n\t\t}\n\n\t\tif (this._extensions.includes(extension)) {\n\t\t\tif (this._enabledExtensions) {\n\t\t\t\tif (!this._enabledExtensions.includes(extension)) {\n\t\t\t\t\tthis._enabledExtensions.push(extension)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tthrow Error(\n\t\t\t\t`[react-native-logs:enable] ERROR: Extension [${extension}] not exist`,\n\t\t\t)\n\t\t}\n\n\t\tif (this._disabledExtensions?.includes(extension)) {\n\t\t\tlet extIndex = this._disabledExtensions.indexOf(extension)\n\t\t\tif (extIndex > -1) {\n\t\t\t\tthis._disabledExtensions.splice(extIndex, 1)\n\t\t\t}\n\t\t\tif (!this._disabledExtensions.length) {\n\t\t\t\tthis._disabledExtensions = null\n\t\t\t}\n\t\t}\n\n\t\treturn true\n\t}\n\n\t/** Disable logger or extension */\n\tdisable = (extension?: string): boolean => {\n\t\tif (!extension) {\n\t\t\tthis._enabled = false\n\t\t\treturn true\n\t\t}\n\t\tif (this._extensions.includes(extension)) {\n\t\t\tif (this._enabledExtensions) {\n\t\t\t\tlet extIndex = this._enabledExtensions.indexOf(extension)\n\t\t\t\tif (extIndex > -1) {\n\t\t\t\t\tthis._enabledExtensions.splice(extIndex, 1)\n\t\t\t\t}\n\t\t\t\tif (!this._enabledExtensions.length) {\n\t\t\t\t\tthis._enabledExtensions = null\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tthrow Error(\n\t\t\t\t`[react-native-logs:disable] ERROR: Extension [${extension}] not exist`,\n\t\t\t)\n\t\t}\n\n\t\tif (!this._disabledExtensions) {\n\t\t\tthis._disabledExtensions = []\n\t\t\tthis._disabledExtensions.push(extension)\n\t\t} else if (!this._disabledExtensions.includes(extension)) {\n\t\t\tthis._disabledExtensions.push(extension)\n\t\t}\n\t\treturn true\n\t}\n\n\t/** Return all created extensions */\n\tgetExtensions = (): string[] => {\n\t\treturn this._extensions\n\t}\n\n\t/** Set log severity API */\n\tsetSeverity = (level: string): string => {\n\t\tif (level in this._levels) {\n\t\t\tthis._level = level\n\t\t} else {\n\t\t\tthrow Error(\n\t\t\t\t`[react-native-logs:setSeverity] ERROR: Level [${level}] not exist`,\n\t\t\t)\n\t\t}\n\t\treturn this._level\n\t}\n\n\t/** Get current log severity API */\n\tgetSeverity = (): string => {\n\t\treturn this._level\n\t}\n\n\t/** Monkey Patch global console.log */\n\tpatchConsole = (): void => {\n\t\tlet extension = 'console'\n\t\tlet levelKeys = Object.keys(this._levels)\n\n\t\tif (!this._originalConsole) {\n\t\t\tthis._originalConsole = console\n\t\t}\n\n\t\tif (!this._transportOptions.consoleFunc) {\n\t\t\tthis._transportOptions.consoleFunc = this._originalConsole.log\n\t\t}\n\n\t\tconsole['log'] = (...msgs: any) => {\n\t\t\tthis._log(levelKeys[0], extension, ...msgs)\n\t\t}\n\n\t\tlevelKeys.forEach((level: string) => {\n\t\t\tif ((console as any)[level]) {\n\t\t\t\t;(console as any)[level] = (...msgs: any) => {\n\t\t\t\t\tthis._log(level, extension, ...msgs)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis._originalConsole &&\n\t\t\t\t\tthis._originalConsole.log(\n\t\t\t\t\t\t`[react-native-logs:patchConsole] WARNING: \"${level}\" method does not exist in console and will not be available`,\n\t\t\t\t\t)\n\t\t\t}\n\t\t})\n\t}\n}\n\ntype defLvlType = 'debug' | 'info' | 'warn' | 'error'\n\n/**\n * Create a logger object. All params will take default values if not passed.\n * each levels has its level severity so we can filter logs with < and > operators\n * all subsequent levels to the one selected will be exposed (ordered by severity asc)\n * through the transport\n */\nconst createLogger = <\n\tK extends transportFunctionType<any> | transportFunctionType<any>[] =\n\t\ttransportFunctionType<{ _def: string }>,\n\tY extends string = keyof typeof defaultLogger.levels,\n>(\n\tconfig?: configLoggerType<K, Y>,\n) => {\n\ttype levelMethods<levels extends string> = {\n\t\t[key in levels]: (...args: unknown[]) => void\n\t}\n\n\ttype loggerType = levelMethods<Y>\n\n\ttype extendMethods = {\n\t\textend: (extension: string) => loggerType\n\t}\n\n\tlet mergeConfig = config ? { ...config } : ({} as object)\n\n\tconst mergedConfig = {\n\t\t...defaultLogger,\n\t\t...mergeConfig,\n\t}\n\n\treturn new logs(mergedConfig) as unknown as Omit<logs<K, Y>, 'extend'> &\n\t\tloggerType &\n\t\textendMethods\n}\n\nconst logger = { createLogger }\n\nexport {\n\tlogger,\n\tconsoleTransport,\n\tmapConsoleTransport,\n\tfileAsyncTransport,\n\tsentryTransport,\n\tcrashlyticsTransport,\n}\n\nexport type { transportFunctionType, configLoggerType, defLvlType }\n"
  },
  {
    "path": "packages/logs/src/transports/consoleTransport.ts",
    "content": "import { transportFunctionType } from '../index'\n\nconst availableColors = {\n\tdefault: null,\n\tblack: 30,\n\tred: 31,\n\tgreen: 32,\n\tyellow: 33,\n\tblue: 34,\n\tmagenta: 35,\n\tcyan: 36,\n\twhite: 37,\n\tgrey: 90,\n\tredBright: 91,\n\tgreenBright: 92,\n\tyellowBright: 93,\n\tblueBright: 94,\n\tmagentaBright: 95,\n\tcyanBright: 96,\n\twhiteBright: 97,\n} as const\n\nconst resetColors = '\\x1b[0m'\n\ntype Color = keyof typeof availableColors\n\nexport type ConsoleTransportOptions = {\n\tcolors?: Record<string, Color>\n\textensionColors?: Record<string, Color>\n\tconsoleFunc?: (msg: string) => void\n}\n\nconst consoleTransport: transportFunctionType<ConsoleTransportOptions> = (\n\tprops,\n) => {\n\tif (!props) return false\n\n\tlet msg = props.msg\n\tlet color\n\n\tif (\n\t\tprops.options?.colors &&\n\t\tprops.options.colors[props.level.text] &&\n\t\tavailableColors[props.options.colors[props.level.text]]\n\t) {\n\t\tcolor = `\\x1b[${availableColors[props.options.colors[props.level.text]]}m`\n\t\tmsg = `${color}${msg}${resetColors}`\n\t}\n\n\tif (props.extension && props.options?.extensionColors) {\n\t\tlet extensionColor = '\\x1b[7m'\n\n\t\tconst extColor = props.options.extensionColors[props.extension]\n\t\tif (extColor && availableColors[extColor]) {\n\t\t\textensionColor = `\\x1b[${availableColors[extColor] + 10}m`\n\t\t}\n\n\t\tlet extStart = color ? resetColors + extensionColor : extensionColor\n\t\tlet extEnd = color ? resetColors + color : resetColors\n\t\tmsg = msg.replace(\n\t\t\tprops.extension,\n\t\t\t`${extStart} ${props.extension} ${extEnd}`,\n\t\t)\n\t}\n\n\tif (props.options?.consoleFunc) {\n\t\tprops.options.consoleFunc(msg.trim())\n\t} else {\n\t\tconsole.log(msg.trim())\n\t}\n\n\treturn true\n}\n\nexport { consoleTransport }\n"
  },
  {
    "path": "packages/logs/src/transports/crashlyticsTransport.ts",
    "content": "import { transportFunctionType } from '../index'\n\nexport type CrashlyticsTransportOption = {\n\tCRASHLYTICS: {\n\t\trecordError: (error: Error | string, name?: string) => void\n\t\tlog: (msg: string) => void\n\t}\n\terrorLevels?: string | Array<string>\n}\n\nconst crashlyticsTransport: transportFunctionType<\n\tCrashlyticsTransportOption\n> = (props) => {\n\tif (!props) return false\n\n\tif (!props?.options?.CRASHLYTICS) {\n\t\tthrow new Error(\n\t\t\t`react-native-logs: crashlyticsTransport - No crashlytics instance provided`,\n\t\t)\n\t}\n\n\tlet isError = false\n\n\tif (props?.options?.errorLevels) {\n\t\tisError = false\n\t\tconst level = props.level.text\n\t\tconst errorLevels = props.options.errorLevels\n\n\t\tconst levelsToCheck = Array.isArray(errorLevels)\n\t\t\t? errorLevels\n\t\t\t: [errorLevels]\n\t\tif (levelsToCheck.includes(level)) {\n\t\t\tisError = true\n\t\t}\n\t}\n\n\ttry {\n\t\tlet msgToRecord: any = props.msg\n\n\t\tif (isError) {\n\t\t\tconst errorToRecord =\n\t\t\t\tmsgToRecord instanceof Error\n\t\t\t\t\t? msgToRecord\n\t\t\t\t\t: new Error(String(msgToRecord))\n\n\t\t\tprops.options.CRASHLYTICS.recordError(\n\t\t\t\terrorToRecord,\n\t\t\t\tprops.extension || undefined,\n\t\t\t)\n\t\t} else {\n\t\t\tprops.options.CRASHLYTICS.log(String(msgToRecord))\n\t\t}\n\t\treturn true\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`react-native-logs: crashlyticsTransport - Error on send msg to crashlytics: ${error}`,\n\t\t)\n\t}\n}\n\nexport { crashlyticsTransport }\n"
  },
  {
    "path": "packages/logs/src/transports/fileAsyncTransport.ts",
    "content": "import { transportFunctionType } from '../index'\n\ntype RNFS = {\n\tDocumentDirectoryPath: string\n\tdocumentDirectory: never\n\twriteAsStringAsync: undefined\n\tappendFile: (\n\t\tfilepath: string,\n\t\tcontents: string,\n\t\tencoding?: string,\n\t) => Promise<void>\n}\ntype EXPOFS = {\n\tdocumentDirectory: string | null\n\tDocumentDirectoryPath: never\n\twriteAsStringAsync: (\n\t\tfileUri: string,\n\t\tcontents: string,\n\t\toptions?: object,\n\t) => Promise<void>\n\treadAsStringAsync?: (fileUri: string, options?: object) => Promise<string>\n\tgetInfoAsync?: (\n\t\tfileUri: string,\n\t\toptions?: object,\n\t) => Promise<{ exists: boolean }>\n\tappendFile: undefined\n}\ntype EXPONEXTFS = {\n\tFile: new (...args: string[]) => {\n\t\turi: string\n\t\tname: string\n\t\texists: boolean\n\t\tcreate: (options?: { intermediates?: boolean; overwrite?: boolean }) => void\n\t\topen: () => {\n\t\t\twriteBytes: (data: Uint8Array) => void\n\t\t\tclose: () => void\n\t\t\tsize: number | null\n\t\t\toffset: number | null\n\t\t}\n\t}\n\tPaths: any\n}\n\ninterface EXPOqueueitem {\n\tFS: Required<EXPOFS>\n\tfile: string\n\tmsg: string\n}\n\nlet EXPOqueue: Array<EXPOqueueitem> = []\nlet EXPOelaborate = false\n\ninterface EXPONEXTFSqueueitem {\n\tFS: Required<EXPONEXTFS>\n\tfile: string\n\tmsg: string\n}\n\nconst EXPOFSreadwrite = async () => {\n\tif (EXPOqueue.length === 0) return\n\n\tEXPOelaborate = true\n\tconst item = EXPOqueue[0]\n\n\ttry {\n\t\tconst prevFile =\n\t\t\t(await item.FS.readAsStringAsync(item.file).catch(() => '')) || ''\n\t\tconst newMsg = prevFile + item.msg\n\t\tawait item.FS.writeAsStringAsync(item.file, newMsg)\n\t} catch (error) {\n\t\tconsole.error('Failed to write log to file (expo legacy):', error)\n\t} finally {\n\t\tEXPOelaborate = false\n\t\tEXPOqueue.shift()\n\t\tif (EXPOqueue.length > 0) {\n\t\t\tEXPOFSreadwrite().then()\n\t\t}\n\t}\n}\n\nconst EXPOcheckqueue = async (\n\tFS: Required<EXPOFS>,\n\tfile: string,\n\tmsg: string,\n) => {\n\tEXPOqueue.push({ FS, file, msg })\n\tif (!EXPOelaborate) {\n\t\tawait EXPOFSreadwrite()\n\t}\n}\n\nconst EXPOFSappend = async (\n\tFS: Required<EXPOFS>,\n\tfile: string,\n\tmsg: string,\n) => {\n\ttry {\n\t\tconst fileInfo = await FS.getInfoAsync(file)\n\t\tif (!fileInfo.exists) {\n\t\t\tawait FS.writeAsStringAsync(file, msg)\n\t\t\treturn true\n\t\t} else {\n\t\t\tawait EXPOcheckqueue(FS, file, msg)\n\t\t\treturn true\n\t\t}\n\t} catch (error) {\n\t\tconsole.error(error)\n\t\treturn false\n\t}\n}\n\nconst RNFSappend = async (FS: any, file: string, msg: string) => {\n\ttry {\n\t\tawait FS.appendFile(file, msg, 'utf8')\n\t\treturn true\n\t} catch (error) {\n\t\tconsole.error(error)\n\t\treturn false\n\t}\n}\n\nlet EXPONEXTFSqueue: Array<EXPONEXTFSqueueitem> = []\nlet EXPONEXTFSelaborate = false\n\nconst EXPONEXTFSprocessQueue = async () => {\n\tif (EXPONEXTFSqueue.length === 0) return\n\tEXPONEXTFSelaborate = true\n\tconst item = EXPONEXTFSqueue[0]\n\n\ttry {\n\t\tconst FS: EXPONEXTFS = item.FS\n\t\tconst FileClass = FS.File\n\t\tif (!FileClass) throw new Error('EXPO NEXT FS does not expose File')\n\n\t\tconst file = new FileClass(item.file)\n\n\t\ttry {\n\t\t\tif (!file.exists) {\n\t\t\t\tfile.create({ intermediates: true })\n\t\t\t}\n\t\t} catch (e) {\n\t\t\t// maybe concurrently created\n\t\t}\n\n\t\tconst fileHandler = file.open()\n\n\t\ttry {\n\t\t\tconst size = typeof fileHandler.size === 'number' ? fileHandler.size : 0\n\t\t\tfileHandler.offset = size\n\n\t\t\tconst encoder = new TextEncoder()\n\t\t\tconst bytes = encoder.encode(item.msg)\n\n\t\t\tfileHandler.writeBytes(bytes)\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tfileHandler.close()\n\t\t\t} catch (e) {\n\t\t\t\tconsole.warn('EXPO FS NEXT error while closing FileHandle', e)\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\tconsole.error('EXPO FS NEXT failed to write log to file:', error)\n\t} finally {\n\t\tEXPONEXTFSelaborate = false\n\t\tEXPONEXTFSqueue.shift()\n\t\tif (EXPONEXTFSqueue.length > 0) {\n\t\t\tEXPONEXTFSprocessQueue().then()\n\t\t}\n\t}\n}\n\nconst EXPONEXTFSappend = async (FS: EXPONEXTFS, file: string, msg: string) => {\n\ttry {\n\t\tEXPONEXTFSqueue.push({ FS, file, msg })\n\t\tif (!EXPONEXTFSelaborate) {\n\t\t\tawait EXPONEXTFSprocessQueue()\n\t\t}\n\t\treturn true\n\t} catch (error) {\n\t\tconsole.error(error)\n\t\treturn false\n\t}\n}\n\nconst dateReplacer = (filename: string, type?: 'eu' | 'us' | 'iso') => {\n\tlet today = new Date()\n\tlet d = today.getDate()\n\tlet m = today.getMonth() + 1\n\tlet y = today.getFullYear()\n\tswitch (type) {\n\t\tcase 'eu':\n\t\t\treturn filename.replace('{date-today}', `${d}-${m}-${y}`)\n\t\tcase 'us':\n\t\t\treturn filename.replace('{date-today}', `${m}-${d}-${y}`)\n\t\tcase 'iso':\n\t\t\treturn filename.replace('{date-today}', `${y}-${m}-${d}`)\n\t\tdefault:\n\t\t\treturn filename.replace('{date-today}', `${d}-${m}-${y}`)\n\t}\n}\n\nexport interface FileAsyncTransportOptions {\n\tfileNameDateType?: 'eu' | 'us' | 'iso'\n\tFS: any\n\tfileName?: string\n\tfilePath?: string\n}\nconst fileAsyncTransport: transportFunctionType<FileAsyncTransportOptions> = (\n\tprops,\n) => {\n\tif (!props) return false\n\n\tlet WRITE: (FS: any, file: string, msg: string) => Promise<boolean>\n\tlet fileName: string = 'log'\n\tlet filePath: string\n\n\tif (!props?.options?.FS) {\n\t\tthrow Error(\n\t\t\t`react-native-logs: fileAsyncTransport - No FileSystem instance provided`,\n\t\t)\n\t}\n\n\tconst FSF = props.options.FS as RNFS | EXPOFS | EXPONEXTFS\n\n\tif ((FSF as RNFS).DocumentDirectoryPath && (FSF as RNFS).appendFile) {\n\t\tWRITE = RNFSappend\n\t\tfilePath = (FSF as RNFS).DocumentDirectoryPath\n\t} else if (\n\t\t(FSF as EXPOFS).documentDirectory &&\n\t\t(FSF as EXPOFS).writeAsStringAsync &&\n\t\t(FSF as EXPOFS).readAsStringAsync &&\n\t\t(FSF as EXPOFS).getInfoAsync\n\t) {\n\t\tWRITE = EXPOFSappend\n\t\tfilePath = (FSF as EXPOFS).documentDirectory!\n\t} else if ((FSF as EXPONEXTFS).File && (FSF as EXPONEXTFS).Paths) {\n\t\tWRITE = EXPONEXTFSappend\n\t\tfilePath = (FSF as EXPONEXTFS).Paths.document\n\t} else {\n\t\tthrow Error(\n\t\t\t`react-native-logs: fileAsyncTransport - FileSystem not supported`,\n\t\t)\n\t}\n\n\tif (props?.options?.fileName) {\n\t\tfileName = props.options.fileName\n\t\tfileName = dateReplacer(fileName, props.options?.fileNameDateType)\n\t}\n\n\tif (props?.options?.filePath) filePath = props.options.filePath\n\n\tconst output = `${props?.msg}\\n`\n\tconst path = `${filePath}/${fileName}`\n\n\tWRITE(FSF, path, output)\n}\n\nexport { fileAsyncTransport }\n"
  },
  {
    "path": "packages/logs/src/transports/mapConsoleTransport.ts",
    "content": "import { transportFunctionType } from '../index'\n\ntype ConsoleMethod = 'log' | 'warn' | 'error' | 'info' | (string & {})\ntype LogLevel = string\n\nexport type MapConsoleTransportOptions = {\n\tmapLevels?: Record<LogLevel, ConsoleMethod>\n}\nconst mapConsoleTransport: transportFunctionType<MapConsoleTransportOptions> = (\n\tprops,\n) => {\n\tif (!props) return false\n\n\tlet logMethod = 'log'\n\n\tif (props.options?.mapLevels && props.options.mapLevels[props.level.text]) {\n\t\tlogMethod = props.options.mapLevels[props.level.text]\n\t} else {\n\t\tlogMethod = props.level.text\n\t}\n\n\tif ((console as any)[logMethod]) {\n\t\t;(console as any)[logMethod](props.msg)\n\t} else {\n\t\tconsole.log(props.msg)\n\t}\n\n\treturn true\n}\n\nexport { mapConsoleTransport }\n"
  },
  {
    "path": "packages/logs/src/transports/sentryTransport.ts",
    "content": "import { transportFunctionType } from '../index'\n\ntype SentryTransportOptions = {\n\tSENTRY: {\n\t\tcaptureException: (msg: string | typeof Error) => void\n\t\taddBreadcrumb: (msg: string | { message: string }) => void\n\t}\n\terrorLevels?: string | Array<string>\n}\n\nconst sentryTransport: transportFunctionType<SentryTransportOptions> = (\n\tprops,\n) => {\n\tif (!props) return false\n\n\tif (!props?.options?.SENTRY) {\n\t\tthrow Error(\n\t\t\t`react-native-logs: sentryTransport - No sentry instance provided`,\n\t\t)\n\t}\n\n\tlet isError = true\n\n\tif (props?.options?.errorLevels) {\n\t\tisError = false\n\t\tif (Array.isArray(props?.options?.errorLevels)) {\n\t\t\tif (props.options.errorLevels.includes(props.level.text)) {\n\t\t\t\tisError = true\n\t\t\t}\n\t\t} else {\n\t\t\tif (props.options.errorLevels === props.level.text) {\n\t\t\t\tisError = true\n\t\t\t}\n\t\t}\n\t}\n\n\ttry {\n\t\tif (isError) {\n\t\t\tprops.options.SENTRY.captureException(props.msg)\n\t\t} else {\n\t\t\tprops.options.SENTRY.addBreadcrumb({ message: props.msg })\n\t\t}\n\t\treturn true\n\t} catch (error) {\n\t\tthrow Error(\n\t\t\t`react-native-logs: sentryTransport - Error oon send msg to Sentry`,\n\t\t)\n\t}\n}\n\nexport { sentryTransport }\n"
  },
  {
    "path": "packages/logs/test/consoleTransport.test.js",
    "content": "'use strict'\nvar rnlogs = require('../dist/index.js')\n\nvar transport =\n\trequire('../dist/transports/consoleTransport.js').consoleTransport\n\ntest('The log function should print string, beutified objects and functions in console', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t\tprintLevel: false,\n\t})\n\tvar outputData = ''\n\tvar outputExp = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\toutputExp = `message`\n\texpect(outputData).toBe(outputExp)\n\toutputData = ''\n\tlog.debug({ message: 'message' })\n\toutputExp = `{\\n  \\\"message\\\": \\\"message\\\"\\n}`\n\texpect(outputData).toBe(outputExp)\n\toutputData = ''\n\tfunction testFunc() {\n\t\treturn 'test'\n\t}\n\tlog.debug(testFunc)\n\toutputExp = `[function testFunc()]`\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('When set higher power level, the lover power level, should not print in console', () => {\n\tvar log = rnlogs.logger.createLogger({ transport: transport })\n\tlog.setSeverity('info')\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\texpect(outputData.length).toBe(0)\n})\n\ntest('When set {enabled:false}, should not print in console', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tenabled: false,\n\t})\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\texpect(outputData.length).toBe(0)\n})\n\ntest('When set {enabled:false, printDate:false} and the call log.enable(), should print expected output', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t\tenabled: false,\n\t})\n\tlog.enable()\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\tvar levelTxt = `DEBUG : `\n\tvar outputExp = `${levelTxt}message`\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('When set {printDate:false, printLevel:false} and empty msg, should not print in console', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t\tprintLevel: false,\n\t})\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('')\n\texpect(outputData).toBe('')\n})\n\ntest(\"When set {dateFormat:'utc'}, should output toUTCString dateformat\", () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tdateFormat: 'utc',\n\t})\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\tvar pattern = /\\d\\d:\\d\\d:\\d\\d GMT \\| DEBUG \\: message$/\n\texpect(outputData).toMatch(pattern)\n})\n\ntest(\"When set {dateFormat:'iso'}, should output toISOString dateformat\", () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tdateFormat: 'iso',\n\t})\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\tvar pattern = /T\\d\\d:\\d\\d:\\d\\d\\.\\d\\d\\dZ \\| DEBUG \\: message$/\n\texpect(outputData).toMatch(pattern)\n})\n\ntest('The log function should print expected output', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t})\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message')\n\tvar levelTxt = `DEBUG : `\n\tvar outputExp = `${levelTxt}message`\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('The log function should print concatenated expected output', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t})\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.debug('message', 'message2')\n\tvar levelTxt = `DEBUG : `\n\tvar outputExp = `${levelTxt}message message2`\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('The enabled namespaced log function should print expected output', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t\tenabledExtensions: ['NAMESPACE'],\n\t})\n\tconst namespacedLog = log.extend('NAMESPACE')\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tnamespacedLog.debug('message')\n\tvar levelTxt = `NAMESPACE | DEBUG : `\n\tvar outputExp = `${levelTxt}message`\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('The enabled namespaced log function should print concatenated expected output', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t\tenabledExtensions: ['NAMESPACE'],\n\t})\n\tconst namespacedLog = log.extend('NAMESPACE')\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tnamespacedLog.debug('message', 'message2')\n\tvar levelTxt = `NAMESPACE | DEBUG : `\n\tvar outputExp = `${levelTxt}message message2`\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('The disabled namespaced log function should not print', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t\tenabledExtensions: ['NAMESPACE2'],\n\t})\n\tconst namespacedLog = log.extend('NAMESPACE')\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tnamespacedLog.debug('message')\n\tvar outputExp = ``\n\texpect(outputData).toBe(outputExp)\n})\n\ntest('The disabled/enabled namespaced in runtime log function should not print/print', () => {\n\tvar log = rnlogs.logger.createLogger({\n\t\ttransport: transport,\n\t\tprintDate: false,\n\t})\n\tconst namespacedLog = log.extend('NAMESPACE')\n\tvar outputData = ''\n\tvar storeLog = (inputs) => (outputData += inputs)\n\tconsole['log'] = jest.fn(storeLog)\n\tlog.disable('NAMESPACE')\n\tnamespacedLog.debug('message')\n\tvar outputExp = ``\n\texpect(outputData).toBe(outputExp)\n\tlog.enable('NAMESPACE')\n\tnamespacedLog.debug('message')\n\tvar outputExp = `NAMESPACE | DEBUG : message`\n\texpect(outputData).toBe(outputExp)\n})\n"
  },
  {
    "path": "packages/logs/test/index.test.js",
    "content": "'use strict'\nvar rnlogs = require('../dist/index.js')\n\ntest('Module should be defined', () => {\n\texpect(rnlogs).toBeDefined()\n\texpect(rnlogs.logger).toBeDefined()\n})\n\ntest('Logger should be created by createLogger', () => {\n\tvar log = rnlogs.logger.createLogger()\n\texpect(log).toBeDefined()\n})\n\ntest('The default log functions should be defined in all transports', () => {\n\tvar log = rnlogs.logger.createLogger()\n\texpect(log.debug).toBeDefined()\n\texpect(log.info).toBeDefined()\n\texpect(log.warn).toBeDefined()\n\texpect(log.error).toBeDefined()\n})\n\ntest('When setSeverity, the getSeverity should be the same', () => {\n\tvar log = rnlogs.logger.createLogger()\n\tlog.setSeverity('info')\n\texpect(log.getSeverity()).toBe('info')\n\tlog.setSeverity('debug')\n\texpect(log.getSeverity()).toBe('debug')\n})\n\ntest('When set higher severity level then the current level, log function shoud return false', () => {\n\tvar log = rnlogs.logger.createLogger()\n\tlog.setSeverity('info')\n\texpect(log.debug('message')).toBe(false)\n})\n\ntest('Custom levels should be defined, even with wrong level config', () => {\n\tvar customConfig = {\n\t\tseverity: 'wrongLevel',\n\t\tlevels: { custom: 0 },\n\t}\n\tvar log = rnlogs.logger.createLogger(customConfig)\n\tlog.setSeverity('custom')\n\texpect(log.getSeverity()).toBe('custom')\n\texpect(log.custom).toBeDefined()\n})\n\ntest('Set wrong level config should throw error', () => {\n\texpect.assertions(1)\n\tvar customConfig = {\n\t\tseverity: 'wrongLevel',\n\t\tlevels: { wrongLevel: 'thisMustBeANumber' },\n\t}\n\ttry {\n\t\tvar log = rnlogs.logger.createLogger(customConfig)\n\t} catch (e) {\n\t\texpect(e.message).toMatch(\n\t\t\t'[react-native-logs] ERROR: [wrongLevel] wrong level config',\n\t\t)\n\t}\n})\n\ntest('Set undefined level should throw error', () => {\n\texpect.assertions(1)\n\tvar log = rnlogs.logger.createLogger()\n\ttry {\n\t\tlog.setSeverity('wrongLevel')\n\t} catch (e) {\n\t\texpect(e.message).toMatch(\n\t\t\t'[react-native-logs:setSeverity] ERROR: Level [wrongLevel] not exist',\n\t\t)\n\t}\n})\n\ntest('Initialize with reserved key should throw error', () => {\n\texpect.assertions(1)\n\tvar customConfig = {\n\t\tseverity: 'custom',\n\t\tlevels: { custom: 0, setSeverity: 1 },\n\t}\n\ttry {\n\t\tvar log = rnlogs.logger.createLogger(customConfig)\n\t} catch (e) {\n\t\texpect(e.message).toMatch(\n\t\t\t'[react-native-logs] ERROR: [setSeverity] is a reserved key, you cannot set it as custom level',\n\t\t)\n\t}\n})\n"
  },
  {
    "path": "packages/logs/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"es2017\",\n\t\t\"module\": \"commonjs\",\n\t\t\"declaration\": true,\n\t\t\"outDir\": \"./dist\",\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false\n\t},\n\t\"exclude\": [\"demo\", \"dist\", \"test\", \"node_modules\"]\n}\n"
  },
  {
    "path": "packages/native/.gitignore",
    "content": "# OSX\n#\n.DS_Store\n\n# VSCode\n.vscode/\njsconfig.json\n\n# Xcode\n#\nbuild/\n*.pbxuser\n!default.pbxuser\n*.mode1v3\n!default.mode1v3\n*.mode2v3\n!default.mode2v3\n*.perspectivev3\n!default.perspectivev3\nxcuserdata\n*.xccheckout\n*.moved-aside\nDerivedData\n*.hmap\n*.ipa\n*.xcuserstate\nproject.xcworkspace\n\n# Android/IJ\n#\n.classpath\n.cxx\n.gradle\n.idea\n.project\n.settings\nlocal.properties\nandroid.iml\nandroid/app/libs\nandroid/keystores/debug.keystore\n\n# Cocoapods\n#\nexample/ios/Pods\n\n# Ruby\nexample/vendor/\n\n# node.js\n#\nnode_modules/\nnpm-debug.log\nyarn-debug.log\nyarn-error.log\n\n# Expo\n.expo/*\n.env"
  },
  {
    "path": "packages/native/android/build.gradle",
    "content": "plugins {\n  id 'com.android.library'\n  id 'expo-module-gradle-plugin'\n}\n\nimport groovy.json.JsonSlurper\n\ndef packageJsonFile = new File(projectDir, '../package.json')\ndef packageJson = new JsonSlurper().parseText(packageJsonFile.text)\n\ngroup = 'expo.modules.bbplayernative'\nversion = packageJson.version\n\nandroid {\n  namespace \"expo.modules.bbplayernative\"\n  defaultConfig {\n    versionCode 1\n    versionName packageJson.version\n  }\n  lintOptions {\n    abortOnError false\n  }\n}\n"
  },
  {
    "path": "packages/native/android/src/main/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <uses-permission android:name=\"android.permission.INTERNET\" />\n  <uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\" />\n</manifest>\n"
  },
  {
    "path": "packages/native/android/src/main/java/expo/modules/bbplayernative/BBPlayerNativeModule.kt",
    "content": "package expo.modules.bbplayernative\n\nimport android.app.DownloadManager\nimport android.content.Context\nimport android.content.Intent\nimport android.database.Cursor\nimport android.net.Uri\nimport android.os.Build\nimport android.os.Environment\nimport android.provider.Settings\nimport expo.modules.kotlin.functions.Coroutine\nimport expo.modules.kotlin.modules.Module\nimport expo.modules.kotlin.modules.ModuleDefinition\nimport expo.modules.kotlin.records.Field\nimport expo.modules.kotlin.records.Record\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.withContext\n\nclass BBPlayerNativeModule : Module() {\n    override fun definition() = ModuleDefinition {\n        Name(\"BBPlayerNative\")\n\n        AsyncFunction(\"canRequestPackageInstallsAsync\") Coroutine { ->\n            val context = requireContext()\n            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {\n                return@Coroutine true\n            }\n            return@Coroutine context.packageManager.canRequestPackageInstalls()\n        }\n\n        AsyncFunction(\"getSupportedAbisAsync\") Coroutine { ->\n            return@Coroutine Build.SUPPORTED_ABIS.toList()\n        }\n\n        AsyncFunction(\"openPackageInstallerSettingsAsync\") {\n            val context = requireContext()\n            openPackageInstallerSettings(context)\n        }\n\n        AsyncFunction(\"downloadAndInstallApkAsync\") Coroutine { options: AppUpdateDownloadOptions ->\n            val context = requireContext()\n            ensureCanRequestPackageInstalls(context)\n\n            val downloadManager =\n                context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager\n            val downloadId = enqueueApkDownload(context, downloadManager, options)\n            val downloadedUri = waitForDownload(downloadManager, downloadId)\n            withContext(Dispatchers.Main) {\n                openApkInstaller(context, downloadedUri)\n            }\n\n            return@Coroutine mapOf(\n                \"downloadId\" to downloadId.toDouble(),\n                \"uri\" to downloadedUri.toString(),\n            )\n        }\n    }\n\n    private fun requireContext(): Context =\n        appContext.reactContext ?: throw IllegalStateException(\"React context is not available\")\n\n    private fun ensureCanRequestPackageInstalls(context: Context) {\n        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return\n        if (context.packageManager.canRequestPackageInstalls()) return\n\n        openPackageInstallerSettings(context)\n        throw IllegalStateException(\"需要先允许 BBPlayer 安装未知来源应用\")\n    }\n\n    private fun openPackageInstallerSettings(context: Context) {\n        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return\n        val intent = Intent(\n            Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,\n            Uri.parse(\"package:${context.packageName}\"),\n        ).apply {\n            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n        }\n        context.startActivity(intent)\n    }\n\n    private fun enqueueApkDownload(\n        context: Context,\n        downloadManager: DownloadManager,\n        options: AppUpdateDownloadOptions,\n    ): Long {\n        if (options.url.isBlank()) {\n            throw IllegalArgumentException(\"更新包下载链接不能为空\")\n        }\n\n        val fileName = sanitizeApkFileName(options.fileName)\n        val title = options.title?.takeIf { it.isNotBlank() } ?: \"BBPlayer 更新包\"\n        val description =\n            options.description?.takeIf { it.isNotBlank() } ?: \"下载完成后将打开系统安装器\"\n\n        val request = DownloadManager.Request(Uri.parse(options.url)).apply {\n            setTitle(title)\n            setDescription(description)\n            setMimeType(APK_MIME_TYPE)\n            setAllowedOverMetered(true)\n            setAllowedOverRoaming(true)\n            setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)\n            setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)\n            addRequestHeader(\"User-Agent\", context.packageName)\n        }\n\n        return downloadManager.enqueue(request)\n    }\n\n    private suspend fun waitForDownload(\n        downloadManager: DownloadManager,\n        downloadId: Long,\n    ): Uri = withContext(Dispatchers.IO) {\n        var downloadedUri: Uri? = null\n\n        while (downloadedUri == null) {\n            val query = DownloadManager.Query().setFilterById(downloadId)\n            val cursor = downloadManager.query(query)\n                ?: throw IllegalStateException(\"无法查询更新包下载状态\")\n\n            cursor.use {\n                if (!it.moveToFirst()) {\n                    throw IllegalStateException(\"更新包下载任务不存在\")\n                }\n\n                when (it.getIntColumn(DownloadManager.COLUMN_STATUS)) {\n                    DownloadManager.STATUS_SUCCESSFUL -> {\n                        downloadedUri = downloadManager.getUriForDownloadedFile(downloadId)\n                            ?: throw IllegalStateException(\"更新包下载完成，但无法获取文件地址\")\n                    }\n\n                    DownloadManager.STATUS_FAILED -> {\n                        val reason = it.getIntColumn(DownloadManager.COLUMN_REASON)\n                        throw IllegalStateException(\"更新包下载失败，错误码 $reason\")\n                    }\n                }\n            }\n\n            if (downloadedUri == null) {\n                delay(DOWNLOAD_POLL_INTERVAL_MS)\n            }\n        }\n\n        return@withContext downloadedUri\n            ?: throw IllegalStateException(\"更新包下载完成，但无法获取文件地址\")\n    }\n\n    private fun openApkInstaller(context: Context, apkUri: Uri) {\n        val intent = Intent(Intent.ACTION_VIEW).apply {\n            setDataAndType(apkUri, APK_MIME_TYPE)\n            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)\n        }\n\n        if (intent.resolveActivity(context.packageManager) == null) {\n            throw IllegalStateException(\"系统中没有可用的 APK 安装器\")\n        }\n\n        context.startActivity(intent)\n    }\n\n    private fun Cursor.getIntColumn(columnName: String): Int =\n        getInt(getColumnIndexOrThrow(columnName))\n\n    private fun sanitizeApkFileName(fileName: String?): String {\n        val normalized = fileName\n            ?.takeIf { it.isNotBlank() }\n            ?.replace(Regex(\"[^A-Za-z0-9._-]\"), \"_\")\n            ?: \"BBPlayer-update-${System.currentTimeMillis()}.apk\"\n\n        return if (normalized.endsWith(\".apk\", ignoreCase = true)) {\n            normalized\n        } else {\n            \"$normalized.apk\"\n        }\n    }\n\n    companion object {\n        private const val APK_MIME_TYPE = \"application/vnd.android.package-archive\"\n        private const val DOWNLOAD_POLL_INTERVAL_MS = 1_000L\n    }\n}\n\nclass AppUpdateDownloadOptions : Record {\n    @Field\n    var url: String = \"\"\n\n    @Field\n    var fileName: String? = null\n\n    @Field\n    var title: String? = null\n\n    @Field\n    var description: String? = null\n}\n"
  },
  {
    "path": "packages/native/expo-module.config.json",
    "content": "{\n\t\"platforms\": [\"android\"],\n\t\"android\": {\n\t\t\"modules\": [\"expo.modules.bbplayernative.BBPlayerNativeModule\"]\n\t}\n}\n"
  },
  {
    "path": "packages/native/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/native\",\n\t\"version\": \"0.1.0\",\n\t\"description\": \"BBPlayer native integrations\",\n\t\"keywords\": [\n\t\t\"BBPlayerNative\",\n\t\t\"bbplayer\",\n\t\t\"expo\",\n\t\t\"react-native\"\n\t],\n\t\"homepage\": \"https://github.com/bbplayer-app/bbplayer/tree/dev/packages/native\",\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer/issues\"\n\t},\n\t\"license\": \"MIT\",\n\t\"author\": \"Roitium <65794453+roitium@users.noreply.github.com> (https://github.com/roitium)\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer.git\",\n\t\t\"directory\": \"packages/native\"\n\t},\n\t\"files\": [\n\t\t\"src\",\n\t\t\"android\",\n\t\t\"expo-module.config.json\"\n\t],\n\t\"sideEffects\": false,\n\t\"main\": \"src/index.ts\",\n\t\"types\": \"src/index.ts\",\n\t\"scripts\": {\n\t\t\"build\": \"expo-module build\",\n\t\t\"clean\": \"expo-module clean\",\n\t\t\"expo-module\": \"expo-module\",\n\t\t\"lint\": \"expo-module lint\",\n\t\t\"open:android\": \"open -a \\\"Android Studio\\\" android\",\n\t\t\"prepublishOnly\": \"expo-module prepublishOnly\",\n\t\t\"test\": \"expo-module test\"\n\t},\n\t\"devDependencies\": {\n\t\t\"expo\": \"55.0.4\",\n\t\t\"expo-module-scripts\": \"^5.0.7\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\"\n\t},\n\t\"peerDependencies\": {\n\t\t\"expo\": \"55.0.4\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\"\n\t}\n}\n"
  },
  {
    "path": "packages/native/src/BBPlayerNative.types.ts",
    "content": "export interface AppUpdateDownloadOptions {\n\turl: string\n\tfileName?: string\n\ttitle?: string\n\tdescription?: string\n}\n\nexport interface AppUpdateInstallResult {\n\tdownloadId: number\n\turi: string\n}\n"
  },
  {
    "path": "packages/native/src/BBPlayerNativeModule.ts",
    "content": "import { NativeModule, requireNativeModule } from 'expo'\nimport { Platform } from 'react-native'\n\nimport type {\n\tAppUpdateDownloadOptions,\n\tAppUpdateInstallResult,\n} from './BBPlayerNative.types'\n\ndeclare class BBPlayerNativeModule extends NativeModule {\n\tgetSupportedAbisAsync(): Promise<string[]>\n\tcanRequestPackageInstallsAsync(): Promise<boolean>\n\topenPackageInstallerSettingsAsync(): Promise<void>\n\tdownloadAndInstallApkAsync(\n\t\toptions: AppUpdateDownloadOptions,\n\t): Promise<AppUpdateInstallResult>\n}\n\nlet nativeModule: BBPlayerNativeModule | null = null\n\nconst getNativeModule = () => {\n\tif (Platform.OS !== 'android') {\n\t\tthrow new Error(\n\t\t\t'BBPlayerNative app updates are only implemented on Android',\n\t\t)\n\t}\n\tnativeModule ??= requireNativeModule<BBPlayerNativeModule>('BBPlayerNative')\n\treturn nativeModule\n}\n\nexport const canRequestPackageInstallsAsync = () =>\n\tgetNativeModule().canRequestPackageInstallsAsync()\n\nexport const getSupportedAbisAsync = () =>\n\tgetNativeModule().getSupportedAbisAsync()\n\nexport const openPackageInstallerSettingsAsync = () =>\n\tgetNativeModule().openPackageInstallerSettingsAsync()\n\nexport const downloadAndInstallApkAsync = (options: AppUpdateDownloadOptions) =>\n\tgetNativeModule().downloadAndInstallApkAsync(options)\n"
  },
  {
    "path": "packages/native/src/index.ts",
    "content": "export * from './BBPlayerNativeModule'\nexport * from './BBPlayerNative.types'\n"
  },
  {
    "path": "packages/orpheus/.gitignore",
    "content": "# OSX\n#\n.DS_Store\n\n# VSCode\n.vscode/\njsconfig.json\n\n# Xcode\n#\nbuild/\n*.pbxuser\n!default.pbxuser\n*.mode1v3\n!default.mode1v3\n*.mode2v3\n!default.mode2v3\n*.perspectivev3\n!default.perspectivev3\nxcuserdata\n*.xccheckout\n*.moved-aside\nDerivedData\n*.hmap\n*.ipa\n*.xcuserstate\nproject.xcworkspace\n\n# Android/IJ\n#\n.classpath\n.cxx\n.gradle\n.idea\n.project\n.settings\nlocal.properties\nandroid.iml\nandroid/app/libs\nandroid/keystores/debug.keystore\n\n# Cocoapods\n#\nexample/ios/Pods\n\n# Ruby\nexample/vendor/\n\n# node.js\n#\nnode_modules/\nnpm-debug.log\nyarn-debug.log\nyarn-error.log\n\n# Expo\n.expo/*\n.env"
  },
  {
    "path": "packages/orpheus/.lyricon_version",
    "content": "532f1392504c859d1e6832ca209f79f9763ca058\n"
  },
  {
    "path": "packages/orpheus/AGENTS.md",
    "content": "# BBPlayer Orpheus Audio Module\n\n**Location:** `packages/orpheus/`\n**Type:** Expo Native Module\n**Purpose:** High-performance audio playback with Bilibili integration\n\n---\n\n## OVERVIEW\n\nCustom Expo native module providing audio playback for BBPlayer. Replaces third-party libraries with tight Android Media3 (ExoPlayer) and AVFoundation integration.\n\n**Key Features:**\n\n- Bilibili audio stream protocol support\n- Dual-layer caching (download + LRU playback cache)\n- Desktop lyrics (Android only)\n- Spectrum visualization (Android only)\n- Seamless playback (Android only)\n\n---\n\n## STRUCTURE\n\n```\n.\n├── src/                          # TypeScript source\n│   ├── index.ts                 # Main entry point\n│   ├── ExpoOrpheusModule.ts     # Module definition\n│   ├── headless.ts              # Headless task registration\n│   └── hooks/                   # React hooks\n│       ├── useOrpheus.ts\n│       └── useOrpheusEvent.ts\n├── android/                      # Android native code\n│   └── src/main/java/\n│       ├── expo/modules/orpheus/\n│       │   ├── OrpheusModule.kt\n│       │   ├── OrpheusService.kt\n│       │   ├── OrpheusView.kt\n│       │   ├── manager/\n│       │   └── util/\n│       └── io/github/proify/lyricon/\n│           └── provider/        # Lyricon integration\n├── ios/                          # iOS native code\n│   └── OrpheusModule.swift\n├── example/                      # Standalone test app\n│   ├── src/\n│   ├── App.tsx\n│   └── index.ts\n└── expo-module.config.json       # Module configuration\n```\n\n---\n\n## WHERE TO LOOK\n\n| Task                  | Location                                      | Notes                               |\n| --------------------- | --------------------------------------------- | ----------------------------------- |\n| **Public API**        | `src/index.ts`                                | Main exports                        |\n| **Module Definition** | `src/ExpoOrpheusModule.ts`                    | Native module interface             |\n| **Hooks**             | `src/hooks/`                                  | React integration                   |\n| **Headless Tasks**    | `src/headless.ts`                             | Platform-specific task registration |\n| **Android Native**    | `android/src/main/java/expo/modules/orpheus/` | Kotlin implementation               |\n| **iOS Native**        | `ios/OrpheusModule.swift`                     | Swift implementation                |\n| **Lyricon**           | `android/.../io/github/proify/lyricon/`       | Lyric provider integration          |\n\n---\n\n## CONVENTIONS\n\n### Native Module Structure\n\n```typescript\n// src/ExpoOrpheusModule.ts\nimport { requireNativeModule } from 'expo-modules-core'\n\nexport interface OrpheusModuleType {\n\tplay(track: Track): Promise<void>\n\tpause(): Promise<void>\n\tseek(position: number): Promise<void>\n\t// ...\n}\n\nexport default requireNativeModule<OrpheusModuleType>('Orpheus')\n```\n\n### React Hooks Pattern\n\n```typescript\n// src/hooks/useOrpheus.ts\nexport function useOrpheus() {\n\tconst module = useRef(OrpheusModule)\n\n\treturn {\n\t\tplay: module.current.play.bind(module.current),\n\t\tpause: module.current.pause.bind(module.current),\n\t\t// ...\n\t}\n}\n```\n\n### Platform-Specific Code\n\n```typescript\n// src/headless.ts\nimport { AppRegistry, Platform } from 'react-native'\n\nexport function registerHeadlessTask() {\n\tif (Platform.OS === 'android') {\n\t\t// Android: Use headless JS\n\t\tAppRegistry.registerHeadlessTask('OrpheusTask', () => async (data) => {\n\t\t\t/* ... */\n\t\t})\n\t} else {\n\t\t// iOS: Use native event bridge\n\t\t// Implementation in Swift\n\t}\n}\n```\n\n---\n\n## ANTI-PATTERNS\n\n### 🚫 NEVER\n\n- Modify Lyricon code directly (vendor code in `io/github/proify/lyricon/`)\n- Use iOS-specific features without Android fallback (or vice versa)\n- Skip testing in example app before publishing\n\n### ⚠️ CAUTION\n\n- Lyricon uses Kotlin 2.3.0 - metadata incompatibility with main project\n- iOS support is minimal - many features unimplemented\n- Desktop lyrics impossible on iOS (system limitation)\n\n---\n\n## UNIQUE STYLES\n\n### Platform Abstraction\n\n```typescript\n// Features split by platform\nconst features = {\n\tdesktopLyrics: Platform.OS === 'android',\n\tspectrum: Platform.OS === 'android',\n\tseamlessPlayback: Platform.OS === 'android',\n\tloudnessNormalization: Platform.OS === 'android',\n}\n```\n\n### Native Event Handling\n\n```typescript\n// src/hooks/useOrpheusEvent.ts\nimport { useEvent } from 'expo-modules-core'\n\nexport function usePlaybackState() {\n\tconst [state, setState] = useState<PlaybackState>('idle')\n\n\tuseEvent(OrpheusModule, 'onPlaybackStateChange', (event) => {\n\t\tsetState(event.state)\n\t})\n\n\treturn state\n}\n```\n\n### Lyricon Integration\n\nLyricon code vendored due to Kotlin version incompatibility:\n\n```kotlin\n// android/.../lyricon/provider/\n// Direct source copy from tomakino/lyricon\n// Do NOT modify - treat as vendor code\n```\n\n---\n\n## COMMANDS\n\n```bash\n# Development\ncd packages/orpheus\npnpm build              # Build module\npnpm test               # Run tests\npnpm lint               # Lint\n\n# Example App\ncd example\npnpm install\npnpm android            # Run example on Android\npnpm ios               # Run example on iOS\n\n# Open Android Studio\npnpm open:android\n```\n\n---\n\n## NOTES\n\n### Lyricon Vendoring\n\nProject includes Lyricon source directly (not npm dependency):\n\n- Reason: Kotlin 2.3.0 vs main project lower version = metadata incompatibility\n- Location: `android/src/main/java/io/github/proify/lyricon/`\n- Policy: Treat as vendor code - do not modify\n\n### iOS Limitations\n\nFeatures NOT available on iOS:\n\n- Desktop lyrics (system limitation - impossible)\n- Spectrum visualization\n- Seamless playback\n- Loudness normalization\n- Cover download for offline playback\n- Batch export of downloaded songs\n\n### Module Configuration\n\n`expo-module.config.json`:\n\n```json\n{\n\t\"platforms\": [\"ios\", \"android\"],\n\t\"ios\": {\n\t\t\"modules\": [\"OrpheusModule\"]\n\t},\n\t\"android\": {\n\t\t\"modules\": [\"expo.modules.orpheus.OrpheusModule\"]\n\t}\n}\n```\n\n### Caching Strategy\n\n- **Download Cache**: Persistent downloaded files\n- **Playback Cache**: LRU cache for streaming (Media3 DownloadManager)\n- Both managed at native layer\n\n### Bilibili Integration\n\n- Automatic audio stream URL resolution\n- High bitrate support\n- Cookie-based authentication passed from JS layer\n"
  },
  {
    "path": "packages/orpheus/CHANGELOG.md",
    "content": "## [0.11.5] (2026-02-10)\n\n### Changed\n\n- 重构 Android 端 player 初始化逻辑，支持 player 被释放后自动重建（`ensurePlayer`）。\n\n## [0.11.4] (2026-02-07)\n\n### Changed\n\n- 同步 `packages/orpheus/docs` 文档，补全缺失的 API 方法、事件和类型定义。\n- 删除 `RELEASING.md` 发版指南及相关 npm 配置文件，本项目不再发布到 npm。\n\n## [0.11.3](https://github.com/bbplayer-app/orpheus/compare/v0.11.2...v0.11.3) (2026-02-02)\n\n### Changed\n\n- use kotlinx.serialization instead of gson\n\n## [0.11.2](https://github.com/bbplayer-app/orpheus/compare/v0.11.1...v0.11.2) (2026-01-28)\n\n### Bug Fixes\n\n- use mmkv from Wu to match react-native-mmkv deps ([15dba67](https://github.com/bbplayer-app/orpheus/commit/15dba678a42afb6b324b22143b8ad15dabd6d981))\n\n## [0.11.1](https://github.com/bbplayer-app/orpheus/compare/v0.11.0...v0.11.1) (2026-01-27)\n\n# [0.11.0](https://github.com/bbplayer-app/orpheus/compare/v0.10.1...v0.11.0) (2026-01-27)\n\n### Features\n\n- error with stackTrace ([1627f2c](https://github.com/bbplayer-app/orpheus/commit/1627f2c9f971a6c712f62482a0797fd25adab7df))\n\n## [0.10.1](https://github.com/bbplayer-app/orpheus/compare/v0.10.0...v0.10.1) (2026-01-27)\n\n### Bug Fixes\n\n- disable experimentalSetMediaCodecAsyncCryptoFlagEnabled in exoplayer ([7f69b15](https://github.com/bbplayer-app/orpheus/commit/7f69b15552da69f232f932df71c38e35deec8684))\n\n# [0.10.0](https://github.com/bbplayer-app/orpheus/compare/v0.9.4...v0.10.0) (2026-01-27)\n\n### Features\n\n- 1 ([ef1f7ba](https://github.com/bbplayer-app/orpheus/commit/ef1f7ba5ad05d23f62b5c0d19b84639355b8280b))\n- 1 ([f386be4](https://github.com/bbplayer-app/orpheus/commit/f386be4fd7a0ac05bff6d31e31ec87094c7bdccf))\n- Add commitlint, husky hooks, and release-it, enhance the example application with new UI components and test data, and provide comprehensive API documentation. ([7b8aefd](https://github.com/bbplayer-app/orpheus/commit/7b8aefd94f0789a3c0686074e1c9f68bcae0b901))\n"
  },
  {
    "path": "packages/orpheus/README.md",
    "content": "# @bbplayer/orpheus\n\nBBPlayer 高性能核心音频播放模块。\n\n## 简介\n\n这是一个为 BBPlayer 项目定制的音频播放库，旨在替代第三方库以提供与 Android Media3 (ExoPlayer) 和 AVFoundation 更紧密的集成，并针对 Bilibili 音频流逻辑提供原生层支持。\n\n## 功能特性\n\n- **Bilibili 集成**：自动处理 Bilibili 音频流协议，支持高码率解析。\n- **双层缓存机制**：包含独立的下载缓存和边下边播 LRU 缓存。\n- **Android Media3**：基于最新的 Media3 架构，提供更好的稳定性。\n- **桌面歌词支持**：实现系统级桌面歌词悬浮窗的原生支持。\n- **高性能**：针对移动端性能优化的零拷贝提取与流处理。\n\n## 文档\n\n详细的 API 文档和使用说明请参阅目录下的 [docs](./docs) 文件夹。\n\n## IOS 支援\n\n目前这个库只进行了基础的 IOS 适配（俗称：「管生不管养」），因为我们目前没有开发 IOS 端的计划。以下列出了一部分 Android 端有但 IOS 还没有实现（或无法实现）的特性：\n\n- 桌面歌词（无法实现）\n- 频谱\n- 无缝播放\n- 响度均衡\n- 封面下载（用于离线播放）\n- 批量导出下载歌曲 (Android-only)\n\n## 声明\n\n该库主要供 BBPlayer 内部使用。虽然代码开源，但我们主要关注满足 BBPlayer 的功能需求。\n\n## 关于 Lyricon\n\n本项目在 `packages/orpheus/android` 中内置了来自 [tomakino/lyricon](https://github.com/tomakino/lyricon) 的部分核心代码，用于处理 Lyricon 相关的连接逻辑。\n\n采用直接克隆代码而非引入依赖库的方式，是因为 Lyricon 使用了 kotlin 2.3.0，而我们主项目的 kotlin 版本更低，导致 metadata 不兼容，无法直接引入。\n"
  },
  {
    "path": "packages/orpheus/android/build.gradle",
    "content": "apply plugin: 'com.android.library'\napply plugin: 'kotlin-kapt'\napply plugin: 'kotlinx-serialization'\napply plugin: 'kotlin-parcelize'\n\nimport groovy.json.JsonSlurper\n\ndef packageJsonFile = new File(projectDir, '../package.json')\ndef packageJson = new JsonSlurper().parseText(packageJsonFile.text)\n\ngroup = 'expo.modules.orpheus'\nversion = packageJson.version\n\ndef expoModulesCorePlugin = new File(project(\":expo-modules-core\").projectDir.absolutePath, \"ExpoModulesCorePlugin.gradle\")\napply from: expoModulesCorePlugin\napplyKotlinExpoModulesCorePlugin()\nuseCoreDependencies()\nuseExpoPublishing()\n\n// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.\n// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.\n// Most of the time, you may like to manage the Android SDK versions yourself.\ndef useManagedAndroidSdkVersions = false\nif (useManagedAndroidSdkVersions) {\n    useDefaultAndroidSdkVersions()\n} else {\n    buildscript {\n        // Simple helper that allows the root project to override versions declared by this library.\n        ext.safeExtGet = { prop, fallback ->\n            rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback\n        }\n    }\n    project.android {\n        compileSdkVersion safeExtGet(\"compileSdkVersion\", 36)\n        defaultConfig {\n            minSdkVersion safeExtGet(\"minSdkVersion\", 24)\n            targetSdkVersion safeExtGet(\"targetSdkVersion\", 36)\n        }\n    }\n}\n\nandroid {\n    namespace \"expo.modules.orpheus\"\n    defaultConfig {\n        versionCode 1\n        versionName packageJson.version\n    }\n    buildFeatures {\n        aidl = true\n    }\n    lintOptions {\n        abortOnError false\n    }\n    kotlinOptions {\n        freeCompilerArgs += [\n            \"-opt-in=kotlin.io.encoding.ExperimentalEncodingApi\",\n            \"-opt-in=kotlinx.serialization.ExperimentalSerializationApi\"\n        ]\n    }\n}\n\ndependencies {\n    implementation 'com.github.HChenX:SuperLyricApi:2.4'\n    implementation \"androidx.media3:media3-exoplayer:1.9.0\"\n    implementation \"androidx.media3:media3-session:1.9.0\"\n    implementation \"androidx.media3:media3-transformer:1.9.0\"\n    implementation(\"androidx.media3:media3-exoplayer-dash:1.9.0\")\n    implementation \"net.jthink:jaudiotagger:3.0.1\"\n    implementation \"com.squareup.retrofit2:retrofit:2.9.0\"\n    implementation \"org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1\"\n    implementation \"com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0\"\n    implementation \"io.github.zhongwuzw:mmkv:2.2.4\"\n    implementation \"com.github.bumptech.glide:glide:4.16.0\"\n    implementation \"androidx.media3:media3-datasource-okhttp:1.9.0\"\n    implementation \"com.squareup.okhttp3:okhttp:4.12.0\"\n    implementation \"androidx.documentfile:documentfile:1.0.1\"\n    compileOnly \"com.facebook.react:react-android\"\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/AndroidManifest.xml",
    "content": "<manifest xmlns:tools=\"http://schemas.android.com/tools\"\n    xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE\" />\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK\" />\n    <uses-permission android:name=\"android.permission.WAKE_LOCK\" />\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_DATA_SYNC\" />\n    <uses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\" />\n    <uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\" />\n    <uses-permission android:name=\"android.permission.RECORD_AUDIO\" />\n\n    <!-- Required for exporting to public music directory on API < 29 -->\n    <uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"\n        android:maxSdkVersion=\"28\" />\n\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n\n    <!--\n        Lyricon requires Android 8.1 (API 27) per its own manifest, but our app supports API 24+.\n        This override allows the manifest merger to succeed; runtime guards in\n        OrpheusMusicService.createStatusBarBackend() prevent Lyricon from being instantiated\n        on API < 27 devices.\n    -->\n    <uses-sdk tools:overrideLibrary=\"io.github.proify.lyricon.provider\" />\n\n    <queries>\n        <intent>\n            <action android:name=\"android.intent.action.OPEN_DOCUMENT_TREE\" />\n        </intent>\n    </queries>\n\n    <application>\n        <meta-data android:name=\"lyricon_module\" android:value=\"true\" />\n        <meta-data android:name=\"lyricon_module_author\" android:value=\"Roitium\" />\n        <meta-data android:name=\"lyricon_module_description\" android:value=\"感谢使用 BBPlayer 喵！\" />\n        <meta-data android:name=\"lyricon_module_tags\" android:resource=\"@array/lyricon_module_tags\" />\n        <service\n            android:name=\".service.OrpheusMusicService\"\n            android:enabled=\"true\"\n            android:exported=\"true\"\n            android:foregroundServiceType=\"mediaPlayback\"\n            tools:ignore=\"ExportedService\">\n            <intent-filter>\n                <action android:name=\"androidx.media3.session.MediaLibraryService\" />\n                <action android:name=\"android.media.browse.MediaBrowserService\" />\n            </intent-filter>\n        </service>\n        <service\n            android:name=\".service.OrpheusDownloadService\"\n            android:exported=\"false\"\n            android:foregroundServiceType=\"dataSync\">\n            <intent-filter>\n                <action android:name=\"androidx.media3.exoplayer.downloadService.action.RESTART\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n            </intent-filter>\n        </service>\n        <service android:name=\".service.OrpheusHeadlessTaskService\" />\n    </application>\n</manifest>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/aidl/io/github/proify/lyricon/lyric/model/Song.aidl",
    "content": "package io.github.proify.lyricon.lyric.model;\n\nparcelable Song;"
  },
  {
    "path": "packages/orpheus/android/src/main/aidl/io/github/proify/lyricon/provider/IProviderBinder.aidl",
    "content": "package io.github.proify.lyricon.provider;\n\nimport io.github.proify.lyricon.provider.IRemoteService;\nimport io.github.proify.lyricon.provider.IProviderService;\n\ninterface IProviderBinder {\n    void onRegistrationCallback(IRemoteService service);\n    IProviderService getProviderService();\n    byte[] getProviderInfo();\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/aidl/io/github/proify/lyricon/provider/IProviderService.aidl",
    "content": "package io.github.proify.lyricon.provider;\n\nimport android.content.Intent;\nimport android.os.Bundle;\n\ninterface IProviderService {\n    Bundle onRunCommand(in Intent intent);\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/aidl/io/github/proify/lyricon/provider/IRemotePlayer.aidl",
    "content": "package io.github.proify.lyricon.provider;\n\nimport android.os.SharedMemory;\nimport io.github.proify.lyricon.lyric.model.Song;\n\n//添加新方法，必须放在最后，保证aidl签名顺序，确保各api版本兼容性\ninterface IRemotePlayer {\n    void setSong(in byte[] song);\n    void setPlaybackState(boolean isPlaying);\n    void seekTo(long position);\n    void sendText(String text);\n    void setPositionUpdateInterval(int interval);\n    void setDisplayTranslation(boolean isDisplayTranslation);\n    SharedMemory getPositionMemory();\n    void setDisplayRoma(boolean isDisplayRoma);\n\n    //依赖[android.media.session.PlaybackState]实现判断播放状态，计算播放位置\n    void setPlaybackState2(in PlaybackState state);\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/aidl/io/github/proify/lyricon/provider/IRemoteService.aidl",
    "content": "package io.github.proify.lyricon.provider;\n\nimport io.github.proify.lyricon.provider.IRemotePlayer;\n\ninterface IRemoteService {\n    IRemotePlayer getPlayer();\n    void disconnect();\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/aidl/io/github/proify/lyricon/provider/ProviderInfo.aidl",
    "content": "package io.github.proify.lyricon.provider;\n\nparcelable ProviderInfo;"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/ExpoOrpheusModule.kt",
    "content": "package expo.modules.orpheus\n\nimport android.content.ComponentName\nimport android.content.Context\nimport android.content.Intent\nimport android.net.Uri\nimport android.os.Build\nimport android.os.Handler\nimport android.os.Looper\nimport android.util.Log\nimport androidx.annotation.OptIn\nimport androidx.core.net.toUri\nimport androidx.media3.common.C\nimport androidx.media3.common.MediaItem\nimport androidx.media3.common.PlaybackException\nimport androidx.media3.common.Player\nimport androidx.media3.common.Timeline\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.exoplayer.offline.Download\nimport androidx.media3.exoplayer.offline.DownloadManager\nimport androidx.media3.exoplayer.offline.DownloadRequest\nimport androidx.media3.exoplayer.offline.DownloadService\nimport androidx.media3.session.MediaController\nimport androidx.media3.session.SessionToken\nimport com.google.common.util.concurrent.ListenableFuture\nimport expo.modules.kotlin.activityresult.AppContextActivityResultLauncher\nimport expo.modules.kotlin.functions.Coroutine\nimport expo.modules.kotlin.modules.Module\nimport expo.modules.kotlin.modules.ModuleDefinition\nimport expo.modules.kotlin.typedarray.Float32Array\nimport expo.modules.orpheus.util.DirectoryPickerContract\nimport expo.modules.orpheus.exception.ControllerNotInitializedException\nimport expo.modules.orpheus.manager.CoverDownloadManager\nimport expo.modules.orpheus.manager.LyricsConsumer\nimport expo.modules.orpheus.manager.LyriconBackend\nimport expo.modules.orpheus.manager.SpectrumManager\nimport expo.modules.orpheus.model.TrackRecord\nimport expo.modules.orpheus.service.OrpheusDownloadService\nimport expo.modules.orpheus.service.OrpheusMusicService\nimport expo.modules.orpheus.util.DownloadUtil\nimport expo.modules.orpheus.util.ExportOptions\nimport expo.modules.orpheus.util.GeneralStorage\nimport expo.modules.orpheus.util.LoudnessStorage\nimport expo.modules.orpheus.util.runExportDownloads\nimport expo.modules.orpheus.util.toJsMap\nimport expo.modules.orpheus.util.toMediaItem\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.CancellationException\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.cancel\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\n\n@UnstableApi\nclass ExpoOrpheusModule : Module() {\n    // keep this controller only to make sure MediaLibraryService is init.\n    private var controllerFuture: ListenableFuture<MediaController>? = null\n\n    private var player: Player? = null\n\n    private val mainHandler = Handler(Looper.getMainLooper())\n\n    private var downloadManager: DownloadManager? = null\n\n    private val spectrumManager = SpectrumManager()\n    private var tempBuffer: FloatArray? = null\n\n    private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())\n\n    // applicationContext 在 OnCreate 时缓存，生命周期与 Application 一致，\n    // 不受 React Native 组件卸载导致 reactContext 变 null 的影响。\n    private var cachedAppContext: Context? = null\n\n    private lateinit var directoryPickerLauncher: AppContextActivityResultLauncher<String, String?>\n\n    // 记录上一首歌曲的 ID，用于在切歌时发送给 JS\n    private var lastMediaId: String? = null\n\n    val json = Json { ignoreUnknownKeys = true }\n\n    private val playerListener = object : Player.Listener {\n\n        /**\n         * 核心：处理切歌、播放结束逻辑\n         */\n        override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {\n            val newId = mediaItem?.mediaId ?: \"\"\n            Log.e(\"Orpheus\", \"onMediaItemTransition: $reason\")\n\n            // Headless task is handled by Service, no need to send event here if removed from API\n            lastMediaId = newId\n            saveCurrentPosition()\n        }\n\n        override fun onTimelineChanged(timeline: Timeline, reason: Int) {\n            // Logic moved to Service\n        }\n\n        override fun onPositionDiscontinuity(\n            oldPosition: Player.PositionInfo,\n            newPosition: Player.PositionInfo,\n            reason: Int\n        ) {\n            // Logic moved to Service\n        }\n\n\n        /**\n         * 处理播放状态改变\n         */\n        override fun onPlaybackStateChanged(state: Int) {\n            // state: 1=IDLE, 2=BUFFERING, 3=READY, 4=ENDED\n            sendEvent(\n                \"onPlaybackStateChanged\", mapOf(\n                    \"state\" to state\n                )\n            )\n\n            updateProgressRunnerState()\n        }\n\n        /**\n         * 处理播放/暂停状态\n         */\n        override fun onIsPlayingChanged(isPlaying: Boolean) {\n            sendEvent(\n                \"onIsPlayingChanged\", mapOf(\n                    \"status\" to isPlaying\n                )\n            )\n\n            if (isPlaying) {\n                player?.audioSessionId?.let { sessionId ->\n                    if (sessionId != C.AUDIO_SESSION_ID_UNSET) {\n                        spectrumManager.start(sessionId)\n                    }\n                }\n            } else {\n                spectrumManager.stop()\n            }\n\n            updateProgressRunnerState()\n        }\n\n        /**\n         * 处理错误\n         */\n        override fun onPlayerError(error: PlaybackException) {\n            val map = error.toJsMap().toMutableMap()\n            map[\"platform\"] = \"android\"\n            sendEvent(\"onPlayerError\", map)\n        }\n\n        override fun onRepeatModeChanged(repeatMode: Int) {\n            super.onRepeatModeChanged(repeatMode)\n            GeneralStorage.saveRepeatMode(repeatMode)\n        }\n\n        override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {\n            super.onShuffleModeEnabledChanged(shuffleModeEnabled)\n            // Persistence is handled by ShuffleManager.setShuffleEnabled; nothing to do here.\n        }\n\n        override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) {\n            sendEvent(\n                \"onPlaybackSpeedChanged\", mapOf(\n                    \"speed\" to playbackParameters.speed\n                )\n            )\n        }\n    }\n\n    @OptIn(UnstableApi::class)\n    override fun definition() = ModuleDefinition {\n        Name(\"Orpheus\")\n\n        Events(\n            \"onPlaybackStateChanged\",\n            \"onPlayerError\",\n            \"onPositionUpdate\",\n            \"onIsPlayingChanged\",\n            \"onDownloadUpdated\",\n            \"onPlaybackSpeedChanged\",\n            \"onTrackStarted\",\n            \"onTrackFinished\",\n            \"onCoverDownloadProgress\",\n            \"onExportProgress\",\n            \"onStatusBarLyricsStatusChanged\",\n            \"onRequestClearLyrics\"\n        )\n\n        RegisterActivityContracts {\n            directoryPickerLauncher = registerForActivityResult(DirectoryPickerContract())\n        }\n\n        OnCreate {\n            val context = appContext.reactContext ?: return@OnCreate\n            cachedAppContext = context.applicationContext\n            GeneralStorage.initialize(context)\n            LoudnessStorage.initialize(context)\n            expo.modules.orpheus.manager.CachedUriManager.initialize(context)\n            val sessionToken = SessionToken(\n                context,\n                ComponentName(context, OrpheusMusicService::class.java)\n            )\n            controllerFuture = MediaController.Builder(context, sessionToken)\n                .setApplicationLooper(Looper.getMainLooper()).buildAsync()\n\n\n            OrpheusMusicService.addOnServiceReadyListener { service ->\n                mainHandler.post {\n                    if (this@ExpoOrpheusModule.player != service.player) {\n                        this@ExpoOrpheusModule.player?.removeListener(playerListener)\n                        this@ExpoOrpheusModule.player = service.player\n                        this@ExpoOrpheusModule.player?.addListener(playerListener)\n                    }\n\n                    service.statusBarLyricsManager.setStatusChangeListener(object : expo.modules.orpheus.manager.StatusBarLyricsManager.StatusChangeListener {\n                        override fun onStatusChanged() {\n                            sendEvent(\"onStatusBarLyricsStatusChanged\", emptyMap<String, Any>())\n                        }\n                    })\n\n                    service.addTrackEventListener(object : OrpheusMusicService.TrackEventListener {\n                        override fun onTrackStarted(trackId: String, reason: Int) {\n                            sendEvent(\n                                \"onTrackStarted\", mapOf(\n                                    \"trackId\" to trackId,\n                                    \"reason\" to reason\n                                )\n                            )\n                        }\n\n                        override fun onTrackFinished(\n                            trackId: String,\n                            finalPosition: Double,\n                            duration: Double\n                        ) {\n                            sendEvent(\n                                \"onTrackFinished\", mapOf(\n                                    \"trackId\" to trackId,\n                                    \"finalPosition\" to finalPosition,\n                                    \"duration\" to duration\n                                )\n                            )\n                        }\n                    })\n\n                    service.addLyricEventListener(object : OrpheusMusicService.LyricEventListener {\n                        override fun onLyricCleared(trackId: String) {\n                            sendEvent(\n                                \"onRequestClearLyrics\", mapOf(\n                                    \"trackId\" to trackId\n                                )\n                            )\n                        }\n                    })\n                }\n            }\n\n            downloadManager = DownloadUtil.getDownloadManager(context)\n            downloadManager?.addListener(downloadListener)\n        }\n\n        OnDestroy {\n            mainHandler.post {\n                mainHandler.removeCallbacks(progressSendEventRunnable)\n                mainHandler.removeCallbacks(progressSaveRunnable)\n                mainHandler.removeCallbacks(downloadProgressRunnable)\n                controllerFuture?.let { MediaController.releaseFuture(it) }\n                downloadManager?.removeListener(downloadListener)\n                player?.removeListener(playerListener)\n                OrpheusMusicService.removeOnServiceReadyListener { }\n                player = null\n                spectrumManager.stop()\n                ioScope.cancel()\n                Log.d(\"Orpheus\", \"Destroy media controller\")\n            }\n        }\n\n        Property(\"restorePlaybackPositionEnabled\")\n            .get { GeneralStorage.isRestoreEnabled() }\n            .set { enabled: Boolean -> GeneralStorage.setRestoreEnabled(enabled) }\n\n        Property(\"loudnessNormalizationEnabled\")\n            .get { GeneralStorage.isLoudnessNormalizationEnabled() }\n            .set { enabled: Boolean -> GeneralStorage.setLoudnessNormalizationEnabled(enabled) }\n\n        Property(\"autoplayOnStartEnabled\")\n            .get { GeneralStorage.isAutoplayOnStartEnabled() }\n            .set { enabled: Boolean -> GeneralStorage.setAutoplayOnStartEnabled(enabled) }\n\n        Property(\"isDesktopLyricsShown\")\n            .get { GeneralStorage.isDesktopLyricsShown() }\n\n        Property(\"isDesktopLyricsLocked\")\n            .get { GeneralStorage.isDesktopLyricsLocked() }\n            .set { locked: Boolean ->\n                mainHandler.post {\n                    OrpheusMusicService.instance?.floatingLyricsManager?.setLocked(locked)\n                }\n            }\n\n        Property(\"isStatusBarLyricsEnabled\")\n            .get { GeneralStorage.isStatusBarLyricsEnabled() }\n            .set { enabled: Boolean ->\n                mainHandler.post {\n                    GeneralStorage.setStatusBarLyricsEnabled(enabled)\n                    OrpheusMusicService.instance?.statusBarLyricsManager?.enabled = enabled\n                }\n            }\n\n        Property(\"isCarLyricsEnabled\")\n            .get { GeneralStorage.isCarLyricsEnabled() }\n            .set { enabled: Boolean ->\n                mainHandler.post {\n                    GeneralStorage.setCarLyricsEnabled(enabled)\n                    OrpheusMusicService.instance?.setCarLyricsEnabled(enabled)\n                }\n            }\n\n        Property(\"statusBarLyricsProvider\")\n            .get { GeneralStorage.getStatusBarLyricsProvider() }\n            .set { provider: String ->\n                mainHandler.post {\n                    // Lyricon requires API 27+; silently fall back to superlyric on older devices\n                    // so the persisted value always reflects what is actually used.\n                    val effective = if (provider == \"lyricon\" && Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {\n                        \"superlyric\"\n                    } else {\n                        provider\n                    }\n                    GeneralStorage.setStatusBarLyricsProvider(effective)\n                    val service = OrpheusMusicService.instance ?: return@post\n                    service.statusBarLyricsManager.backend = service.createStatusBarBackend(effective)\n                }\n            }\n\n        Property(\"isSuperLyricApiEnabled\")\n            .get { com.hchen.superlyricapi.SuperLyricTool.isEnabled }\n\n        Property(\"isLyriconApiEnabled\")\n            .get {\n                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return@get false\n                OrpheusMusicService.instance?.statusBarLyricsManager?.backend\n                    ?.let { it is LyriconBackend && it.isAvailable }\n                    ?: false\n            }\n\n\n        Function(\"setBilibiliCookie\") { cookie: String ->\n            OrpheusConfig.bilibiliCookie = cookie\n        }\n\n        AsyncFunction(\"getPosition\") Coroutine { ->\n            withPlayerOnMainThread { it.currentPosition.toDouble() / 1000.0 }\n        }\n\n        AsyncFunction(\"getDuration\") Coroutine { ->\n            val d = withPlayerOnMainThread { it.duration }\n            if (d == C.TIME_UNSET) 0.0 else d.toDouble() / 1000.0\n        }\n\n        AsyncFunction(\"getBuffered\") Coroutine { ->\n            withPlayerOnMainThread { it.bufferedPosition.toDouble() / 1000.0 }\n        }\n\n        AsyncFunction(\"getIsPlaying\") Coroutine { ->\n            withPlayerOnMainThread { it.isPlaying }\n        }\n\n        AsyncFunction(\"getCurrentIndex\") Coroutine { ->\n            withPlayerOnMainThread { it.currentMediaItemIndex }\n        }\n\n        AsyncFunction(\"getCurrentTrack\") Coroutine { ->\n            val currentItem = withPlayerOnMainThread { it.currentMediaItem } ?: return@Coroutine null\n\n            mediaItemToTrackRecord(currentItem)\n        }\n\n        AsyncFunction(\"getShuffleMode\") {\n            // Read from persisted state (managed by ShuffleManager).\n            GeneralStorage.getShuffleMode()\n        }\n\n        AsyncFunction(\"getIndexTrack\") Coroutine { index: Int ->\n            val item = withPlayerOnMainThread { currentPlayer ->\n                if (index < 0 || index >= currentPlayer.mediaItemCount) {\n                    return@withPlayerOnMainThread null\n                }\n\n                currentPlayer.getMediaItemAt(index)\n            }\n                ?: return@Coroutine null\n\n            mediaItemToTrackRecord(item)\n        }\n\n        AsyncFunction(\"play\") Coroutine { ->\n            withPlayerOnMainThread { currentPlayer ->\n                if (currentPlayer.playbackState == Player.STATE_ENDED) {\n                    currentPlayer.seekTo(0)\n                }\n                prepareIfIdle(currentPlayer)\n                currentPlayer.play()\n            }\n        }\n\n        AsyncFunction(\"pause\") Coroutine { ->\n            withPlayerOnMainThread { it.pause() }\n        }\n\n        AsyncFunction(\"clear\") Coroutine { ->\n            withPlayerOnMainThread { it.clearMediaItems() }\n        }\n\n        AsyncFunction(\"skipTo\") Coroutine { index: Int ->\n            // 跳转到指定索引的开头\n            // When shuffle is enabled, `index` is the position in the shuffle-traversal\n            // order (as returned by getQueue). Convert to the physical queue index first.\n            withServiceAndPlayerOnMainThread { service, currentPlayer ->\n                if (service.shuffleManager.isEnabled) {\n                    val order = service.shuffleManager.getTraversalOrder()\n                    val physicalIndex = order?.getOrElse(index) { C.INDEX_UNSET } ?: C.INDEX_UNSET\n                    if (physicalIndex != C.INDEX_UNSET) {\n                        currentPlayer.seekTo(physicalIndex, C.TIME_UNSET)\n                    } else {\n                        return@withServiceAndPlayerOnMainThread\n                    }\n                } else {\n                    currentPlayer.seekTo(index, C.TIME_UNSET)\n                }\n                prepareIfIdle(currentPlayer)\n            }\n        }\n\n        AsyncFunction(\"skipToNext\") Coroutine { ->\n            withPlayerOnMainThread { currentPlayer ->\n                // When in REPEAT_MODE_ONE, always allow next - wrap around if at the end\n                val mediaItemCount = currentPlayer.mediaItemCount\n                if (currentPlayer.repeatMode == Player.REPEAT_MODE_ONE\n                    && mediaItemCount > 0\n                    && !currentPlayer.hasNextMediaItem()\n                ) {\n                    currentPlayer.seekTo(0, C.TIME_UNSET)\n                    prepareIfIdle(currentPlayer)\n                    return@withPlayerOnMainThread\n                }\n\n                if (currentPlayer.hasNextMediaItem()) {\n                    currentPlayer.seekToNext()\n                    prepareIfIdle(currentPlayer)\n                }\n            }\n        }\n\n        AsyncFunction(\"skipToPrevious\") Coroutine { ->\n            withPlayerOnMainThread { currentPlayer ->\n                // When in REPEAT_MODE_ONE, always allow previous - wrap around if at the beginning\n                val mediaItemCount = currentPlayer.mediaItemCount\n                if (currentPlayer.repeatMode == Player.REPEAT_MODE_ONE\n                    && mediaItemCount > 0\n                    && !currentPlayer.hasPreviousMediaItem()\n                ) {\n                    currentPlayer.seekTo(mediaItemCount - 1, C.TIME_UNSET)\n                    prepareIfIdle(currentPlayer)\n                    return@withPlayerOnMainThread\n                }\n\n                if (currentPlayer.hasPreviousMediaItem()) {\n                    currentPlayer.seekToPreviousMediaItem()\n                    prepareIfIdle(currentPlayer)\n                }\n            }\n        }\n\n        AsyncFunction(\"seekTo\") Coroutine { seconds: Double ->\n            val ms = (seconds * 1000).toLong()\n            withPlayerOnMainThread { it.seekTo(ms) }\n        }\n\n        AsyncFunction(\"setRepeatMode\") Coroutine { mode: Int ->\n            // mode: 0=OFF, 1=TRACK, 2=QUEUE\n            val repeatMode = when (mode) {\n                1 -> Player.REPEAT_MODE_ONE\n                2 -> Player.REPEAT_MODE_ALL\n                else -> Player.REPEAT_MODE_OFF\n            }\n            withPlayerOnMainThread { it.repeatMode = repeatMode }\n        }\n\n        AsyncFunction(\"setShuffleMode\") Coroutine { enabled: Boolean ->\n            // Delegate to the service's ShuffleManager which uses Media3's built-in\n            // shuffleModeEnabled for O(1) shuffle toggle without physical queue reordering.\n            withServiceOnMainThread { service ->\n                if (service != null) {\n                    service.applyShuffleMode(enabled)\n                } else {\n                    // Service not yet bound — persist the preference for restorePlayerState to pick up\n                    GeneralStorage.saveShuffleMode(enabled)\n                }\n            }\n        }\n\n        AsyncFunction(\"getRepeatMode\") Coroutine { ->\n            withPlayerOnMainThread { it.repeatMode }\n        }\n\n        AsyncFunction(\"removeTrack\") Coroutine { index: Int ->\n            withServiceAndPlayerOnMainThread { service, currentPlayer ->\n                if (service.shuffleManager.isEnabled) {\n                    // index is the shuffle-traversal position; resolve to physical index.\n                    val order = service.shuffleManager.getTraversalOrder()\n                    val physicalIndex = order?.getOrElse(index) { -1 } ?: -1\n                    if (physicalIndex >= 0 && physicalIndex < currentPlayer.mediaItemCount) {\n                        currentPlayer.removeMediaItem(physicalIndex)\n                    }\n                } else {\n                    if (index >= 0 && index < currentPlayer.mediaItemCount) {\n                        currentPlayer.removeMediaItem(index)\n                    }\n                }\n            }\n        }\n\n        AsyncFunction(\"getQueue\") Coroutine { ->\n            val items = withServiceAndPlayerOnMainThread { service, currentPlayer ->\n                // When shuffle is enabled, return items in the logical playback (shuffle traversal)\n                // order so the UI displays what will actually be played next.\n                val traversal = if (service.shuffleManager.isEnabled) {\n                    service.shuffleManager.getTraversalOrder()\n                } else {\n                    null\n                }\n\n                if (traversal != null) {\n                    traversal.map { physicalIdx -> currentPlayer.getMediaItemAt(physicalIdx) }\n                } else {\n                    List(currentPlayer.mediaItemCount) { index -> currentPlayer.getMediaItemAt(index) }\n                }\n            }\n\n            items.map(::mediaItemToTrackRecord)\n        }\n\n        AsyncFunction(\"setSleepTimer\") { durationMs: Long ->\n            OrpheusMusicService.instance?.startSleepTimer(durationMs)\n            return@AsyncFunction null\n        }\n\n        AsyncFunction(\"getSleepTimerEndTime\") {\n            return@AsyncFunction OrpheusMusicService.instance?.getSleepTimerRemaining()\n        }\n\n        AsyncFunction(\"cancelSleepTimer\") {\n            OrpheusMusicService.instance?.cancelSleepTimer()\n            return@AsyncFunction null\n        }\n\n        AsyncFunction(\"addToEnd\") Coroutine { tracks: List<TrackRecord>, startFromId: String?, clearQueue: Boolean? ->\n            val context = appContext.reactContext\n            val mediaItems = tracks.map { track ->\n                track.toMediaItem(context)\n            }\n            withPlayerOnMainThread { currentPlayer ->\n                if (clearQueue == true) {\n                    currentPlayer.clearMediaItems()\n                }\n                val initialSize = currentPlayer.mediaItemCount\n                currentPlayer.addMediaItems(mediaItems)\n\n                if (!startFromId.isNullOrEmpty()) {\n                    val relativeIndex = tracks.indexOfFirst { it.id == startFromId }\n\n                    if (relativeIndex != -1) {\n                        val targetIndex = initialSize + relativeIndex\n\n                        currentPlayer.seekTo(targetIndex, C.TIME_UNSET)\n                        currentPlayer.prepare()\n                        currentPlayer.play()\n\n                        return@withPlayerOnMainThread\n                    }\n                }\n\n                if (currentPlayer.playbackState == Player.STATE_IDLE) {\n                    currentPlayer.prepare()\n                }\n            }\n        }\n\n        AsyncFunction(\"playNext\") Coroutine { track: TrackRecord ->\n            val context = appContext.reactContext\n            val mediaItem = track.toMediaItem(context)\n            withServiceAndPlayerOnMainThread { service, currentPlayer ->\n                val shuffleEnabled = service.shuffleManager.isEnabled\n\n                var existingIndex = -1\n                for (i in 0 until currentPlayer.mediaItemCount) {\n                    if (currentPlayer.getMediaItemAt(i).mediaId == track.id) {\n                        existingIndex = i\n                        break\n                    }\n                }\n\n                if (existingIndex != -1) {\n                    if (existingIndex == currentPlayer.currentMediaItemIndex) {\n                        return@withServiceAndPlayerOnMainThread\n                    }\n                    if (shuffleEnabled) {\n                        // Remove the existing instance then re-add right after the current item.\n                        // Using remove+add (rather than moveMediaItem) keeps the physical insertion\n                        // index deterministic: after removing existingIndex, currentMediaItemIndex\n                        // is automatically adjusted, so +1 always points to the correct next slot.\n                        currentPlayer.removeMediaItem(existingIndex)\n                        val insertPhysical = (currentPlayer.currentMediaItemIndex + 1).coerceAtMost(currentPlayer.mediaItemCount)\n                        currentPlayer.addMediaItem(insertPhysical, mediaItem)\n                        service.shuffleManager.repositionAsNext(insertPhysical)\n                    } else {\n                        val targetIndex = currentPlayer.currentMediaItemIndex + 1\n                        val safeTargetIndex = targetIndex.coerceAtMost(currentPlayer.mediaItemCount)\n                        currentPlayer.moveMediaItem(existingIndex, safeTargetIndex)\n                    }\n                } else {\n                    val targetIndex = currentPlayer.currentMediaItemIndex + 1\n                    val safeTargetIndex = targetIndex.coerceAtMost(currentPlayer.mediaItemCount)\n                    currentPlayer.addMediaItem(safeTargetIndex, mediaItem)\n                    if (shuffleEnabled) {\n                        service.shuffleManager.repositionAsNext(safeTargetIndex)\n                    }\n                }\n\n                if (currentPlayer.playbackState == Player.STATE_IDLE) {\n                    currentPlayer.prepare()\n                }\n            }\n        }\n\n        AsyncFunction(\"downloadTrack\") { track: TrackRecord ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            val downloadRequest = DownloadRequest.Builder(track.id, track.url.toUri())\n                .setData(json.encodeToString(track).toByteArray())\n                .build()\n\n            DownloadService.sendAddDownload(\n                context,\n                OrpheusDownloadService::class.java,\n                downloadRequest,\n                false\n            )\n        }\n\n        AsyncFunction(\"multiDownload\") { tracks: List<TrackRecord> ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            tracks.forEach { track ->\n                val downloadRequest = DownloadRequest.Builder(track.id, track.url.toUri())\n                    .setData(json.encodeToString(track).toByteArray())\n                    .build()\n                DownloadService.sendAddDownload(\n                    context,\n                    OrpheusDownloadService::class.java,\n                    downloadRequest,\n                    false\n                )\n            }\n            return@AsyncFunction\n        }\n\n        AsyncFunction(\"resumeDownload\") { id: String ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            DownloadService.sendSetStopReason(\n                context,\n                OrpheusDownloadService::class.java,\n                id,\n                Download.STOP_REASON_NONE,\n                false\n            )\n        }\n\n        AsyncFunction(\"retryDownload\") { track: TrackRecord ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            val downloadRequest = DownloadRequest.Builder(track.id, track.url.toUri())\n                .setData(json.encodeToString(track).toByteArray())\n                .build()\n            DownloadService.sendAddDownload(\n                context,\n                OrpheusDownloadService::class.java,\n                downloadRequest,\n                false\n            )\n        }\n\n        AsyncFunction(\"setDownloadMaxParallelTasks\") { maxParallelTasks: Int ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            DownloadUtil.setMaxParallelDownloads(context, maxParallelTasks)\n        }\n\n        AsyncFunction(\"removeDownload\") { id: String ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            DownloadService.sendRemoveDownload(\n                context,\n                OrpheusDownloadService::class.java,\n                id,\n                false\n            )\n            CoverDownloadManager.deleteCover(context, id)\n        }\n\n        AsyncFunction(\"removeDownloads\") { ids: List<String> ->\n            val context = appContext.reactContext ?: return@AsyncFunction\n            for (id in ids) {\n                DownloadService.sendRemoveDownload(\n                    context,\n                    OrpheusDownloadService::class.java,\n                    id,\n                    false\n                )\n                CoverDownloadManager.deleteCover(context, id)\n            }\n        }\n\n        AsyncFunction(\"removeAllDownloads\") {\n            val context = appContext.reactContext ?: return@AsyncFunction null\n            DownloadService.sendRemoveAllDownloads(\n                context,\n                OrpheusDownloadService::class.java,\n                false\n            )\n            CoverDownloadManager.deleteAllCovers(context)\n        }\n\n        AsyncFunction(\"getDownloads\") {\n            val context =\n                appContext.reactContext ?: return@AsyncFunction emptyList<Map<String, Any>>()\n            val downloadManager = DownloadUtil.getDownloadManager(context)\n            val downloadIndex = downloadManager.downloadIndex\n\n            val cursor = downloadIndex.getDownloads()\n            val result = ArrayList<Map<String, Any>>()\n\n            try {\n                while (cursor.moveToNext()) {\n                    val download = cursor.download\n                    result.add(getDownloadMap(download))\n                }\n            } finally {\n                cursor.close()\n            }\n            return@AsyncFunction result\n        }\n\n        AsyncFunction(\"getDownloadStatusByIds\") { ids: List<String> ->\n            val context =\n                appContext.reactContext ?: return@AsyncFunction emptyMap<String, Int>()\n            val downloadManager = DownloadUtil.getDownloadManager(context)\n            val downloadIndex = downloadManager.downloadIndex\n\n            val result = mutableMapOf<String, Int>()\n\n            for (id in ids) {\n                val download = downloadIndex.getDownload(id)\n                if (download != null) {\n                    result[id] = download.state\n                }\n            }\n            return@AsyncFunction result\n        }\n\n        AsyncFunction(\"clearUncompletedDownloadTasks\") {\n            val context = appContext.reactContext ?: return@AsyncFunction null\n            val downloadManager = DownloadUtil.getDownloadManager(context)\n            val downloadIndex = downloadManager.downloadIndex\n\n            val cursor = downloadIndex.getDownloads()\n            try {\n                while (cursor.moveToNext()) {\n                    val download = cursor.download\n                    if (download.state != Download.STATE_COMPLETED) {\n                        DownloadService.sendRemoveDownload(\n                            context,\n                            OrpheusDownloadService::class.java,\n                            download.request.id,\n                            false\n                        )\n                    }\n                }\n            } finally {\n                cursor.close()\n            }\n        }\n\n        AsyncFunction(\"downloadMissingCovers\") {\n            val context =\n                appContext.reactContext ?: return@AsyncFunction 0\n            val downloadManager = DownloadUtil.getDownloadManager(context)\n            val downloadIndex = downloadManager.downloadIndex\n            val cursor = downloadIndex.getDownloads()\n\n            // 先收集所有待下载项\n            data class PendingCover(val trackId: String, val artworkUrl: String)\n\n            val pendingList = mutableListOf<PendingCover>()\n\n            try {\n                while (cursor.moveToNext()) {\n                    val download = cursor.download\n                    if (download.state != Download.STATE_COMPLETED) continue\n                    if (download.request.data.isEmpty()) continue\n\n                    val trackId = download.request.id\n                    if (CoverDownloadManager.getCoverFile(context, trackId) != null) continue\n\n                    try {\n                        val track = json.decodeFromString<TrackRecord>(\n                            String(download.request.data)\n                        )\n                        val artwork = track.artwork\n                        if (!artwork.isNullOrEmpty()) {\n                            pendingList.add(PendingCover(trackId, artwork))\n                        }\n                    } catch (e: Exception) {\n                        Log.e(\"Orpheus\", \"Failed to parse track for cover: ${e.message}\")\n                    }\n                }\n            } finally {\n                cursor.close()\n            }\n\n            val total = pendingList.size\n            if (total == 0) return@AsyncFunction 0\n\n            // 在 IO 线程顺序下载，逐个发送进度事件\n            ioScope.launch {\n                pendingList.forEachIndexed { index, item ->\n                    val status = try {\n                        CoverDownloadManager.downloadCover(context, item.trackId, item.artworkUrl)\n                        \"success\"\n                    } catch (e: Exception) {\n                        Log.e(\"Orpheus\", \"Cover download failed for ${item.trackId}: ${e.message}\")\n                        \"failed\"\n                    }\n                    sendEvent(\n                        \"onCoverDownloadProgress\", mapOf(\n                            \"current\" to (index + 1),\n                            \"total\" to total,\n                            \"trackId\" to item.trackId,\n                            \"status\" to status\n                        )\n                    )\n                }\n            }\n\n            return@AsyncFunction total\n        }\n\n        AsyncFunction(\"exportDownloads\") { ids: List<String>, destinationUri: String, filenamePattern: String?, embedLyrics: Boolean, convertToLrc: Boolean, cropCoverArt: Boolean ->\n            val context = appContext.reactContext ?: run {\n                sendEvent(\"onExportProgress\", mapOf(\n                    \"status\" to \"error\",\n                    \"message\" to \"React context is null\"\n                ))\n                return@AsyncFunction\n            }\n            runExportDownloads(\n                ids = ids,\n                destinationUri = destinationUri,\n                context = context,\n                options = ExportOptions(\n                    filenamePattern = filenamePattern,\n                    embedLyrics = embedLyrics,\n                    convertToLrc = convertToLrc,\n                    cropCoverArt = cropCoverArt,\n                ),\n                json = json,\n                ioScope = ioScope,\n                sendEvent = ::sendEvent,\n            )\n        }\n\n        Function(\"getDownloadedCoverUri\") { trackId: String ->\n            val context = appContext.reactContext ?: return@Function null\n            val file = CoverDownloadManager.getCoverFile(context, trackId)\n            file?.let { \"file://${it.absolutePath}\" }\n        }\n\n        AsyncFunction(\"getUncompletedDownloadTasks\") {\n            val context =\n                appContext.reactContext ?: return@AsyncFunction emptyList<Map<String, Any>>()\n            val downloadManager = DownloadUtil.getDownloadManager(context)\n            val downloadIndex = downloadManager.downloadIndex\n\n            val cursor = downloadIndex.getDownloads()\n            val result = ArrayList<Map<String, Any>>()\n\n            try {\n                while (cursor.moveToNext()) {\n                    val download = cursor.download\n                    if (download.state != Download.STATE_COMPLETED) {\n                        result.add(getDownloadMap(download))\n                    }\n                }\n            } finally {\n                cursor.close()\n            }\n            return@AsyncFunction result\n        }\n\n        AsyncFunction(\"checkOverlayPermission\") {\n            val context = appContext.reactContext ?: return@AsyncFunction false\n            android.provider.Settings.canDrawOverlays(context)\n        }\n\n        AsyncFunction(\"requestOverlayPermission\") Coroutine { ->\n            val context = appContext.reactContext ?: return@Coroutine false\n            withContext(Dispatchers.Main.immediate) {\n                if (!android.provider.Settings.canDrawOverlays(context)) {\n                    val intent = android.content.Intent(\n                        android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION,\n                        \"package:${context.packageName}\".toUri()\n                    )\n                    intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)\n                    context.startActivity(intent)\n                }\n            }\n        }\n\n        AsyncFunction(\"showDesktopLyrics\") Coroutine { ->\n            withServiceOnMainThread { it?.floatingLyricsManager?.show() }\n        }\n\n        AsyncFunction(\"hideDesktopLyrics\") Coroutine { ->\n            withServiceOnMainThread { it?.floatingLyricsManager?.hide() }\n        }\n\n        AsyncFunction(\"setLyricsInternal\") Coroutine { lyricsJson: String, consumerIds: List<String> ->\n            submitLyricsInternal(lyricsJson, resolveLyricsConsumers(consumerIds))\n        }\n\n        AsyncFunction(\"clearOverlays\") Coroutine { ->\n            // 无歌词时临时隐藏 overlay，但不修改 GeneralStorage（用户偏好保持 true）\n            // 当再次收到歌词时，桌面歌词会按用户偏好重新 show()\n            withServiceOnMainThread { service ->\n                service?.lyricsManager?.clearConsumers(LyricsConsumer.all(), softHideDesktop = true)\n            }\n        }\n\n        AsyncFunction(\"setPlaybackSpeed\") Coroutine { speed: Float ->\n            withPlayerOnMainThread { it.setPlaybackSpeed(speed) }\n        }\n\n        AsyncFunction(\"selectDirectory\") Coroutine { ->\n            val context = appContext.reactContext ?: return@Coroutine null\n            val uriString = directoryPickerLauncher.launch(\"\")\n            if (uriString != null) {\n                try {\n                    val treeUri = uriString.toUri()\n                    context.contentResolver.takePersistableUriPermission(\n                        treeUri,\n                        Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION\n                    )\n                } catch (e: Exception) {\n                    Log.e(\"Orpheus\", \"Failed to take persistable URI permission: ${e.message}\")\n                }\n            }\n            uriString\n        }\n\n        AsyncFunction(\"isDirectoryPickerAvailable\") {\n            val context = appContext.reactContext ?: return@AsyncFunction false\n            Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null\n        }\n\n        AsyncFunction(\"getPlaybackSpeed\") Coroutine { ->\n            withPlayerOnMainThread { it.playbackParameters.speed }\n        }\n\n        Function(\"getLruCachedUris\") { uris: List<String> ->\n            try {\n                uris.filter { uri ->\n                    expo.modules.orpheus.manager.CachedUriManager.isFullyCached(uri)\n                }\n            } catch (e: Exception) {\n                emptyList<String>()\n            }\n        }\n\n        Function(\"updateSpectrumData\") { destination: Float32Array ->\n            val size = destination.length\n            if (tempBuffer == null || tempBuffer!!.size != size) {\n                tempBuffer = FloatArray(size)\n            }\n            val buffer = tempBuffer!!\n            spectrumManager.getSpectrumData(buffer)\n\n            val byteBuffer = destination.toDirectBuffer()\n            byteBuffer.order(java.nio.ByteOrder.nativeOrder())\n            byteBuffer.asFloatBuffer().put(buffer)\n        }\n    }\n\n    private fun getDownloadMap(download: Download): Map<String, Any> {\n        val trackJson = if (download.request.data.isNotEmpty()) {\n            String(download.request.data)\n        } else null\n\n        val map = mutableMapOf<String, Any>(\n            \"id\" to download.request.id,\n            \"state\" to download.state,\n            \"percentDownloaded\" to download.percentDownloaded,\n            \"bytesDownloaded\" to download.bytesDownloaded,\n            \"contentLength\" to download.contentLength\n        )\n\n        if (trackJson != null) {\n            try {\n                val track = json.decodeFromString<TrackRecord>(trackJson)\n                map[\"track\"] = track\n            } catch (e: Exception) {\n                e.printStackTrace()\n            }\n        }\n        return map\n    }\n\n    private val downloadListener = object : DownloadManager.Listener {\n        override fun onDownloadChanged(\n            downloadManager: DownloadManager,\n            download: Download,\n            finalException: Exception?\n        ) {\n            sendEvent(\"onDownloadUpdated\", getDownloadMap(download))\n            updateDownloadProgressRunnerState()\n\n            // 歌曲下载完成后，异步下载封面\n            if (download.state == Download.STATE_COMPLETED && download.request.data.isNotEmpty()) {\n                // 封面下载只需能访问文件系统的 Context，使用 OnCreate 时缓存的\n                // applicationContext，避免 reactContext 为 null 时封面静默跳过。\n                val context = cachedAppContext ?: appContext.reactContext ?: return\n                try {\n                    val track = json.decodeFromString<TrackRecord>(\n                        String(download.request.data)\n                    )\n                    val artwork = track.artwork\n                    if (!artwork.isNullOrEmpty()) {\n                        ioScope.launch {\n                            CoverDownloadManager.downloadCover(context, track.id, artwork)\n                        }\n                    }\n                } catch (e: Exception) {\n                    Log.e(\"Orpheus\", \"Failed to trigger cover download: ${e.message}\")\n                }\n            }\n        }\n    }\n\n    private val downloadProgressRunnable = object : Runnable {\n        override fun run() {\n            val manager = downloadManager ?: return\n            if (manager.currentDownloads.isNotEmpty()) {\n                for (download in manager.currentDownloads) {\n                    if (download.state == Download.STATE_DOWNLOADING) {\n                        sendEvent(\"onDownloadUpdated\", getDownloadMap(download))\n                    }\n                }\n                mainHandler.postDelayed(this, 500)\n            }\n        }\n    }\n\n    private fun updateDownloadProgressRunnerState() {\n        mainHandler.removeCallbacks(downloadProgressRunnable)\n        val manager = downloadManager ?: return\n\n        val hasActiveDownloads =\n            manager.currentDownloads.any { it.state == Download.STATE_DOWNLOADING }\n\n        if (hasActiveDownloads) {\n            mainHandler.post(downloadProgressRunnable)\n        }\n    }\n\n    private val progressSendEventRunnable = object : Runnable {\n        override fun run() {\n            val p = player ?: return\n\n            if (p.isPlaying) {\n                val currentMs = p.currentPosition\n                val durationMs = p.duration\n\n                sendEvent(\n                    \"onPositionUpdate\", mapOf(\n                        \"position\" to currentMs / 1000.0,\n                        \"duration\" to if (durationMs == C.TIME_UNSET) 0.0 else durationMs / 1000.0,\n                        \"buffered\" to p.bufferedPosition / 1000.0\n                    )\n                )\n            }\n\n            mainHandler.postDelayed(this, 200)\n        }\n    }\n\n    private val progressSaveRunnable = object : Runnable {\n        override fun run() {\n            saveCurrentPosition()\n            mainHandler.postDelayed(this, 5000)\n        }\n    }\n\n    private fun updateProgressRunnerState() {\n        val p = player\n        // 如果正在播放且状态是 READY，则开始轮询\n        if (p != null && p.isPlaying && p.playbackState == Player.STATE_READY) {\n            mainHandler.removeCallbacks(progressSendEventRunnable)\n            mainHandler.removeCallbacks(progressSaveRunnable)\n            mainHandler.post(progressSaveRunnable)\n            mainHandler.post(progressSendEventRunnable)\n        } else {\n            mainHandler.removeCallbacks(progressSendEventRunnable)\n            mainHandler.removeCallbacks(progressSaveRunnable)\n        }\n    }\n\n    private fun mediaItemToTrackRecord(item: MediaItem): TrackRecord {\n        val extras = item.mediaMetadata.extras\n        val trackJson = extras?.getString(\"track_json\")\n\n        if (trackJson != null) {\n            try {\n                return json.decodeFromString(trackJson)\n            } catch (e: Exception) {\n                e.printStackTrace()\n            }\n        }\n\n        val track = TrackRecord()\n        track.id = item.mediaId\n        track.url = item.localConfiguration?.uri?.toString() ?: \"\"\n        track.title = item.mediaMetadata.title?.toString()\n        track.artist = item.mediaMetadata.artist?.toString()\n        track.artwork = item.mediaMetadata.artworkUri?.toString()\n\n        return track\n    }\n\n    private fun saveCurrentPosition() {\n        val p = player ?: return\n        if (p.playbackState != Player.STATE_IDLE) {\n            GeneralStorage.savePosition(\n                p.currentMediaItemIndex,\n                p.currentPosition\n            )\n        }\n    }\n\n    private fun ensurePlayer() {\n        val service = OrpheusMusicService.instance\n            ?: throw ControllerNotInitializedException()\n        val servicePlayer = service.ensurePlayer()\n        if (this.player !== servicePlayer) {\n            this.player?.removeListener(playerListener)\n            this.player = servicePlayer\n            servicePlayer.addListener(playerListener)\n        }\n    }\n\n    private fun prepareIfIdle(player: Player) {\n        if (player.playbackState == Player.STATE_IDLE && player.mediaItemCount > 0) {\n            player.prepare()\n        }\n    }\n\n    private suspend fun <T> withPlayerOnMainThread(block: (Player) -> T): T =\n        withContext(Dispatchers.Main.immediate) {\n            ensurePlayer()\n            val currentPlayer = player ?: throw ControllerNotInitializedException()\n            block(currentPlayer)\n        }\n\n    private suspend fun <T> withServiceAndPlayerOnMainThread(block: (OrpheusMusicService, Player) -> T): T =\n        withContext(Dispatchers.Main.immediate) {\n            ensurePlayer()\n            val service = OrpheusMusicService.instance ?: throw ControllerNotInitializedException()\n            val currentPlayer = player ?: throw ControllerNotInitializedException()\n            block(service, currentPlayer)\n        }\n\n    private suspend fun <T> withServiceOnMainThread(block: (OrpheusMusicService?) -> T): T =\n        withContext(Dispatchers.Main.immediate) {\n            block(OrpheusMusicService.instance)\n        }\n\n    private suspend fun submitLyricsInternal(\n        lyricsJson: String,\n        consumers: Set<LyricsConsumer>,\n    ) {\n        try {\n            val data = json.decodeFromString<expo.modules.orpheus.model.LyricsData>(lyricsJson)\n            withServiceOnMainThread { service ->\n                service?.lyricsManager?.submitLyrics(data, consumers)\n            }\n        } catch (e: CancellationException) {\n            throw e\n        } catch (e: Exception) {\n            Log.e(\n                \"OrpheusLyrics\",\n                \"[Module] submitLyrics failed consumers=${consumers.joinToString()} reason=${e.message}\",\n                e,\n            )\n        }\n    }\n\n    private fun resolveLyricsConsumers(consumerIds: List<String>): Set<LyricsConsumer> {\n        if (consumerIds.isEmpty()) return LyricsConsumer.all()\n\n        val resolved = consumerIds.mapNotNull { LyricsConsumer.fromIdentifier(it) }.toSet()\n        if (resolved.isEmpty()) {\n            Log.w(\"OrpheusLyrics\", \"[Module] No valid consumers resolved from $consumerIds\")\n        }\n        return resolved\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/OrpheusConfig.kt",
    "content": "package expo.modules.orpheus\n\nobject OrpheusConfig {\n    var bilibiliCookie: String? = null\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliApi.kt",
    "content": "package expo.modules.orpheus.bilibili\n\nimport retrofit2.Call\nimport retrofit2.http.GET\nimport retrofit2.http.Header\nimport retrofit2.http.Query\nimport retrofit2.http.QueryMap\n\ninterface BilibiliApi {\n    @GET(\"/x/web-interface/nav\")\n    fun getNavInfo(): Call<BilibiliNavResponse>\n\n    @GET(\"/x/player/wbi/playurl\")\n    fun getPlayUrl(\n        @Header(\"Cookie\") cookie: String? = null,\n        @QueryMap params: Map<String, String>\n    ): Call<BilibiliApiResponse<BilibiliAudioStreamResponse>>\n\n    @GET(\"/x/player/pagelist\")\n    fun getPageList(\n        @Header(\"Cookie\") cookie: String? = null,\n        @Query(\"bvid\") bvid: String\n    ): Call<BilibiliApiResponse<List<BilibiliPageListResponse>>>\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliModels.kt",
    "content": "package expo.modules.orpheus.bilibili\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class BilibiliApiResponse<TData>(\n    @SerialName(\"code\") val code: Int,\n    @SerialName(\"message\") val message: String? = null,\n    @SerialName(\"data\") val data: TData? = null\n)\n\n@Serializable\ndata class BilibiliAudioStreamResponse(\n    @SerialName(\"durl\") val durl: List<DurlItem>? = null,\n\n    @SerialName(\"dash\") val dash: DashData? = null,\n\n    @SerialName(\"volume\") val volume: VolumeData? = null\n)\n\n@Serializable\ndata class DurlItem(\n    @SerialName(\"order\") val order: Int,\n    @SerialName(\"url\") val url: String,\n    @SerialName(\"backup_url\") val backupUrl: List<String>?\n)\n\n@Serializable\ndata class DashData(\n    @SerialName(\"audio\") val audio: List<DashAudioItem>?,\n    @SerialName(\"dolby\") val dolby: DolbyData?,\n    @SerialName(\"flac\") val flac: FlacData?\n)\n\n@Serializable\ndata class DashAudioItem(\n    @SerialName(\"id\") val id: Int,\n    @SerialName(\"base_url\") val baseUrl: String,\n    @SerialName(\"backup_url\") val backupUrl: List<String>?\n)\n\n@Serializable\ndata class DolbyData(\n    @SerialName(\"type\") val type: Int,\n    @SerialName(\"audio\") val audio: List<DashAudioItem>?\n)\n\n@Serializable\ndata class FlacData(\n    @SerialName(\"display\") val display: Boolean,\n    @SerialName(\"audio\") val audio: DashAudioItem?\n)\n\n@Serializable\ndata class VolumeData(\n    @SerialName(\"measured_i\") val measuredI: Double,\n    @SerialName(\"target_i\") val targetI: Double\n)\n\n@Serializable\ndata class BilibiliNavResponse(\n    @SerialName(\"code\") val code: Int,\n    @SerialName(\"message\") val message: String? = null,\n    @SerialName(\"data\") val data: NavData?\n)\n\n@Serializable\ndata class NavData(\n    @SerialName(\"wbi_img\") val wbiImg: WbiImgData?\n)\n\n@Serializable\ndata class WbiImgData(\n    @SerialName(\"img_url\") val imgUrl: String,\n    @SerialName(\"sub_url\") val subUrl: String\n)\n\n@Serializable\ndata class BilibiliPageListResponse(\n    @SerialName(\"cid\") val cid: Long\n)"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliRepository.kt",
    "content": "package expo.modules.orpheus.bilibili\n\nimport android.util.Log\n\nimport java.io.IOException\nimport java.text.SimpleDateFormat\nimport java.util.Date\nimport java.util.Locale\n\nobject BilibiliRepository {\n    val TAG = \"Orpheus/BilibiliRepo\"\n\n    private val api: BilibiliApi by lazy {\n        NetworkModule.retrofit.create(BilibiliApi::class.java)\n    }\n\n    private var cachedImgKey: String? = null\n    private var cachedSubKey: String? = null\n    private var cachedDateStr: String? = null\n\n    private fun getTodayDateStr(): String {\n        val sdf = SimpleDateFormat(\"yyyyMMdd\", Locale.getDefault())\n        return sdf.format(Date())\n    }\n\n    @Synchronized\n    private fun getWbiKeys(): Pair<String, String> {\n        val today = getTodayDateStr()\n\n        if (cachedImgKey != null && cachedSubKey != null && today == cachedDateStr) {\n            return cachedImgKey!! to cachedSubKey!!\n        }\n\n        val response = api.getNavInfo().execute()\n        val wbiData = response.body()?.data?.wbiImg\n\n        if (!response.isSuccessful || wbiData == null) {\n            val msg = response.body()?.message ?: \"Unknown Error\"\n            throw IOException(\"Bilibili API Error: code=${response.code()} msg=$msg\")\n        }\n\n        val imgKey = WbiUtil.extractKey(wbiData.imgUrl)\n        val subKey = WbiUtil.extractKey(wbiData.subUrl)\n\n        cachedImgKey = imgKey\n        cachedSubKey = subKey\n        cachedDateStr = today\n\n        return imgKey to subKey\n    }\n\n    /**\n     * 解析音频 URL\n     */\n    fun resolveAudioUrl(\n        bvid: String,\n        cid: Long?,\n        audioQuality: Int,\n        enableDolby: Boolean,\n        enableHiRes: Boolean,\n        cookie: String?\n    ): Pair<String, VolumeData?> {\n        var cidInternal = cid\n        val (imgKey, subKey) = getWbiKeys()\n        if (cidInternal === null) {\n            cidInternal = getFirstCid(bvid, cookie)\n        }\n\n        Log.e(TAG, \"resolve url: bvid: $bvid, cid: $cid, enableDolby: \")\n\n        val rawParams = mapOf(\n            \"bvid\" to bvid,\n            \"cid\" to cidInternal,\n            \"fnval\" to 4048,\n            \"fnver\" to 0,\n            \"fourk\" to 1,\n            \"qlt\" to audioQuality,\n            \"voice_balance\" to 1\n        )\n\n        val signedParams = WbiUtil.sign(rawParams, imgKey, subKey)\n\n        val call = api.getPlayUrl(cookie, signedParams)\n        val response = call.execute()\n\n        if (!response.isSuccessful) {\n            throw IOException(\"Bilibili API Http Error: ${response.code()}\")\n        }\n\n        val apiResponse = response.body()\n        if (apiResponse?.code != 0) {\n            val msg = apiResponse?.message ?: \"Unknown Error\"\n            throw IOException(\"Bilibili API Error: code=${apiResponse?.code} msg=$msg\")\n        }\n\n        if (apiResponse.data == null) {\n            throw IOException(\"Bilibili API Logic Error: code=0 msg=${apiResponse.message} but data is missing\")\n        }\n\n        val data = apiResponse.data\n        val dash = data.dash\n        val durl = data.durl\n        val volume = data.volume\n\n        if (dash == null) {\n            if (durl.isNullOrEmpty()) {\n                throw IOException(\"AudioStreamError: 请求到的流数据不包含 dash 或 durl 任一字段\")\n            }\n            return durl[0].url to volume\n        }\n\n        if (enableDolby && dash.dolby?.audio?.isNotEmpty() == true) {\n            Log.d(TAG, \"select dolby source\")\n            return dash.dolby.audio[0].baseUrl to volume\n        }\n\n        if (enableHiRes && dash.flac?.audio != null) {\n            Log.d(TAG, \"select hires source\")\n            return dash.flac.audio.baseUrl to volume\n        }\n\n        if (dash.audio.isNullOrEmpty()) {\n            throw IOException(\"AudioStreamError: 未找到有效的音频流数据\")\n        }\n\n        val targetAudio = dash.audio.find { it.id == audioQuality }\n\n        if (targetAudio != null) {\n            return targetAudio.baseUrl to volume\n        } else {\n            val highestQualityAudio = dash.audio[0]\n            return highestQualityAudio.baseUrl to volume\n        }\n    }\n\n    fun getFirstCid(bvid: String, cookie: String?): Long {\n        val call = api.getPageList(cookie = cookie, bvid = bvid)\n        val response = call.execute()\n\n        if (!response.isSuccessful) {\n            throw IOException(\"Bilibili API Http Error: ${response.code()}\")\n        }\n\n        val apiResponse = response.body()\n        if (apiResponse?.code != 0) {\n            val msg = apiResponse?.message ?: \"Unknown Error\"\n            throw IOException(\"Bilibili API Error: code=${apiResponse?.code} msg=$msg\")\n        }\n\n        if (apiResponse.data == null) {\n            throw IOException(\"Bilibili API Logic Error: code=0 msg=${apiResponse.message} but data is missing\")\n        }\n\n        return apiResponse.data[0].cid\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/bilibili/NetworkModule.kt",
    "content": "package expo.modules.orpheus.bilibili\n\nimport com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory\nimport kotlinx.serialization.json.Json\nimport okhttp3.Interceptor\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Response\nimport retrofit2.Retrofit\nimport java.util.concurrent.TimeUnit\n\nobject NetworkModule {\n    private const val BASE_URL = \"https://api.bilibili.com\"\n\n    private val json = Json { ignoreUnknownKeys = true }\n\n    private val client: OkHttpClient by lazy {\n        val builder = OkHttpClient.Builder()\n            .connectTimeout(10, TimeUnit.SECONDS)\n            .readTimeout(10, TimeUnit.SECONDS)\n            .writeTimeout(10, TimeUnit.SECONDS)\n            .addInterceptor(BilibiliHeaderInterceptor())\n\n        builder.build()\n    }\n\n    val retrofit: Retrofit by lazy {\n        val contentType = \"application/json\".toMediaType()\n        Retrofit.Builder()\n            .baseUrl(BASE_URL)\n            .client(client)\n            .addConverterFactory(json.asConverterFactory(contentType)) // 自动 JSON 解析\n            .build()\n    }\n\n    private class BilibiliHeaderInterceptor : Interceptor {\n        override fun intercept(chain: Interceptor.Chain): Response {\n            val originalRequest = chain.request()\n\n            val newRequest = originalRequest.newBuilder()\n                .header(\n                    \"User-Agent\",\n                    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n                )\n                .header(\"Referer\", \"https://www.bilibili.com/\")\n                .build()\n\n            return chain.proceed(newRequest)\n        }\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/bilibili/WbiUtil.kt",
    "content": "package expo.modules.orpheus.bilibili\n\nimport java.net.URLEncoder\nimport java.security.MessageDigest\nimport java.util.TreeMap\n\nobject WbiUtil {\n    private val mixinKeyEncTab = intArrayOf(\n        46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,\n        33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,\n        61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,\n        36, 20, 34, 44, 52\n    )\n\n    private fun String.toMD5(): String {\n        val md = MessageDigest.getInstance(\"MD5\")\n        val digest = md.digest(this.toByteArray())\n        return digest.joinToString(\"\") { \"%02x\".format(it) }\n    }\n\n    private fun Any?.encodeURIComponent(): String {\n        if (this == null) return \"\"\n        return URLEncoder.encode(this.toString(), \"UTF-8\")\n            .replace(\"+\", \"%20\")\n            .replace(\"*\", \"%2A\")\n            .replace(\"%7E\", \"~\")\n    }\n\n    /**\n     * 计算 WBI 混淆键\n     */\n    private fun getMixinKey(orig: String): String {\n        return buildString {\n            repeat(32) {\n                if (it < mixinKeyEncTab.size && mixinKeyEncTab[it] < orig.length) {\n                    append(orig[mixinKeyEncTab[it]])\n                }\n            }\n        }\n    }\n\n    /**\n     * 核心签名方法\n     * @param params 原始参数 Map\n     * @param imgKey 来自 /nav 接口\n     * @param subKey 来自 /nav 接口\n     * @return 包含 w_rid 和 wts 的完整参数 Map\n     */\n    fun sign(params: Map<String, Any?>, imgKey: String, subKey: String): Map<String, String> {\n        val mixinKey = getMixinKey(imgKey + subKey)\n        val currTime = System.currentTimeMillis() / 1000\n\n        val sortedParams = TreeMap<String, Any?>()\n        params.forEach { (k, v) -> if (v != null) sortedParams[k] = v }\n        sortedParams[\"wts\"] = currTime\n\n        val queryStr = sortedParams.entries.joinToString(\"&\") { (k, v) ->\n            \"${k.encodeURIComponent()}=${v.encodeURIComponent()}\"\n        }\n\n        val w_rid = (queryStr + mixinKey).toMD5()\n\n        val finalMap = HashMap<String, String>()\n        sortedParams.forEach { (k, v) -> finalMap[k] = v.toString() }\n        finalMap[\"w_rid\"] = w_rid\n\n        return finalMap\n    }\n\n    fun extractKey(url: String): String {\n        return url.substringAfterLast(\"/\").substringBeforeLast(\".\")\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/exception/exceptions.kt",
    "content": "package expo.modules.orpheus.exception\n\nimport expo.modules.kotlin.exception.CodedException\n\nclass ControllerNotInitializedException : CodedException(\n    \"ERR_CONTROLLER_NOT_INIT\",\n    \"The MediaController is not initialized. Connect to service first.\",\n    null\n)"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/CachedUriManager.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport androidx.media3.common.C\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.datasource.cache.Cache\nimport androidx.media3.datasource.cache.CacheSpan\nimport androidx.media3.datasource.cache.ContentMetadata\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport java.util.Collections\nimport java.util.concurrent.ConcurrentHashMap\n\n@UnstableApi\nobject CachedUriManager : Cache.Listener {\n    private val fullyCachedUris = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())\n    private var isInitialized = false\n    private val scope = CoroutineScope(Dispatchers.IO)\n\n    @Synchronized\n    fun initialize(context: Context) {\n        if (isInitialized) return\n        \n        val lruCache = DownloadCache.getLruCache(context)\n        lruCache.addListener(CachedUriManager.javaClass.simpleName, this)\n        \n        scope.launch {\n            val keys = lruCache.keys\n            for (key in keys) {\n                checkIfFullyCached(lruCache, key)\n            }\n        }\n        \n        isInitialized = true\n    }\n\n    fun isFullyCached(uri: String): Boolean {\n        return fullyCachedUris.contains(uri)\n    }\n\n    private fun checkIfFullyCached(cache: Cache, key: String) {\n        val metadata = cache.getContentMetadata(key)\n        val expectedLength = ContentMetadata.getContentLength(metadata)\n\n        if (expectedLength != C.LENGTH_UNSET.toLong()) {\n            val spans = cache.getCachedSpans(key)\n            var totalCachedBytes = 0L\n            for (span in spans) {\n                totalCachedBytes += span.length\n            }\n\n            if (totalCachedBytes >= expectedLength) {\n                fullyCachedUris.add(key)\n            } else {\n                fullyCachedUris.remove(key)\n            }\n        }\n    }\n\n    override fun onSpanAdded(cache: Cache, span: CacheSpan) {\n        val key = span.key ?: return\n        scope.launch {\n            checkIfFullyCached(cache, key)\n        }\n    }\n\n    override fun onSpanRemoved(cache: Cache, span: CacheSpan) {\n        val key = span.key ?: return\n        fullyCachedUris.remove(key)\n    }\n\n    override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/CoverDownloadManager.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport android.util.Log\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.sync.Mutex\nimport kotlinx.coroutines.sync.withLock\nimport kotlinx.coroutines.withContext\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport java.io.File\nimport java.io.FileOutputStream\nimport java.util.concurrent.ConcurrentHashMap\n\nobject CoverDownloadManager {\n    private const val TAG = \"CoverDownloadManager\"\n    private const val COVERS_DIR = \"downloaded_covers\"\n\n    private val okHttpClient = OkHttpClient()\n\n    @Volatile\n    private var coverCache: ConcurrentHashMap<String, File>? = null\n    private val downloadLocks = ConcurrentHashMap<String, Mutex>()\n\n    fun getCoversDir(context: Context): File {\n        return File(context.filesDir, COVERS_DIR)\n    }\n\n    private fun initCacheIfNeeded(context: Context) {\n        if (coverCache != null) return\n\n        synchronized(this) {\n            if (coverCache != null) return\n            val newCache = ConcurrentHashMap<String, File>()\n            val dir = getCoversDir(context)\n            if (dir.exists()) {\n                dir.listFiles()?.forEach { file ->\n                    newCache[file.nameWithoutExtension] = file\n                }\n            }\n            coverCache = newCache\n        }\n    }\n\n    /**\n     * 获取已下载的封面路径，如果不存在则返回 null。\n     * 支持任意扩展名的模糊匹配。\n     */\n    fun getCoverFile(context: Context, trackId: String): File? {\n        initCacheIfNeeded(context)\n        val safeId = sanitizeTrackId(trackId)\n        return coverCache?.get(safeId)\n    }\n\n    /**\n     * 将 trackId 中的文件系统非法字符替换为 _，确保可作为文件名。\n     */\n    private fun sanitizeTrackId(trackId: String): String {\n        return trackId.replace(Regex(\"[/\\\\\\\\:*?\\\"<>|]\"), \"_\")\n    }\n\n    /**\n     * 从 URL 提取文件扩展名，默认 jpg。\n     * 处理 Bilibili 风格的 URL（如 xxx.jpg@100w_100h.webp）。\n     */\n    private fun extractExtension(url: String): String {\n        return try {\n            // 先去掉 query 和 fragment\n            val cleanUrl = url.split(\"?\")[0].split(\"#\")[0]\n            // 再去掉 @ 后缀（如 @100w_100h.webp）\n            val pathPart = cleanUrl.split(\"@\")[0]\n            val lastDot = pathPart.lastIndexOf('.')\n            if (lastDot != -1) {\n                pathPart.substring(lastDot + 1).lowercase()\n            } else \"jpg\"\n        } catch (_: Exception) {\n            \"jpg\"\n        }\n    }\n\n    /**\n     * 下载封面到本地。如果已存在则跳过。\n     * 使用 Glide 下载，禁用 Glide 磁盘缓存以避免双重缓存。\n     */\n    suspend fun downloadCover(context: Context, trackId: String, artworkUrl: String) {\n        val safeId = sanitizeTrackId(trackId)\n        val mutex = downloadLocks.getOrPut(safeId) { Mutex() }\n\n        mutex.withLock {\n            withContext(Dispatchers.IO) {\n                // 如果已存在，跳过\n                if (getCoverFile(context, trackId) != null) {\n                    Log.d(TAG, \"Cover already exists for $trackId, skipping\")\n                    return@withContext\n                }\n\n                val ext = extractExtension(artworkUrl)\n                val dir = getCoversDir(context)\n                if (!dir.exists()) dir.mkdirs()\n                val targetFile = File(dir, \"$safeId.$ext\")\n                val tempFile = File(dir, \"$safeId.tmp\")\n\n                try {\n                    val safeUrl = artworkUrl.replace(\"http://\", \"https://\")\n\n                    val request = Request.Builder()\n                        .url(safeUrl)\n                        .header(\n                            \"User-Agent\",\n                            \"Mozilla/5.0 (Android 14; Mobile; rv:109.0) Gecko/109.0 Firefox/112.0\"\n                        )\n                        .build()\n\n                    okHttpClient.newCall(request).execute().use { response ->\n                        if (!response.isSuccessful) {\n                            throw Exception(\"HTTP error code: ${response.code}\")\n                        }\n                        response.body?.byteStream()?.use { input ->\n                            FileOutputStream(tempFile).use { output ->\n                                input.copyTo(output)\n                            }\n                        } ?: throw Exception(\"Empty response body\")\n                    }\n\n                    if (tempFile.renameTo(targetFile)) {\n                        Log.d(\n                            TAG,\n                            \"Downloaded cover for $trackId ($artworkUrl) -> ${targetFile.absolutePath}\"\n                        )\n                        initCacheIfNeeded(context)\n                        coverCache?.put(safeId, targetFile)\n                    } else {\n                        throw Exception(\"Failed to rename temp file to target file\")\n                    }\n                } catch (e: Exception) {\n                    Log.e(\n                        TAG,\n                        \"Failed to download cover for $trackId, url=$artworkUrl: ${e.message}\"\n                    )\n                    // 清理可能的部分文件\n                    tempFile.delete()\n                    targetFile.delete() // Also try to delete target file just in case\n                }\n            }\n        }\n    }\n\n    /**\n     * 删除指定歌曲的封面（支持任意扩展名）。\n     */\n    fun deleteCover(context: Context, trackId: String) {\n        val file = getCoverFile(context, trackId)\n        if (file != null && file.delete()) {\n            val safeId = sanitizeTrackId(trackId)\n            coverCache?.remove(safeId)\n            Log.d(TAG, \"Deleted cover for $trackId\")\n        }\n    }\n\n    /**\n     * 删除所有封面。\n     */\n    fun deleteAllCovers(context: Context) {\n        val dir = getCoversDir(context)\n        if (dir.exists()) {\n            dir.deleteRecursively()\n            coverCache?.clear()\n            Log.d(TAG, \"Deleted all covers\")\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/DownloadCache.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.database.StandaloneDatabaseProvider\nimport androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor\nimport androidx.media3.datasource.cache.NoOpCacheEvictor\nimport androidx.media3.datasource.cache.SimpleCache\nimport java.io.File\n\n@UnstableApi\nobject DownloadCache {\n    private var stableCache: SimpleCache? = null\n    private var lruCache: SimpleCache? = null\n\n    @Synchronized\n    fun getStableCache(context: Context): SimpleCache {\n        if (stableCache == null) {\n            val cacheDir = File(context.filesDir, \"media_download\")\n            val evictor = NoOpCacheEvictor()\n            val databaseProvider = StandaloneDatabaseProvider(context)\n            stableCache = SimpleCache(cacheDir, evictor, databaseProvider)\n        }\n        return stableCache!!\n    }\n\n    @Synchronized\n    fun getLruCache(context: Context): SimpleCache {\n        if (lruCache == null) {\n            val cacheDir = File(context.cacheDir, \"media_cache_lru\")\n            val evictor = LeastRecentlyUsedCacheEvictor(256 * 1024 * 1024)\n            val databaseProvider = StandaloneDatabaseProvider(context)\n            lruCache = SimpleCache(cacheDir, evictor, databaseProvider)\n        }\n        return lruCache!!\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/FloatingLyricsManager.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport android.graphics.Color\nimport android.graphics.PixelFormat\nimport android.graphics.drawable.GradientDrawable\nimport android.os.Build\nimport android.os.Handler\nimport android.os.Looper\nimport android.view.ContextThemeWrapper\nimport android.view.Gravity\nimport android.view.MotionEvent\nimport android.view.View\nimport android.view.WindowManager\nimport android.widget.FrameLayout\nimport android.widget.HorizontalScrollView\nimport android.widget.ImageButton\nimport android.widget.ImageView\nimport android.widget.LinearLayout\nimport android.widget.SeekBar\nimport android.widget.TextView\nimport androidx.core.graphics.toColorInt\nimport androidx.media3.common.Player\nimport androidx.media3.exoplayer.ExoPlayer\nimport expo.modules.orpheus.R\nimport expo.modules.orpheus.model.LyricsLine\nimport expo.modules.orpheus.util.GeneralStorage\nimport expo.modules.orpheus.view.LyricView\nimport kotlin.math.abs\n\nclass FloatingLyricsManager(private val context: Context, private val player: ExoPlayer?) {\n\n    private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager\n    private var floatingView: FrameLayout? = null\n    private var lyricView: LyricView? = null\n    private var settingsPanel: LinearLayout? = null\n    private var playPauseButton: ImageButton? = null\n    private var params: WindowManager.LayoutParams? = null\n\n    private val uiContext = ContextThemeWrapper(context, android.R.style.Theme_DeviceDefault)\n\n    /** Callback invoked when the user clicks \"清空歌词\" in the settings panel. */\n    var onClearLyricsRequested: ((trackId: String) -> Unit)? = null\n\n    private var currentLine: LyricsLine? = null\n    private var currentProgressMs: Long = 0L\n\n    private var textSize = 18f\n    private var textColor = \"#FFC107\".toColorInt()\n    private var displayMode = 0 \n    private var isLocked = false\n    private var cachedStatusBarHeight = 0\n\n    private val colors = listOf(\"#FFFFFF\", \"#FFC107\", \"#FF5722\", \"#E91E63\", \"#9C27B0\", \"#2196F3\", \"#00BCD4\", \"#4CAF50\")\n    private val colorViews = mutableListOf<View>()\n\n    private val playerListener = object : Player.Listener {\n        override fun onIsPlayingChanged(isPlaying: Boolean) {\n            updatePlayPauseButton(isPlaying)\n            Handler(Looper.getMainLooper()).post { lyricView?.setPlaybackState(isPlaying) }\n        }\n    }\n\n    init {\n        isLocked = GeneralStorage.isDesktopLyricsLocked()\n        displayMode = GeneralStorage.getDesktopLyricsMode().coerceIn(0, 2)\n        textColor = GeneralStorage.getDesktopLyricsHighlightColor()\n        textSize = GeneralStorage.getDesktopLyricsTextSize()\n        val resourceId = context.resources.getIdentifier(\"status_bar_height\", \"dimen\", \"android\")\n        if (resourceId > 0) cachedStatusBarHeight = context.resources.getDimensionPixelSize(resourceId)\n    }\n\n    fun show() {\n        if (floatingView != null) return\n        \n        // Re-read latest settings from storage before showing\n        isLocked = GeneralStorage.isDesktopLyricsLocked()\n        displayMode = GeneralStorage.getDesktopLyricsMode().coerceIn(0, 2)\n        textColor = GeneralStorage.getDesktopLyricsHighlightColor()\n        textSize = GeneralStorage.getDesktopLyricsTextSize()\n        \n        @Suppress(\"DEPRECATION\")\n        val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE\n        params = WindowManager.LayoutParams(\n            WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT,\n            type, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,\n            PixelFormat.TRANSLUCENT\n        ).apply {\n            gravity = Gravity.TOP or Gravity.START\n            y = GeneralStorage.getDesktopLyricsY()\n        }\n        createView()\n        updateTouchableFlags()\n        try {\n            windowManager.addView(floatingView, params)\n            player?.addListener(playerListener)\n            val playing = player?.isPlaying == true\n            updatePlayPauseButton(playing)\n            lyricView?.setPlaybackState(playing)\n            applyCurrentLyricState()\n            GeneralStorage.setDesktopLyricsShown(true)\n            syncTrackInfo()\n        } catch (e: Exception) { e.printStackTrace() }\n    }\n\n    fun syncTrackInfo() {\n        val mediaItem = player?.currentMediaItem\n        val title = mediaItem?.mediaMetadata?.title?.toString() ?: \"\"\n        val artist = mediaItem?.mediaMetadata?.artist?.toString() ?: \"\"\n        Handler(Looper.getMainLooper()).post { lyricView?.setTrackInfo(title, artist) }\n    }\n\n    /** Whether the floating window is currently attached to the screen. */\n    val isShowing: Boolean get() = floatingView != null\n\n    fun hide() {\n        floatingView?.let {\n            try { windowManager.removeView(it) } catch (e: Exception) { e.printStackTrace() }\n            player?.removeListener(playerListener)\n            floatingView = null\n            lyricView = null\n            settingsPanel = null\n            playPauseButton = null\n            colorViews.clear()\n            GeneralStorage.setDesktopLyricsShown(false)\n        }\n    }\n\n    /**\n     * Temporarily hides the floating window WITHOUT persisting the state to GeneralStorage.\n     * Used when there are no lyrics for the current track so the panel vanishes,\n     * but it will be re-shown automatically when lyrics become available again.\n     */\n    fun softHide() {\n        floatingView?.let {\n            try { windowManager.removeView(it) } catch (e: Exception) { e.printStackTrace() }\n            player?.removeListener(playerListener)\n            floatingView = null\n            lyricView = null\n            settingsPanel = null\n            playPauseButton = null\n            colorViews.clear()\n            // Note: intentionally NOT calling GeneralStorage.setDesktopLyricsShown(false)\n        }\n    }\n\n    fun setCurrentLine(line: LyricsLine?) {\n        currentLine = line\n        updateText(line)\n    }\n\n    fun updateLyricProgress(progressMs: Long) {\n        currentProgressMs = progressMs.coerceAtLeast(0L)\n        Handler(Looper.getMainLooper()).post { lyricView?.updateProgress(currentProgressMs) }\n    }\n\n    fun clearLyrics() {\n        currentLine = null\n        currentProgressMs = 0L\n        Handler(Looper.getMainLooper()).post {\n            lyricView?.setLine(null)\n            lyricView?.updateProgress(0L)\n        }\n    }\n\n    fun setLocked(locked: Boolean) {\n        isLocked = locked\n        GeneralStorage.setDesktopLyricsLocked(locked)\n        updateTouchableFlags()\n        if (locked) settingsPanel?.visibility = View.GONE\n    }\n\n    private fun updateTouchableFlags() {\n        val p = params ?: return\n        p.flags = if (isLocked) p.flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE else p.flags and WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE.inv()\n        updateLayout()\n    }\n\n    private fun updateLayout() {\n        try { if (floatingView?.isAttachedToWindow == true) windowManager.updateViewLayout(floatingView, params) } catch (e: Exception) {}\n    }\n\n    private fun updateText(line: LyricsLine?) {\n        Handler(Looper.getMainLooper()).post { lyricView?.setLine(line) }\n    }\n\n    private fun applyCurrentLyricState() {\n        Handler(Looper.getMainLooper()).post {\n            lyricView?.setLine(currentLine)\n            lyricView?.updateProgress(currentProgressMs)\n        }\n    }\n\n    private fun updatePlayPauseButton(isPlaying: Boolean) {\n        Handler(Looper.getMainLooper()).post {\n            playPauseButton?.setImageResource(if (isPlaying) R.drawable.outline_pause_24 else R.drawable.outline_play_arrow_24)\n        }\n    }\n\n    private fun createView() {\n        val frame = FrameLayout(uiContext)\n        val contentContainer = LinearLayout(uiContext).apply {\n            orientation = LinearLayout.VERTICAL\n            gravity = Gravity.CENTER_HORIZONTAL\n            layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT)\n        }\n        lyricView = LyricView(uiContext).apply {\n            setStyle(this@FloatingLyricsManager.textSize, this@FloatingLyricsManager.textColor)\n            setDisplayMode(this@FloatingLyricsManager.displayMode)\n            setPadding(20, 10, 20, 10)\n            setOnClickListener { toggleSettings() }\n        }\n        settingsPanel = createSettingsPanel()\n        settingsPanel?.visibility = View.GONE\n        contentContainer.addView(lyricView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 10 })\n        contentContainer.addView(settingsPanel, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT))\n        frame.addView(contentContainer)\n        \n        var initialY = 0\n        var initialTouchY = 0f\n        var isClick = false\n        val touchSlop = 10\n        lyricView?.setOnTouchListener { v, event ->\n            if (isLocked) return@setOnTouchListener false\n            when (event.action) {\n                MotionEvent.ACTION_DOWN -> { initialY = params?.y ?: 0; initialTouchY = event.rawY; isClick = true; true }\n                MotionEvent.ACTION_MOVE -> {\n                    val dy = (event.rawY - initialTouchY).toInt()\n                    if (abs(dy) > touchSlop) { isClick = false; params?.y = maxOf(cachedStatusBarHeight, initialY + dy); updateLayout() }\n                    true\n                }\n                MotionEvent.ACTION_UP -> { \n                    if (isClick) v.performClick()\n                    else params?.y?.let { GeneralStorage.setDesktopLyricsY(it) }\n                    true \n                }\n                else -> false\n            }\n        }\n        floatingView = frame\n    }\n\n    private fun createSettingsPanel(): LinearLayout {\n        val panel = LinearLayout(uiContext).apply {\n            orientation = LinearLayout.VERTICAL\n            background = GradientDrawable().apply { setColor(\"#DD1A1A1A\".toColorInt()); cornerRadius = 32f }\n            setPadding(32, 24, 32, 24)\n            gravity = Gravity.CENTER_HORIZONTAL\n        }\n\n        // Playback Row\n        val controlsRow = LinearLayout(uiContext).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER; setPadding(0, 0, 0, 24) }\n        controlsRow.addView(createControlButton(R.drawable.outline_skip_previous_24) { player?.seekToPreviousMediaItem() })\n        controlsRow.addView(View(uiContext), LinearLayout.LayoutParams(40, 1))\n        playPauseButton = createControlButton(if (player?.isPlaying == true) R.drawable.outline_pause_24 else R.drawable.outline_play_arrow_24) {\n            if (player?.isPlaying == true) player.pause() else player?.play()\n        }.apply { textSize = 28f }\n        controlsRow.addView(playPauseButton)\n        controlsRow.addView(View(uiContext), LinearLayout.LayoutParams(40, 1))\n        controlsRow.addView(createControlButton(R.drawable.outline_skip_next_24) { player?.seekToNextMediaItem() })\n\n        // Size Slider\n        val sizeRow = LinearLayout(uiContext).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL; setPadding(0, 0, 0, 24) }\n        sizeRow.addView(TextView(uiContext).apply { text = this@FloatingLyricsManager.context.getString(R.string.size); setTextColor(Color.LTGRAY); textSize = 12f })\n        sizeRow.addView(SeekBar(uiContext).apply {\n            max = 30\n            progress = (textSize - 10).toInt()\n            setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {\n                override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) {\n                    if (!p2) return // Only handle user-initiated changes\n                    textSize = (p1 + 10).toFloat()\n                    GeneralStorage.setDesktopLyricsTextSize(textSize)\n                    lyricView?.setStyle(textSize, textColor)\n                }\n                override fun onStartTrackingTouch(p0: SeekBar?) {}\n                override fun onStopTrackingTouch(p0: SeekBar?) {}\n            })\n        }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = 16 })\n\n        // Color Row\n        val colorRow = LinearLayout(uiContext).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL; setPadding(0, 0, 0, 24) }\n        colorRow.addView(TextView(uiContext).apply { text = \"配色\"; setTextColor(Color.LTGRAY); textSize = 12f; layoutParams = LinearLayout.LayoutParams(80, LinearLayout.LayoutParams.WRAP_CONTENT) })\n        val scroll = HorizontalScrollView(uiContext).apply { isHorizontalScrollBarEnabled = false; overScrollMode = View.OVER_SCROLL_NEVER }\n        val container = LinearLayout(uiContext).apply { orientation = LinearLayout.HORIZONTAL }\n        colorViews.clear()\n        colors.forEach { colorString ->\n            val color = colorString.toColorInt()\n            val v = View(uiContext).apply {\n                layoutParams = LinearLayout.LayoutParams(55, 55).apply { marginEnd = 16 }\n                background = createColorCircleDrawable(color, color == textColor)\n                setOnClickListener {\n                    textColor = color\n                    GeneralStorage.setDesktopLyricsHighlightColor(color)\n                    lyricView?.setStyle(textSize, textColor)\n                    refreshColorSelection()\n                }\n            }\n            colorViews.add(v)\n            container.addView(v)\n        }\n        scroll.addView(container)\n        colorRow.addView(scroll)\n\n        // Action Row\n        val actionsRow = LinearLayout(uiContext).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER }\n        actionsRow.addView(createActionButton(R.string.lock, R.drawable.outline_lock_24) { setLocked(true) })\n        actionsRow.addView(View(uiContext), LinearLayout.LayoutParams(24, 1))\n        val modeBtn = createActionButton(getModeTextRes(), R.drawable.outline_translate_24) {\n            displayMode = (displayMode + 1) % 3\n            GeneralStorage.setDesktopLyricsMode(displayMode)\n            lyricView?.setDisplayMode(displayMode)\n            (it as TextView).text = this@FloatingLyricsManager.context.getString(getModeTextRes())\n            updateLayout()\n        }\n        actionsRow.addView(modeBtn)\n        actionsRow.addView(View(uiContext), LinearLayout.LayoutParams(24, 1))\n        actionsRow.addView(createActionButton(R.string.clear_lyrics, R.drawable.outline_lyrics_off_24) {\n            settingsPanel?.visibility = View.GONE\n            updateLayout()\n            val trackId = player?.currentMediaItem?.mediaId ?: return@createActionButton\n            // Clear the overlay immediately for instant feedback\n            clearLyrics()\n            onClearLyricsRequested?.invoke(trackId)\n        })\n        actionsRow.addView(View(uiContext), LinearLayout.LayoutParams(24, 1))\n        actionsRow.addView(createActionButton(R.string.close, R.drawable.outline_close_24) { settingsPanel?.visibility = View.GONE; updateLayout() })\n\n        panel.addView(controlsRow); panel.addView(sizeRow); panel.addView(colorRow); panel.addView(actionsRow)\n        return panel\n    }\n\n    private fun createColorCircleDrawable(color: Int, selected: Boolean): GradientDrawable {\n        return GradientDrawable().apply {\n            shape = GradientDrawable.OVAL\n            setColor(color)\n            setStroke(if (selected) 5 else 1, if (selected) Color.WHITE else Color.DKGRAY)\n        }\n    }\n\n    private fun refreshColorSelection() {\n        colors.forEachIndexed { index, colorString ->\n            val color = colorString.toColorInt()\n            colorViews.getOrNull(index)?.background = createColorCircleDrawable(color, color == textColor)\n        }\n    }\n\n    private fun createControlButton(resId: Int, onClick: () -> Unit): ImageButton {\n        return ImageButton(uiContext).apply {\n            setImageResource(resId)\n            setBackgroundColor(Color.TRANSPARENT); setColorFilter(Color.WHITE)\n            scaleType = ImageView.ScaleType.FIT_CENTER; setPadding(16, 16, 16, 16)\n            setOnClickListener { onClick() }\n        }\n    }\n\n    private fun createActionButton(textId: Int, iconId: Int, onClick: (View) -> Unit): TextView {\n        return TextView(uiContext).apply {\n            text = this@FloatingLyricsManager.context.getString(textId)\n            textSize = 11f; setTextColor(Color.WHITE); gravity = Gravity.CENTER; setPadding(20, 12, 20, 12)\n            setCompoundDrawablesWithIntrinsicBounds(iconId, 0, 0, 0); compoundDrawablePadding = 6\n            background = GradientDrawable().apply { setColor(\"#33FFFFFF\".toColorInt()); cornerRadius = 50f }\n            setOnClickListener { onClick(it) }\n        }\n    }\n\n    private fun getModeTextRes(): Int = when (displayMode) { 0 -> R.string.lyric_mode_trans; 1 -> R.string.lyric_mode_roma; else -> R.string.lyric_mode_none }\n\n    private fun toggleSettings() {\n        if (settingsPanel?.visibility == View.VISIBLE) {\n            settingsPanel?.visibility = View.GONE\n        } else {\n            settingsPanel?.visibility = View.VISIBLE\n            updatePlayPauseButton(player?.isPlaying == true)\n        }\n        updateLayout()\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/LyriconBackend.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport android.os.Build\nimport android.os.Handler\nimport android.os.Looper\nimport android.util.Log\nimport androidx.annotation.RequiresApi\nimport androidx.media3.common.C\nimport expo.modules.orpheus.model.LyricsData\nimport expo.modules.orpheus.model.LyricsLine\nimport expo.modules.orpheus.service.OrpheusMusicService\nimport io.github.proify.lyricon.provider.LyriconFactory\nimport io.github.proify.lyricon.lyric.model.RichLyricLine\nimport io.github.proify.lyricon.lyric.model.LyricWord\nimport io.github.proify.lyricon.lyric.model.Song\nimport io.github.proify.lyricon.provider.service.addConnectionListener\n\nprivate const val TAG = \"LyriconBackend\"\n\n/**\n * Lyricon implementation for status bar lyrics.\n * Supports per-word (dynamic) lyrics and translations via AIDL IPC.\n */\n@RequiresApi(Build.VERSION_CODES.O_MR1)\nclass LyriconBackend(context: Context) : StatusBarLyricsBackend(context) {\n\n    private val provider = LyriconFactory.createProvider(context)\n    private val mainHandler = Handler(Looper.getMainLooper())\n    private val frameLock = Any()\n\n    @Volatile private var connected: Boolean = false\n    @Volatile private var lastSong: Song? = null\n    @Volatile private var lastFrame: StatusBarLyricFrame? = null\n    @Volatile private var lastIsPlaying: Boolean = false\n\n    override val isAvailable: Boolean\n        get() = connected\n\n    init {\n        provider.service.addConnectionListener {\n            onConnected {\n                connected = true\n                Log.d(TAG, \"Lyricon connected - syncing state\")\n                syncState()\n                notifyStatusChanged()\n            }\n            onReconnected {\n                connected = true\n                Log.d(TAG, \"Lyricon reconnected - syncing state\")\n                syncState()\n                notifyStatusChanged()\n            }\n            onDisconnected {\n                connected = false\n                Log.d(TAG, \"Lyricon disconnected\")\n                notifyStatusChanged()\n            }\n            onConnectTimeout {\n                connected = false\n                Log.w(TAG, \"Lyricon connection timeout\")\n                notifyStatusChanged()\n            }\n        }\n        provider.register()\n    }\n\n    private fun notifyStatusChanged() {\n        OrpheusMusicService.instance?.statusBarLyricsManager?.notifyStatusChanged()\n    }\n\n    private fun syncState() {\n        val song = lastSong\n        val frame = synchronized(frameLock) { lastFrame }\n        mainHandler.post {\n            try {\n                provider.player.setDisplayTranslation(true)\n                song?.let { provider.player.setSong(it) }\n                frame?.let { provider.player.setPosition(it.positionMs.coerceAtLeast(0L)) }\n                provider.player.setPlaybackState(lastIsPlaying)\n                Log.d(TAG, \"[syncState] Restored song and state ($lastIsPlaying)\")\n            } catch (e: Exception) {\n                Log.e(TAG, \"[syncState] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    override fun setLyricsData(data: LyricsData) {\n        if (data.lyrics.isEmpty()) {\n            clearLyrics()\n            return\n        }\n\n        val richLines = buildRichLines(data.lyrics)\n\n        mainHandler.post {\n            val player = OrpheusMusicService.instance?.player\n            val mediaItem = player?.currentMediaItem\n            val fallbackDuration = richLines.maxOfOrNull { it.end } ?: 0L\n            val song = Song(\n                id = mediaItem?.mediaId ?: \"\",\n                name = mediaItem?.mediaMetadata?.title?.toString() ?: \"\",\n                artist = mediaItem?.mediaMetadata?.artist?.toString() ?: \"\",\n                duration = player?.duration?.takeIf { it != C.TIME_UNSET } ?: fallbackDuration,\n                lyrics = richLines,\n            )\n\n            lastSong = song\n\n            try {\n                provider.player.setSong(song)\n                provider.player.setPlaybackState(lastIsPlaying)\n                Log.d(TAG, \"[setLyricsData] Sent song lines=${richLines.size} id=${song.id}\")\n            } catch (e: Exception) {\n                Log.e(TAG, \"[setLyricsData] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    override fun renderLyricFrame(frame: StatusBarLyricFrame?) {\n        synchronized(frameLock) {\n            lastFrame = frame\n        }\n\n        if (frame == null) {\n            return\n        }\n\n        mainHandler.post {\n            updatePositionInternal(frame.positionMs)\n        }\n    }\n\n    private fun clearLyrics() {\n        synchronized(frameLock) {\n            lastFrame = null\n        }\n        lastSong = null\n        mainHandler.post {\n            try {\n                provider.player.setSong(Song(lyrics = emptyList()))\n                provider.player.setPlaybackState(false)\n                Log.d(TAG, \"[clearLyrics] Lyrics cleared\")\n            } catch (e: Exception) {\n                Log.e(TAG, \"[clearLyrics] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    override fun updateProgress(positionMs: Long) {\n        if (!connected) return\n\n        val clamped = positionMs.coerceAtLeast(0L)\n        mainHandler.post {\n            if (!connected) return@post\n\n            synchronized(frameLock) {\n                lastFrame = lastFrame?.copy(positionMs = clamped)\n            }\n            updatePositionInternal(clamped, logFailure = false)\n        }\n    }\n\n    override fun setPlaybackState(isPlaying: Boolean) {\n        lastIsPlaying = isPlaying\n        mainHandler.post {\n            try {\n                provider.player.setPlaybackState(isPlaying)\n                Log.d(TAG, \"[setPlaybackState] $isPlaying\")\n            } catch (e: Exception) {\n                Log.e(TAG, \"[setPlaybackState] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    override fun onStop() {\n        synchronized(frameLock) {\n            lastFrame = null\n        }\n        lastIsPlaying = false\n        mainHandler.post {\n            try {\n                provider.player.setPlaybackState(false)\n            } catch (e: Exception) {\n                Log.e(TAG, \"[onStop] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    override fun destroy() {\n        synchronized(frameLock) {\n            lastFrame = null\n        }\n        lastSong = null\n        lastIsPlaying = false\n        mainHandler.post {\n            try {\n                provider.player.setPlaybackState(false)\n            } catch (e: Exception) {\n                Log.e(TAG, \"[destroy] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    private fun updatePositionInternal(positionMs: Long, logFailure: Boolean = true) {\n        try {\n            provider.player.setPosition(positionMs.coerceAtLeast(0L))\n        } catch (e: Exception) {\n            if (logFailure) {\n                Log.e(TAG, \"[position] Failed: ${e.message}\")\n            }\n        }\n    }\n\n    private fun buildRichLines(lyrics: List<LyricsLine>): List<RichLyricLine> {\n        return lyrics.mapIndexed { index, line ->\n            val lineStartMs = (line.timestamp * 1000).toLong().coerceAtLeast(0L)\n            val lineEndMs = line.endTime\n                ?.times(1000)\n                ?.toLong()\n                ?.coerceAtLeast(lineStartMs)\n                ?: lyrics.getOrNull(index + 1)\n                    ?.timestamp\n                    ?.times(1000)\n                    ?.toLong()\n                    ?.coerceAtLeast(lineStartMs)\n                ?: line.spans?.lastOrNull()?.endTime?.coerceAtLeast(lineStartMs)\n                ?: (lineStartMs + DEFAULT_LINE_DURATION_MS)\n            val words = line.spans?.map { span ->\n                LyricWord(\n                    begin = span.startTime,\n                    end = span.endTime,\n                    duration = span.duration,\n                    text = span.text,\n                )\n            }\n\n            RichLyricLine(\n                begin = lineStartMs,\n                end = lineEndMs,\n                text = line.text,\n                words = words,\n                translation = line.translation?.ifEmpty { null } ?: line.romaji?.ifEmpty { null },\n            )\n        }\n    }\n\n    private companion object {\n        const val DEFAULT_LINE_DURATION_MS = 5000L\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/SpectrumManager.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.media.audiofx.Visualizer\nimport android.util.Log\nimport kotlin.math.hypot\n\nclass SpectrumManager {\n    private var visualizer: Visualizer? = null\n    private var isEnabled = false\n    private val fftSize = Visualizer.getCaptureSizeRange()[1] // Max capture size (usually 1024)\n    private var fftBytes = ByteArray(fftSize)\n\n    fun start(audioSessionId: Int) {\n        if (visualizer != null) {\n            stop()\n        }\n\n        try {\n            visualizer = Visualizer(audioSessionId).apply {\n                captureSize = fftSize\n                setDataCaptureListener(object : Visualizer.OnDataCaptureListener {\n                    override fun onWaveFormDataCapture(\n                        visualizer: Visualizer?,\n                        waveform: ByteArray?,\n                        samplingRate: Int\n                    ) {\n                        // Not used\n                    }\n\n                    override fun onFftDataCapture(\n                        visualizer: Visualizer?,\n                        fft: ByteArray?,\n                        samplingRate: Int\n                    ) {\n                        // 我们采用手动轮询获取数据，但这个是必须的\n                    }\n                }, Visualizer.getMaxCaptureRate() / 2, false, true)\n                enabled = true\n            }\n            isEnabled = true\n        } catch (e: Exception) {\n            Log.e(\"Orpheus\", \"Failed to initialize Visualizer: ${e.message}\")\n            isEnabled = false\n        }\n    }\n\n    fun stop() {\n        try {\n            visualizer?.enabled = false\n            visualizer?.release()\n        } catch (e: Exception) {\n            e.printStackTrace()\n        } finally {\n            visualizer = null\n            isEnabled = false\n        }\n    }\n\n    /**\n     * Fills the provided FloatArray with normalized magnitude data (0.0 - 1.0).\n     * The array size should ideally be fftSize / 2.\n     */\n    fun getSpectrumData(destination: FloatArray) {\n        if (!isEnabled || visualizer == null) {\n            destination.fill(0f)\n            return\n        }\n\n        try {\n            visualizer?.getFft(fftBytes)\n\n            val n = fftBytes.size\n            val outputSize = minOf(destination.size, n / 2)\n\n            for (i in 0 until outputSize) {\n                if (i == 0) {\n                    val real = fftBytes[0].toFloat()\n                    val imag = fftBytes[1].toFloat()\n                    destination[0] = hypot(real, imag) / 128.0f\n                } else {\n                    val k = i * 2\n                    if (k + 1 < n) {\n                        val real = fftBytes[k].toFloat()\n                        val imag = fftBytes[k + 1].toFloat()\n                        val magnitude = hypot(real, imag)\n                        destination[i] = magnitude / 128.0f\n                    }\n                }\n            }\n        } catch (e: Exception) {\n            destination.fill(0f)\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/StatusBarLyricsBackend.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport expo.modules.orpheus.model.LyricsData\nimport expo.modules.orpheus.model.LyricsLine\n\ndata class StatusBarLyricFrame(\n    val line: LyricsLine,\n    val positionMs: Long,\n    val lineDurationMs: Long,\n    val lineProgressMs: Long,\n    val delayMs: Int,\n)\n\n/**\n * Abstract backend for status bar lyrics frameworks.\n * Concrete implementations wrap SuperLyric and Lyricon respectively.\n */\nabstract class StatusBarLyricsBackend(protected val context: Context) {\n    /** Whether the underlying framework service is active/connected. */\n    abstract val isAvailable: Boolean\n\n    /** Called when the full status bar lyric set changes. */\n    open fun setLyricsData(data: LyricsData) {}\n\n    /** Called when UnifiedLyricsManager selects a new current line. */\n    abstract fun renderLyricFrame(frame: StatusBarLyricFrame?)\n\n    /** Called continuously with the current projected song position. */\n    abstract fun updateProgress(positionMs: Long)\n\n    /** Called when the player starts or pauses. */\n    abstract fun setPlaybackState(isPlaying: Boolean)\n\n    /** Called when playback stops or the track changes. */\n    abstract fun onStop()\n\n    /** Optional cleanup hook called when this backend is no longer needed. */\n    open fun destroy() {}\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/StatusBarLyricsManager.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport android.util.Log\nimport expo.modules.orpheus.model.LyricsData\n\nprivate const val TAG = \"StatusBarLyrics\"\n\n/**\n * Orchestrates status bar lyrics by switching between providers\n * and maintaining the currently rendered line state.\n */\nclass StatusBarLyricsManager(private val context: Context) {\n\n    interface StatusChangeListener {\n        fun onStatusChanged()\n    }\n\n    private var statusChangeListener: StatusChangeListener? = null\n\n    fun setStatusChangeListener(listener: StatusChangeListener?) {\n        statusChangeListener = listener\n    }\n\n    fun notifyStatusChanged() {\n        statusChangeListener?.onStatusChanged()\n    }\n\n    var enabled: Boolean = false\n        set(value) {\n            val prev = field\n            field = value\n            if (prev && !value) {\n                backend?.onStop()\n            } else if (!prev && value) {\n                reapplyCurrentState()\n            }\n        }\n\n    /** Active backend; swap to switch between SuperLyric and Lyricon. */\n    var backend: StatusBarLyricsBackend? = null\n        set(value) {\n            val previous = field\n            if (previous != null) {\n                if (enabled) previous.onStop()\n                previous.destroy()\n            }\n            field = value\n            Log.d(TAG, \"[backend] switched to ${value?.javaClass?.simpleName}\")\n\n            if (enabled) {\n                reapplyCurrentState()\n            }\n        }\n\n    private var lastFrame: StatusBarLyricFrame? = null\n    private var lastLyricsData: LyricsData? = null\n    private var lastIsPlaying: Boolean = false\n\n    fun setLyricsData(data: LyricsData) {\n        lastLyricsData = data\n\n        if (!enabled) return\n\n        backend?.setLyricsData(data)\n    }\n\n    fun renderLyricFrame(frame: StatusBarLyricFrame?) {\n        lastFrame = frame\n\n        if (!enabled) return\n\n        backend?.renderLyricFrame(frame)\n    }\n\n    fun updateProgress(positionMs: Long, lineProgressMs: Long) {\n        lastFrame = lastFrame?.copy(\n            positionMs = positionMs,\n            lineProgressMs = lineProgressMs,\n        )\n\n        if (!enabled) return\n\n        backend?.updateProgress(positionMs)\n    }\n\n    fun setPlaybackState(isPlaying: Boolean) {\n        lastIsPlaying = isPlaying\n\n        if (!enabled) return\n\n        backend?.setPlaybackState(isPlaying)\n    }\n\n    fun onStop() {\n        lastFrame = null\n        lastLyricsData = null\n        lastIsPlaying = false\n        backend?.onStop()\n    }\n\n    private fun reapplyCurrentState() {\n        lastLyricsData?.let { backend?.setLyricsData(it) }\n        backend?.renderLyricFrame(lastFrame)\n        backend?.setPlaybackState(lastIsPlaying)\n        lastFrame?.let { backend?.updateProgress(it.positionMs) }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/SuperLyricBackend.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport android.content.Context\nimport android.util.Log\nimport com.hchen.superlyricapi.SuperLyricData\nimport com.hchen.superlyricapi.SuperLyricPush\nimport com.hchen.superlyricapi.SuperLyricTool\n\nprivate const val TAG = \"SuperLyricBackend\"\n\n/**\n * SuperLyric implementation for status bar lyrics.\n * Simple line-by-line display protocol.\n */\nclass SuperLyricBackend(context: Context) : StatusBarLyricsBackend(context) {\n\n    override val isAvailable: Boolean\n        get() = SuperLyricTool.isEnabled\n\n    private var lastFrame: StatusBarLyricFrame? = null\n\n    override fun renderLyricFrame(frame: StatusBarLyricFrame?) {\n        lastFrame = frame\n\n        if (frame == null) {\n            onStop()\n            return\n        }\n\n        sendFrame(frame)\n    }\n\n    // SuperLyric is line-by-line; progress is ignored.\n    override fun updateProgress(positionMs: Long) = Unit\n\n    private fun sendFrame(frame: StatusBarLyricFrame) {\n        if (!SuperLyricTool.isEnabled) return\n\n        val line = frame.line\n        val translation = line.translation ?: line.romaji\n        val data = SuperLyricData()\n            .setLyric(line.text)\n            .setPackageName(context.packageName)\n            .setDelay(frame.delayMs)\n\n        if (!translation.isNullOrEmpty()) {\n            data.setTranslation(translation)\n        }\n\n        try {\n            SuperLyricPush.onSuperLyric(data)\n            Log.d(TAG, \"[render] text=\\\"${line.text}\\\" delay=${frame.delayMs}\")\n        } catch (e: Exception) {\n            Log.e(TAG, \"[render] Failed: ${e.message}\")\n        }\n    }\n\n    override fun setPlaybackState(isPlaying: Boolean) {\n        if (isPlaying) {\n            lastFrame?.let { frame ->\n                sendFrame(\n                    frame.copy(\n                        delayMs = (frame.delayMs.toLong() - frame.lineProgressMs)\n                            .coerceAtLeast(0L)\n                            .toInt(),\n                    ),\n                )\n            }\n        }\n    }\n\n    override fun onStop() {\n        lastFrame = null\n\n        if (!SuperLyricTool.isEnabled) return\n\n        try {\n            SuperLyricPush.onStop(SuperLyricData().setPackageName(context.packageName))\n        } catch (e: Exception) {\n            Log.e(TAG, \"[onStop] Failed: ${e.message}\")\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/manager/UnifiedLyricsManager.kt",
    "content": "package expo.modules.orpheus.manager\n\nimport expo.modules.orpheus.model.LyricsData\nimport expo.modules.orpheus.model.LyricsLine\nimport expo.modules.orpheus.util.GeneralStorage\n\nenum class LyricsConsumer {\n    DESKTOP,\n    STATUS_BAR,\n    CAR;\n\n    companion object {\n        fun all(): Set<LyricsConsumer> = linkedSetOf(DESKTOP, STATUS_BAR, CAR)\n\n        fun fromIdentifier(value: String): LyricsConsumer? {\n            return when (value.lowercase()) {\n                \"desktop\" -> DESKTOP\n                \"statusbar\", \"status_bar\", \"status-bar\" -> STATUS_BAR\n                \"car\" -> CAR\n                else -> null\n            }\n        }\n    }\n}\n\nprivate enum class LyricTextField {\n    TEXT,\n    TRANSLATION,\n    ROMAJI,\n    TRANSLATION_OR_ROMAJI,\n}\n\nprivate data class LyricsConsumerProfile(\n    val primaryText: LyricTextField = LyricTextField.TEXT,\n    val secondaryText: LyricTextField? = null,\n    val preserveTranslation: Boolean = false,\n    val preserveRomaji: Boolean = false,\n    val preserveWordTiming: Boolean = false,\n)\n\nclass UnifiedLyricsManager(\n    private val floatingLyricsManager: FloatingLyricsManager,\n    private val statusBarLyricsManager: StatusBarLyricsManager,\n    private val currentPlaybackSeconds: () -> Double?,\n    private val onCarLyricsChanged: (String?) -> Unit,\n) {\n\n    private var sharedLyrics: LyricsData = EMPTY_LYRICS\n    private val consumerOverrides = mutableMapOf<LyricsConsumer, LyricsData>()\n    private val projectedLyrics = mutableMapOf<LyricsConsumer, LyricsData>()\n    private var lastCarLyricText: String? = null\n    private var lastDesktopLineIndex: Int = UNSET_LINE_INDEX\n    private var lastStatusBarLineIndex: Int = UNSET_LINE_INDEX\n\n    fun submitLyrics(data: LyricsData, consumers: Set<LyricsConsumer> = LyricsConsumer.all()) {\n        val normalized = normalize(data)\n        val isAllConsumers = consumers.size == LyricsConsumer.entries.size\n        val affectedConsumers = if (isAllConsumers) LyricsConsumer.all() else consumers\n\n        if (isAllConsumers) {\n            sharedLyrics = normalized\n            consumerOverrides.clear()\n        } else {\n            consumers.forEach { consumer ->\n                consumerOverrides[consumer] = normalized\n            }\n        }\n\n        refreshProjectedLyrics(affectedConsumers)\n        affectedConsumers.forEach(::applyLyricsToConsumer)\n    }\n\n    fun clearConsumers(consumers: Set<LyricsConsumer>, softHideDesktop: Boolean = false) {\n        if (consumers.isEmpty()) return\n\n        if (consumers.size == LyricsConsumer.entries.size) {\n            sharedLyrics = EMPTY_LYRICS\n            consumerOverrides.clear()\n        } else {\n            consumers.forEach { consumerOverrides[it] = EMPTY_LYRICS }\n        }\n\n        refreshProjectedLyrics(consumers)\n\n        consumers.forEach { consumer ->\n            when (consumer) {\n                LyricsConsumer.DESKTOP -> {\n                    lastDesktopLineIndex = UNSET_LINE_INDEX\n                    floatingLyricsManager.clearLyrics()\n                    if (softHideDesktop) {\n                        floatingLyricsManager.softHide()\n                    }\n                }\n                LyricsConsumer.STATUS_BAR -> {\n                    lastStatusBarLineIndex = UNSET_LINE_INDEX\n                    statusBarLyricsManager.onStop()\n                }\n                LyricsConsumer.CAR -> {\n                    lastCarLyricText = null\n                    onCarLyricsChanged(null)\n                }\n            }\n        }\n    }\n\n    fun updateTime(seconds: Double) {\n        updateDesktopConsumer(seconds)\n        updateStatusBarConsumer(seconds)\n        updateCarLyrics(seconds)\n    }\n\n    fun setPlaybackState(isPlaying: Boolean) {\n        statusBarLyricsManager.setPlaybackState(isPlaying)\n    }\n\n    fun setCarLyricsEnabled(enabled: Boolean) {\n        if (enabled) {\n            currentPlaybackSeconds()?.let { seconds ->\n                updateCarLyrics(seconds, force = true)\n            }\n        } else {\n            lastCarLyricText = null\n            onCarLyricsChanged(null)\n        }\n    }\n\n    private fun applyLyricsToConsumer(consumer: LyricsConsumer) {\n        val projected = projectedLyrics[consumer] ?: EMPTY_LYRICS\n\n        when (consumer) {\n            LyricsConsumer.DESKTOP -> {\n                if (\n                    projected.lyrics.isNotEmpty() &&\n                    GeneralStorage.isDesktopLyricsShown() &&\n                    !floatingLyricsManager.isShowing\n                ) {\n                    floatingLyricsManager.show()\n                }\n                lastDesktopLineIndex = UNSET_LINE_INDEX\n                currentPlaybackSeconds()?.let(::updateDesktopConsumer) ?: floatingLyricsManager.clearLyrics()\n            }\n            LyricsConsumer.STATUS_BAR -> {\n                statusBarLyricsManager.setLyricsData(projected)\n                lastStatusBarLineIndex = UNSET_LINE_INDEX\n                currentPlaybackSeconds()?.let(::updateStatusBarConsumer)\n                    ?: statusBarLyricsManager.renderLyricFrame(null)\n            }\n            LyricsConsumer.CAR -> {\n                lastCarLyricText = null\n                if (GeneralStorage.isCarLyricsEnabled()) {\n                    currentPlaybackSeconds()?.let { seconds ->\n                        updateCarLyrics(seconds, force = true)\n                    }\n                } else {\n                    onCarLyricsChanged(null)\n                }\n            }\n        }\n    }\n\n    private fun dataForConsumer(consumer: LyricsConsumer): LyricsData {\n        return consumerOverrides[consumer] ?: sharedLyrics\n    }\n\n    private fun refreshProjectedLyrics(consumers: Set<LyricsConsumer>) {\n        consumers.forEach { consumer ->\n            projectedLyrics[consumer] = projectLyrics(dataForConsumer(consumer), consumer)\n        }\n    }\n\n    private fun updateDesktopConsumer(seconds: Double) {\n        val snapshot = snapshotFor(LyricsConsumer.DESKTOP, seconds)\n\n        if (snapshot.lineIndex != lastDesktopLineIndex) {\n            floatingLyricsManager.setCurrentLine(snapshot.line)\n            lastDesktopLineIndex = snapshot.lineIndex\n        }\n\n        floatingLyricsManager.updateLyricProgress(snapshot.adjustedTimeMs)\n    }\n\n    private fun updateStatusBarConsumer(seconds: Double) {\n        val snapshot = snapshotFor(LyricsConsumer.STATUS_BAR, seconds)\n\n        if (snapshot.lineIndex != lastStatusBarLineIndex) {\n            statusBarLyricsManager.renderLyricFrame(\n                snapshot.line?.let { line ->\n                    StatusBarLyricFrame(\n                        line = line,\n                        positionMs = snapshot.adjustedTimeMs,\n                        lineDurationMs = snapshot.lineDurationMs,\n                        lineProgressMs = snapshot.lineProgressMs,\n                        delayMs = snapshot.delayMs,\n                    )\n                },\n            )\n            if (snapshot.line == null) {\n                statusBarLyricsManager.updateProgress(snapshot.adjustedTimeMs, snapshot.lineProgressMs)\n            }\n            lastStatusBarLineIndex = snapshot.lineIndex\n        } else if (snapshot.line != null) {\n            statusBarLyricsManager.updateProgress(snapshot.adjustedTimeMs, snapshot.lineProgressMs)\n        }\n    }\n\n    private fun updateCarLyrics(seconds: Double, force: Boolean = false) {\n        if (!GeneralStorage.isCarLyricsEnabled()) return\n\n        val nextLyric = snapshotFor(LyricsConsumer.CAR, seconds).line?.text?.takeIf { it.isNotBlank() }\n        if (!force && nextLyric == lastCarLyricText) return\n\n        lastCarLyricText = nextLyric\n        onCarLyricsChanged(nextLyric)\n    }\n\n    private fun snapshotFor(consumer: LyricsConsumer, seconds: Double): LyricSnapshot {\n        val data = projectedLyrics[consumer] ?: EMPTY_LYRICS\n        if (data.lyrics.isEmpty()) {\n            return LyricSnapshot(\n                lineIndex = NO_LINE_INDEX,\n                line = null,\n                adjustedTimeMs = 0L,\n                lineProgressMs = 0L,\n                lineDurationMs = 0L,\n                delayMs = 0,\n            )\n        }\n\n        val adjustedTime = seconds - data.offset\n        val adjustedTimeMs = (adjustedTime * 1000).toLong().coerceAtLeast(0L)\n        val index = data.lyrics.indexOfLast { it.timestamp <= adjustedTime }\n        if (index < 0) {\n            return LyricSnapshot(\n                lineIndex = NO_LINE_INDEX,\n                line = null,\n                adjustedTimeMs = adjustedTimeMs,\n                lineProgressMs = 0L,\n                lineDurationMs = 0L,\n                delayMs = 0,\n            )\n        }\n\n        val line = data.lyrics[index]\n        val lineStartMs = (line.timestamp * 1000).toLong().coerceAtLeast(0L)\n        val lineEndMs = resolveLineEndMs(data, index, lineStartMs)\n        val lineProgressMs = (adjustedTimeMs - lineStartMs).coerceAtLeast(0L)\n        val nextLineStartMs = data.lyrics.getOrNull(index + 1)\n            ?.timestamp\n            ?.times(1000)\n            ?.toLong()\n\n        return LyricSnapshot(\n            lineIndex = index,\n            line = line,\n            adjustedTimeMs = adjustedTimeMs,\n            lineProgressMs = lineProgressMs,\n            lineDurationMs = (lineEndMs - lineStartMs).coerceAtLeast(1L),\n            delayMs = nextLineStartMs?.minus(lineStartMs)?.toInt() ?: 0,\n        )\n    }\n\n    private fun projectLyrics(data: LyricsData, consumer: LyricsConsumer): LyricsData {\n        val profile = profileFor(consumer)\n        return LyricsData(\n            lyrics = data.lyrics.mapNotNull { line -> projectLine(line, profile) },\n            offset = data.offset,\n        )\n    }\n\n    private fun projectLine(line: LyricsLine, profile: LyricsConsumerProfile): LyricsLine? {\n        val primaryText = resolveText(line, profile.primaryText)\n            ?.takeIf { it.isNotBlank() }\n            ?: return null\n        val secondaryText = profile.secondaryText\n            ?.let { field -> resolveText(line, field) }\n            ?.takeIf { it.isNotBlank() }\n\n        val translation = when {\n            profile.preserveTranslation -> line.translation\n            profile.secondaryText == LyricTextField.TRANSLATION ||\n                profile.secondaryText == LyricTextField.TRANSLATION_OR_ROMAJI -> secondaryText\n            else -> null\n        }\n        val romaji = when {\n            profile.preserveRomaji -> line.romaji\n            profile.secondaryText == LyricTextField.ROMAJI -> secondaryText\n            else -> null\n        }\n        val spans = if (profile.preserveWordTiming && primaryText == line.text) {\n            line.spans\n        } else {\n            null\n        }\n\n        return line.copy(\n            text = primaryText,\n            translation = translation,\n            romaji = romaji,\n            spans = spans,\n        )\n    }\n\n    private fun resolveText(line: LyricsLine, field: LyricTextField): String? {\n        return when (field) {\n            LyricTextField.TEXT -> line.text\n            LyricTextField.TRANSLATION -> line.translation\n            LyricTextField.ROMAJI -> line.romaji\n            LyricTextField.TRANSLATION_OR_ROMAJI -> line.translation ?: line.romaji\n        }\n    }\n\n    private fun profileFor(consumer: LyricsConsumer): LyricsConsumerProfile {\n        return when (consumer) {\n            LyricsConsumer.DESKTOP -> LyricsConsumerProfile(\n                preserveTranslation = true,\n                preserveRomaji = true,\n                preserveWordTiming = true,\n            )\n            LyricsConsumer.STATUS_BAR -> LyricsConsumerProfile(\n                secondaryText = LyricTextField.TRANSLATION_OR_ROMAJI,\n                preserveWordTiming = true,\n            )\n            LyricsConsumer.CAR -> LyricsConsumerProfile(\n                primaryText = LyricTextField.TEXT,\n            )\n        }\n    }\n\n    private fun normalize(data: LyricsData): LyricsData {\n        return data.copy(\n            lyrics = data.lyrics\n                .filter { it.text.isNotBlank() }\n                .sortedBy { it.timestamp },\n        )\n    }\n\n    private fun resolveLineEndMs(data: LyricsData, index: Int, lineStartMs: Long): Long {\n        val line = data.lyrics[index]\n\n        line.endTime?.let {\n            return (it * 1000).toLong().coerceAtLeast(lineStartMs)\n        }\n\n        data.lyrics.getOrNull(index + 1)?.let {\n            return (it.timestamp * 1000).toLong().coerceAtLeast(lineStartMs)\n        }\n\n        line.spans?.lastOrNull()?.let {\n            return it.endTime.coerceAtLeast(lineStartMs)\n        }\n\n        return lineStartMs + DEFAULT_LINE_DURATION_MS\n    }\n\n    private companion object {\n        val EMPTY_LYRICS = LyricsData(emptyList(), 0.0)\n        const val DEFAULT_LINE_DURATION_MS = 5000L\n        const val NO_LINE_INDEX = -1\n        const val UNSET_LINE_INDEX = Int.MIN_VALUE\n    }\n\n    private data class LyricSnapshot(\n        val lineIndex: Int,\n        val line: LyricsLine?,\n        val adjustedTimeMs: Long,\n        val lineProgressMs: Long,\n        val lineDurationMs: Long,\n        val delayMs: Int,\n    )\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/model/LyricsModels.kt",
    "content": "package expo.modules.orpheus.model\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class LyricSpan(\n    @SerialName(\"text\") val text: String,\n    @SerialName(\"startTime\") val startTime: Long, // 毫秒\n    @SerialName(\"endTime\") val endTime: Long,     // 毫秒\n    @SerialName(\"duration\") val duration: Long    // 毫秒\n)\n\n@Serializable\ndata class LyricsLine(\n    @SerialName(\"timestamp\") val timestamp: Double, // 秒\n    @SerialName(\"endTime\") val endTime: Double? = null, // 秒\n    @SerialName(\"text\") val text: String,\n    @SerialName(\"translation\") val translation: String? = null,\n    @SerialName(\"romaji\") val romaji: String? = null,\n    @SerialName(\"spans\") val spans: List<LyricSpan>? = null\n)\n\n@Serializable\ndata class LyricsData(\n    @SerialName(\"lyrics\") val lyrics: List<LyricsLine>,\n    @SerialName(\"offset\") val offset: Double = 0.0\n)\n\n/** 歌词缓存文件的最小结构，忽略其他字段 */\n@Serializable\ndata class LyricFileCache(\n    @SerialName(\"lrc\") val lrc: String? = null,\n)\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/model/TrackRecord.kt",
    "content": "package expo.modules.orpheus.model\n\nimport expo.modules.kotlin.records.Field\nimport expo.modules.kotlin.records.Record\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\nclass TrackRecord : Record {\n    @Field\n    @SerialName(\"id\")\n    var id: String = \"\"\n\n    @Field\n    @SerialName(\"url\")\n    var url: String = \"\"\n\n    @Field\n    @SerialName(\"title\")\n    var title: String? = null\n\n    @Field\n    @SerialName(\"artist\")\n    var artist: String? = null\n\n    @Field\n    @SerialName(\"artwork\")\n    var artwork: String? = null\n\n    // unit: second\n    @Field\n    @SerialName(\"duration\")\n    var duration: Double? = null\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/network/OkHttpClientManager.kt",
    "content": "package expo.modules.orpheus.network\n\nimport okhttp3.OkHttpClient\nimport java.util.concurrent.TimeUnit\n\nobject OkHttpClientManager {\n    val okHttpClient: OkHttpClient by lazy {\n        OkHttpClient.Builder()\n            .connectTimeout(30, TimeUnit.SECONDS)\n            .readTimeout(30, TimeUnit.SECONDS)\n            .writeTimeout(30, TimeUnit.SECONDS)\n            .build()\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/service/OrpheusDownloadService.kt",
    "content": "package expo.modules.orpheus.service\n\nimport android.Manifest\nimport android.app.Notification\nimport android.app.PendingIntent\nimport android.content.Intent\nimport androidx.annotation.RequiresPermission\nimport androidx.core.net.toUri\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.exoplayer.offline.Download\nimport androidx.media3.exoplayer.offline.DownloadManager\nimport androidx.media3.exoplayer.offline.DownloadService\nimport androidx.media3.exoplayer.scheduler.PlatformScheduler\nimport androidx.media3.exoplayer.scheduler.Scheduler\nimport expo.modules.orpheus.R\nimport expo.modules.orpheus.util.DownloadUtil\n\n@UnstableApi\nclass OrpheusDownloadService : DownloadService(\n    FOREGROUND_NOTIFICATION_ID,\n    DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,\n    CHANNEL_ID,\n    androidx.media3.exoplayer.R.string.exo_download_notification_channel_name,\n    0\n) {\n    companion object {\n        const val FOREGROUND_NOTIFICATION_ID = 114514\n        const val CHANNEL_ID = \"orpheus_download_channel\"\n    }\n\n    override fun getDownloadManager(): DownloadManager {\n        return DownloadUtil.getDownloadManager(this)\n    }\n\n    @RequiresPermission(Manifest.permission.RECEIVE_BOOT_COMPLETED)\n    override fun getScheduler(): Scheduler {\n        return PlatformScheduler(this, 114514)\n    }\n\n    override fun getForegroundNotification(\n        downloads: MutableList<Download>,\n        notMetRequirements: Int\n    ): Notification {\n        var launchIntent = packageManager.getLaunchIntentForPackage(packageName)\n\n        if (launchIntent == null) {\n            launchIntent = Intent().apply {\n                setClassName(packageName, \"$packageName.MainActivity\")\n            }\n        }\n\n        launchIntent.apply {\n            action = Intent.ACTION_VIEW\n            data = \"orpheus://downloads\".toUri()\n            addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)\n        }\n\n        val contentIntent = launchIntent.let {\n            PendingIntent.getActivity(\n                this,\n                0,\n                it,\n                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT\n            )\n        }\n\n        return DownloadUtil.getDownloadNotificationHelper(this)\n            .buildProgressNotification(\n                this,\n                R.drawable.baseline_download_24,\n                contentIntent,\n                null,\n                downloads,\n                notMetRequirements\n            )\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/service/OrpheusHeadlessTaskService.kt",
    "content": "package expo.modules.orpheus.service\n\nimport android.content.Intent\nimport com.facebook.react.HeadlessJsTaskService\nimport com.facebook.react.bridge.Arguments\nimport com.facebook.react.jstasks.HeadlessJsTaskConfig\n\nclass OrpheusHeadlessTaskService : HeadlessJsTaskService() {\n\n    override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {\n        val extras = intent?.extras\n        return if (extras != null) {\n            HeadlessJsTaskConfig(\n                \"OrpheusHeadlessTask\",\n                Arguments.fromBundle(extras),\n                5000, // timeout for the task\n                true // allowed in foreground\n            )\n        } else {\n            null\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/service/OrpheusMusicService.kt",
    "content": "package expo.modules.orpheus.service\n\nimport android.app.PendingIntent\nimport android.content.Intent\nimport android.os.Bundle\nimport android.util.Log\nimport androidx.annotation.OptIn\nimport androidx.core.net.toUri\nimport androidx.media3.common.AudioAttributes\nimport androidx.media3.common.C\nimport androidx.media3.common.MediaItem\nimport androidx.media3.common.MediaMetadata\nimport androidx.media3.common.Player\nimport androidx.media3.common.Timeline\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.exoplayer.DefaultRenderersFactory\nimport androidx.media3.exoplayer.ExoPlayer\nimport androidx.media3.exoplayer.source.DefaultMediaSourceFactory\nimport androidx.media3.session.CommandButton\nimport androidx.media3.session.DefaultMediaNotificationProvider\nimport androidx.media3.session.MediaLibraryService\nimport androidx.media3.session.MediaSession\nimport androidx.media3.session.SessionCommand\nimport androidx.media3.session.SessionResult\nimport com.google.common.collect.ImmutableList\nimport com.google.common.util.concurrent.Futures\nimport com.google.common.util.concurrent.ListenableFuture\nimport expo.modules.orpheus.R\nimport expo.modules.orpheus.manager.FloatingLyricsManager\nimport expo.modules.orpheus.manager.LyricsConsumer\nimport expo.modules.orpheus.manager.LyriconBackend\nimport expo.modules.orpheus.manager.StatusBarLyricsManager\nimport expo.modules.orpheus.manager.SuperLyricBackend\nimport expo.modules.orpheus.manager.UnifiedLyricsManager\nimport expo.modules.orpheus.model.LyricsData\nimport expo.modules.orpheus.model.LyricsLine\nimport expo.modules.orpheus.model.TrackRecord\nimport expo.modules.orpheus.util.CustomCommands\nimport expo.modules.orpheus.util.DownloadUtil\nimport expo.modules.orpheus.util.GeneralStorage\nimport expo.modules.orpheus.util.GlideBitmapLoader\nimport expo.modules.orpheus.util.LoudnessStorage\nimport expo.modules.orpheus.util.SleepTimeController\nimport expo.modules.orpheus.util.calculateLoudnessGain\nimport expo.modules.orpheus.util.fadeInTo\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.MainScope\nimport kotlinx.coroutines.cancel\nimport kotlinx.serialization.decodeFromString\nimport kotlinx.serialization.json.Json\nimport java.util.concurrent.CopyOnWriteArrayList\nimport kotlin.math.abs\n\nclass OrpheusMusicService : MediaLibraryService() {\n\n    var player: ExoPlayer? = null\n    private var mediaSession: MediaLibrarySession? = null\n    private var sleepTimerManager: SleepTimeController? = null\n    private var volumeFadeJob: Job? = null\n    private var scope = MainScope()\n\n    lateinit var floatingLyricsManager: FloatingLyricsManager\n    lateinit var statusBarLyricsManager: StatusBarLyricsManager\n    private val serviceHandler = android.os.Handler(android.os.Looper.getMainLooper())\n\n    private var lastTrackFinishedAt: Long = 0\n    private val durationCache = mutableMapOf<String, Long>()\n    lateinit var shuffleManager: ShuffleManager\n    lateinit var lyricsManager: UnifiedLyricsManager\n    private var currentMediaId: String? = null\n    private val json = Json { ignoreUnknownKeys = true }\n\n    private val lyricsUpdateRunnable = object : Runnable {\n        override fun run() {\n            player?.let { p ->\n                if (p.isPlaying) {\n                    val seconds = p.currentPosition / 1000.0\n                    lyricsManager.updateTime(seconds)\n                }\n            }\n            serviceHandler.postDelayed(this, 200)\n        }\n    }\n\n    companion object {\n        var instance: OrpheusMusicService? = null\n            private set(value) {\n                field = value\n                if (value != null) {\n                    listeners.forEach { it(value) }\n                }\n            }\n\n        private val listeners = CopyOnWriteArrayList<(OrpheusMusicService) -> Unit>()\n\n        fun addOnServiceReadyListener(listener: (OrpheusMusicService) -> Unit) {\n            instance?.let { listener(it) }\n            listeners.add(listener)\n        }\n\n        fun removeOnServiceReadyListener(listener: (OrpheusMusicService) -> Unit) {\n            listeners.remove(listener)\n        }\n    }\n\n    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {\n        super.onStartCommand(intent, flags, startId)\n        return START_STICKY\n    }\n\n    override fun onTaskRemoved(rootIntent: Intent?) {\n        val player = mediaSession?.player\n        if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {\n            stopSelf()\n        }\n        super.onTaskRemoved(rootIntent)\n    }\n\n    override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {\n        super.onUpdateNotification(session, true)\n    }\n\n    @OptIn(UnstableApi::class)\n    override fun onCreate() {\n        super.onCreate()\n        instance = this\n\n        GeneralStorage.initialize(this)\n        LoudnessStorage.initialize(this)\n\n        setMediaNotificationProvider(object : DefaultMediaNotificationProvider(this) {\n            override fun getMediaButtons(\n                session: MediaSession,\n                playerCommands: Player.Commands,\n                customLayout: ImmutableList<CommandButton>,\n                showPlaying: Boolean\n            ): ImmutableList<CommandButton> {\n                val builder = ImmutableList.builder<CommandButton>()\n                val player = session.player\n\n                // Previous\n                builder.add(\n                    CommandButton.Builder(CommandButton.ICON_UNDEFINED)\n                        .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)\n                        .setCustomIconResId(R.drawable.outline_skip_previous_24)\n                        .setDisplayName(\"Previous\")\n                        .setEnabled(playerCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS))\n                        .build()\n                )\n\n                // Play/Pause\n                if (showPlaying) {\n                    builder.add(\n                        CommandButton.Builder(CommandButton.ICON_UNDEFINED)\n                            .setPlayerCommand(Player.COMMAND_PLAY_PAUSE)\n                            .setCustomIconResId(R.drawable.outline_pause_24)\n                            .setDisplayName(\"Pause\")\n                            .setEnabled(playerCommands.contains(Player.COMMAND_PLAY_PAUSE))\n                            .build()\n                    )\n                } else {\n                    builder.add(\n                        CommandButton.Builder(CommandButton.ICON_UNDEFINED)\n                            .setPlayerCommand(Player.COMMAND_PLAY_PAUSE)\n                            .setCustomIconResId(R.drawable.outline_play_arrow_24)\n                            .setDisplayName(\"Play\")\n                            .setEnabled(playerCommands.contains(Player.COMMAND_PLAY_PAUSE))\n                            .build()\n                    )\n                }\n\n                // Next\n                builder.add(\n                    CommandButton.Builder(CommandButton.ICON_UNDEFINED)\n                        .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)\n                        .setCustomIconResId(R.drawable.outline_skip_next_24)\n                        .setDisplayName(\"Next\")\n                        .setEnabled(playerCommands.contains(Player.COMMAND_SEEK_TO_NEXT))\n                        .build()\n                )\n\n                // Repeat Mode Toggle\n                val repeatIcon = when (player.repeatMode) {\n                    Player.REPEAT_MODE_ONE -> R.drawable.outline_repeat_one_24\n                    Player.REPEAT_MODE_ALL -> R.drawable.outline_repeat_24\n                    else -> R.drawable.outline_repeat_off_24\n                }\n\n                builder.add(\n                    CommandButton.Builder(CommandButton.ICON_UNDEFINED)\n                        .setSessionCommand(\n                            SessionCommand(\n                                CustomCommands.CMD_TOGGLE_REPEAT_MODE,\n                                Bundle.EMPTY\n                            )\n                        )\n                        .setCustomIconResId(repeatIcon)\n                        .setDisplayName(\"Repeat Mode\")\n                        .setEnabled(true)\n                        .build()\n                )\n\n                return builder.build()\n            }\n        })\n\n        initializePlayer()\n    }\n\n    /**\n     * 创建/重建 ExoPlayer、MediaSession 及相关组件。\n     * 可多次调用，每次调用前应确保旧 player 已释放或为 null。\n     */\n    @OptIn(UnstableApi::class)\n    private fun initializePlayer() {\n        val dataSourceFactory = DownloadUtil.getPlayerDataSourceFactory(this)\n\n        val mediaSourceFactory = DefaultMediaSourceFactory(this)\n            .setDataSourceFactory(dataSourceFactory)\n\n        val renderersFactory = DefaultRenderersFactory(this)\n            .experimentalSetMediaCodecAsyncCryptoFlagEnabled(false)\n\n        player = ExoPlayer.Builder(this, renderersFactory)\n            .setMediaSourceFactory(mediaSourceFactory)\n            .setAudioAttributes(\n                AudioAttributes.Builder()\n                    .setUsage(C.USAGE_MEDIA)\n                    .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)\n                    .build(),\n                true\n            )\n            .setHandleAudioBecomingNoisy(true)\n            .build()\n\n        shuffleManager = ShuffleManager { player }\n\n        floatingLyricsManager = FloatingLyricsManager(this, player)\n        floatingLyricsManager.onClearLyricsRequested = { trackId ->\n            lyricEventListeners.forEach { it.onLyricCleared(trackId) }\n            sendRequestClearLyricsEvent(trackId)\n        }\n        if (GeneralStorage.isDesktopLyricsShown()) {\n            serviceHandler.post { floatingLyricsManager.show() }\n        }\n\n        statusBarLyricsManager = StatusBarLyricsManager(this)\n        statusBarLyricsManager.backend = createStatusBarBackend(GeneralStorage.getStatusBarLyricsProvider())\n        statusBarLyricsManager.enabled = GeneralStorage.isStatusBarLyricsEnabled()\n        lyricsManager = UnifiedLyricsManager(\n            floatingLyricsManager = floatingLyricsManager,\n            statusBarLyricsManager = statusBarLyricsManager,\n            currentPlaybackSeconds = { player?.currentPosition?.toDouble()?.div(1000.0) },\n            onCarLyricsChanged = ::updateCurrentMetadata,\n        )\n\n        setupListeners()\n\n        var launchIntent = packageManager.getLaunchIntentForPackage(packageName)\n\n        if (launchIntent == null) {\n            launchIntent = Intent().apply {\n                setClassName(packageName, \"$packageName.MainActivity\")\n            }\n        }\n\n        launchIntent.apply {\n            action = Intent.ACTION_VIEW\n            data = \"orpheus://player\".toUri()\n            addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)\n        }\n\n        val contentIntent = launchIntent.let {\n            PendingIntent.getActivity(\n                this,\n                0,\n                it,\n                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT\n            )\n        }\n\n        mediaSession = MediaLibrarySession.Builder(this, player!!, callback)\n            .setId(\"OrpheusSession\")\n            .setSessionActivity(contentIntent)\n            .setBitmapLoader(GlideBitmapLoader(this))\n            .build()\n\n        restorePlayerState(GeneralStorage.isRestoreEnabled())\n        sleepTimerManager = SleepTimeController(player!!)\n    }\n\n    /**\n     * 检查 player 是否存在，不存在则重建。\n     * 供外部（如 ExpoOrpheusModule）调用。\n     */\n    @OptIn(UnstableApi::class)\n    fun ensurePlayer(): ExoPlayer {\n        if (player == null) {\n            Log.w(\"OrpheusMusicService\", \"Player was null, reinitializing...\")\n            initializePlayer()\n        }\n        return player!!\n    }\n\n    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? {\n        return mediaSession\n    }\n\n    override fun onDestroy() {\n        serviceHandler.removeCallbacks(lyricsUpdateRunnable)\n        floatingLyricsManager.hide()\n        statusBarLyricsManager.onStop()\n        scope.cancel()\n        instance = null\n\n        mediaSession?.run {\n            player.release()\n            release()\n            mediaSession = null\n        }\n        this.player = null\n        super.onDestroy()\n    }\n\n    /**\n     * Enable or disable shuffle mode. Delegates to ShuffleManager which uses\n     * Media3's built-in shuffleModeEnabled flag for zero-cost queue traversal.\n     */\n    fun applyShuffleMode(enabled: Boolean) {\n        shuffleManager.setShuffleEnabled(enabled)\n    }\n\n    fun startSleepTimer(durationMs: Long) {\n        sleepTimerManager?.start(durationMs)\n    }\n\n    fun cancelSleepTimer() {\n        sleepTimerManager?.cancel()\n    }\n\n    fun getSleepTimerRemaining(): Long? {\n        return sleepTimerManager?.getStopTimeMs()\n    }\n\n    var callback: MediaLibrarySession.Callback = @UnstableApi\n    object : MediaLibrarySession.Callback {\n\n        @OptIn(UnstableApi::class)\n        override fun onConnect(\n            session: MediaSession,\n            controller: MediaSession.ControllerInfo\n        ): MediaSession.ConnectionResult {\n            val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()\n                .add(SessionCommand(CustomCommands.CMD_TOGGLE_REPEAT_MODE, Bundle.EMPTY))\n                .build()\n\n            return MediaSession.ConnectionResult.AcceptedResultBuilder(session)\n                .setAvailableSessionCommands(sessionCommands)\n                .build()\n        }\n\n        @OptIn(UnstableApi::class)\n        override fun onCustomCommand(\n            session: MediaSession,\n            controller: MediaSession.ControllerInfo,\n            customCommand: SessionCommand,\n            args: Bundle\n        ): ListenableFuture<SessionResult> {\n            if (customCommand.customAction == CustomCommands.CMD_TOGGLE_REPEAT_MODE) {\n                val player = session.player\n                val newMode = when (player.repeatMode) {\n                    Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL\n                    Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE\n                    Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF\n                    else -> Player.REPEAT_MODE_OFF\n                }\n                player.repeatMode = newMode\n                return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))\n            }\n            return super.onCustomCommand(session, controller, customCommand, args)\n        }\n\n        override fun onPlaybackResumption(\n            mediaSession: MediaSession,\n            controller: MediaSession.ControllerInfo,\n            isPlayback: Boolean\n        ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {\n            return Futures.immediateFuture(\n                MediaSession.MediaItemsWithStartPosition(\n                    emptyList(), // 没有媒体项\n                    C.INDEX_UNSET, // 索引未定\n                    C.TIME_UNSET   // 进度未定\n                )\n            )\n        }\n    }\n\n    @OptIn(UnstableApi::class)\n    private fun restorePlayerState(restorePosition: Boolean) {\n        val player = player ?: return\n\n        val restoredItems = GeneralStorage.restoreQueue(this)\n\n        if (restoredItems.isNotEmpty()) {\n            player.setMediaItems(restoredItems)\n\n            val savedIndex = GeneralStorage.getSavedIndex()\n            val savedPosition = GeneralStorage.getSavedPosition()\n            val savedShuffleMode = GeneralStorage.getShuffleMode()\n            val savedRepeatMode = GeneralStorage.getRepeatMode()\n\n            if (savedIndex >= 0 && savedIndex < restoredItems.size) {\n                player.seekTo(savedIndex, if (restorePosition) savedPosition else C.TIME_UNSET)\n            } else {\n                player.seekTo(0, 0L)\n            }\n\n            // Restore shuffle state without re-shuffling the saved queue order\n            shuffleManager.restoreShuffleEnabled(savedShuffleMode)\n            player.repeatMode = savedRepeatMode\n\n            currentMediaId = player.currentMediaItem?.mediaId\n\n            player.playWhenReady = GeneralStorage.isAutoplayOnStartEnabled()\n            player.prepare()\n\n            // 软件冷启动时，恢复的歌曲并不会触发 onMediaTransition 事件，我们需要手动补发一个\n            if (player.currentMediaItem != null) {\n                sendTrackStartEvent(\n                    player.currentMediaItem,\n                    Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED\n                )\n            }\n        }\n    }\n\n    interface TrackEventListener {\n        fun onTrackStarted(trackId: String, reason: Int)\n        fun onTrackFinished(trackId: String, finalPosition: Double, duration: Double)\n    }\n\n    interface LyricEventListener {\n        fun onLyricCleared(trackId: String)\n    }\n\n    private val trackEventListeners = CopyOnWriteArrayList<TrackEventListener>()\n    private val lyricEventListeners = CopyOnWriteArrayList<LyricEventListener>()\n\n    fun addTrackEventListener(listener: TrackEventListener) {\n        trackEventListeners.add(listener)\n    }\n\n    fun removeTrackEventListener(listener: TrackEventListener) {\n        trackEventListeners.remove(listener)\n    }\n\n    fun addLyricEventListener(listener: LyricEventListener) {\n        lyricEventListeners.add(listener)\n    }\n\n    fun removeLyricEventListener(listener: LyricEventListener) {\n        lyricEventListeners.remove(listener)\n    }\n\n    @OptIn(UnstableApi::class)\n    private fun sendTrackStartEvent(mediaItem: androidx.media3.common.MediaItem?, reason: Int) {\n        if (mediaItem == null) return\n\n        // Notify local listeners\n        trackEventListeners.forEach { it.onTrackStarted(mediaItem.mediaId, reason) }\n\n        try {\n            val intent = Intent(this, OrpheusHeadlessTaskService::class.java)\n            intent.putExtra(\"eventName\", \"onTrackStarted\")\n            intent.putExtra(\"trackId\", mediaItem.mediaId)\n            intent.putExtra(\"reason\", reason)\n            startService(intent)\n        } catch (e: Exception) {\n            e.printStackTrace()\n        }\n    }\n\n    private fun sendTrackFinishedEvent(trackId: String, finalPosition: Double, duration: Double) {\n        // Notify local listeners\n        trackEventListeners.forEach { it.onTrackFinished(trackId, finalPosition, duration) }\n\n        try {\n            val intent = Intent(this, OrpheusHeadlessTaskService::class.java)\n            intent.putExtra(\"eventName\", \"onTrackFinished\")\n            intent.putExtra(\"trackId\", trackId)\n            intent.putExtra(\"finalPosition\", finalPosition)\n            intent.putExtra(\"duration\", duration)\n            startService(intent)\n        } catch (e: Exception) {\n            e.printStackTrace()\n        }\n    }\n\n    private fun sendTrackPausedEvent() {\n        try {\n            val intent = Intent(this, OrpheusHeadlessTaskService::class.java)\n            intent.putExtra(\"eventName\", \"onTrackPaused\")\n            startService(intent)\n        } catch (e: Exception) {\n            e.printStackTrace()\n        }\n    }\n\n    private fun sendTrackResumedEvent() {\n        try {\n            val intent = Intent(this, OrpheusHeadlessTaskService::class.java)\n            intent.putExtra(\"eventName\", \"onTrackResumed\")\n            startService(intent)\n        } catch (e: Exception) {\n            e.printStackTrace()\n        }\n    }\n\n    private fun sendRequestClearLyricsEvent(trackId: String) {\n        try {\n            val intent = Intent(this, OrpheusHeadlessTaskService::class.java)\n            intent.putExtra(\"eventName\", \"onRequestClearLyrics\")\n            intent.putExtra(\"trackId\", trackId)\n            startService(intent)\n        } catch (e: Exception) {\n            e.printStackTrace()\n        }\n    }\n\n    private fun setupListeners() {\n        player?.addListener(object : Player.Listener {\n            override fun onIsPlayingChanged(isPlaying: Boolean) {\n                android.util.Log.d(\"StatusBarLyrics\", \"[Service] onIsPlayingChanged: $isPlaying | state=${player?.playbackState} mediaId=${player?.currentMediaItem?.mediaId}\")\n                lyricsManager.setPlaybackState(isPlaying)\n                if (isPlaying) {\n                    serviceHandler.removeCallbacks(lyricsUpdateRunnable)\n                    serviceHandler.post(lyricsUpdateRunnable)\n                    sendTrackResumedEvent()\n                } else {\n                    serviceHandler.removeCallbacks(lyricsUpdateRunnable)\n                    sendTrackPausedEvent()\n                }\n            }\n\n            @OptIn(UnstableApi::class)\n            override fun onMediaItemTransition(\n                mediaItem: androidx.media3.common.MediaItem?,\n                reason: Int\n            ) {\n                val mediaId = mediaItem?.mediaId\n                val reasonStr = when (reason) {\n                    Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> \"AUTO\"\n                    Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> \"SEEK\"\n                    Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> \"PLAYLIST_CHANGED\"\n                    Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> \"REPEAT\"\n                    else -> \"UNKNOWN($reason)\"\n                }\n                android.util.Log.d(\"StatusBarLyrics\", \"[Service] onMediaItemTransition: id=$mediaId reason=$reasonStr ts=${System.currentTimeMillis()}\")\n\n                // If the same track is still current (e.g. an item was added/removed elsewhere\n                // in the queue causing a PLAYLIST_CHANGED transition with the same media ID),\n                // we should NOT reset lyrics or notify JS as it causes a UI flash and audio stutter.\n                if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && mediaId == currentMediaId) {\n                    Log.d(\"OrpheusMusicService\", \"Ignoring onMediaItemTransition as track hasn't changed.\")\n                    saveCurrentQueue()\n                    return\n                }\n                currentMediaId = mediaId\n\n                sendTrackStartEvent(mediaItem, reason)\n\n                lyricsManager.clearConsumers(LyricsConsumer.all())\n                floatingLyricsManager.syncTrackInfo()\n\n                saveCurrentQueue()\n                val uri = mediaItem?.localConfiguration?.uri?.toString() ?: return\n\n                val volumeData = LoudnessStorage.getLoudnessData(uri)\n                applyVolumeForCurrentItem(volumeData)\n            }\n\n            override fun onTimelineChanged(timeline: Timeline, reason: Int) {\n                saveCurrentQueue()\n                val player = player ?: return\n                val currentItem = player.currentMediaItem ?: return\n                val duration = player.duration\n                if (duration != C.TIME_UNSET && duration > 0) {\n                    durationCache[currentItem.mediaId] = duration\n                }\n            }\n\n            @OptIn(UnstableApi::class)\n            override fun onPositionDiscontinuity(\n                oldPosition: Player.PositionInfo,\n                newPosition: Player.PositionInfo,\n                reason: Int\n            ) {\n                val isAutoTransition = reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION\n                val isIndexChanged = oldPosition.mediaItemIndex != newPosition.mediaItemIndex\n                val lastMediaItem = oldPosition.mediaItem ?: return\n                val currentTime = System.currentTimeMillis()\n\n                // Debounce\n                if ((currentTime - lastTrackFinishedAt) < 200) {\n                    return\n                }\n\n                if (isAutoTransition || isIndexChanged) {\n                    val duration = durationCache[lastMediaItem.mediaId] ?: return\n                    lastTrackFinishedAt = currentTime\n\n                    sendTrackFinishedEvent(\n                        lastMediaItem.mediaId,\n                        oldPosition.positionMs / 1000.0,\n                        duration / 1000.0\n                    )\n                }\n            }\n        })\n    }\n\n    private fun saveCurrentQueue() {\n        val player = player ?: return\n        val queue = List(player.mediaItemCount) { i -> player.getMediaItemAt(i) }\n        if (queue.isNotEmpty()) {\n            GeneralStorage.saveQueue(queue)\n        }\n    }\n\n    fun createStatusBarBackend(provider: String): expo.modules.orpheus.manager.StatusBarLyricsBackend {\n        return if (provider == \"lyricon\" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O_MR1) {\n            LyriconBackend(this)\n        } else {\n            SuperLyricBackend(this)\n        }\n    }\n\n    fun setCarLyricsEnabled(enabled: Boolean) {\n        lyricsManager.setCarLyricsEnabled(enabled)\n    }\n\n    fun setCarLyrics(lyrics: List<LyricsLine>, offset: Double = 0.0) {\n        lyricsManager.submitLyrics(\n            LyricsData(lyrics = lyrics, offset = offset),\n            setOf(LyricsConsumer.CAR),\n        )\n    }\n\n    fun clearCarLyrics() {\n        lyricsManager.clearConsumers(setOf(LyricsConsumer.CAR))\n    }\n\n    private fun updateCurrentMetadata(currentLyric: String?) {\n        val player = player ?: return\n        val currentIndex = player.currentMediaItemIndex\n        if (currentIndex == C.INDEX_UNSET || currentIndex >= player.mediaItemCount) return\n\n        val currentItem = player.getMediaItemAt(currentIndex)\n        val updatedMetadata = buildPlaybackMetadata(currentItem, currentLyric)\n        if (currentItem.mediaMetadata == updatedMetadata) return\n\n        val updatedItem = currentItem.buildUpon()\n            .setMediaMetadata(updatedMetadata)\n            .build()\n\n        // Lyric-only metadata refresh should not trigger extra queue persistence.\n        player.replaceMediaItem(currentIndex, updatedItem)\n    }\n\n    private fun buildPlaybackMetadata(item: MediaItem, currentLyric: String?): MediaMetadata {\n        val originalTrack = extractTrackRecord(item)\n        val baseTitle = originalTrack?.title ?: item.mediaMetadata.title?.toString().orEmpty()\n        val baseArtist = originalTrack?.artist ?: item.mediaMetadata.artist?.toString().orEmpty()\n        val displayArtist = listOf(baseTitle, baseArtist)\n            .filter { it.isNotBlank() }\n            .joinToString(\" - \")\n\n        return MediaMetadata.Builder()\n            .setTitle(currentLyric?.takeIf { it.isNotBlank() } ?: baseTitle)\n            .setArtist(\n                if (currentLyric.isNullOrBlank()) {\n                    baseArtist\n                } else {\n                    displayArtist\n                }\n            )\n            .setArtworkUri(item.mediaMetadata.artworkUri)\n            .setExtras(item.mediaMetadata.extras)\n            .build()\n    }\n\n    private fun extractTrackRecord(item: MediaItem): TrackRecord? {\n        val trackJson = item.mediaMetadata.extras?.getString(\"track_json\") ?: return null\n        return try {\n            json.decodeFromString<TrackRecord>(trackJson)\n        } catch (_: Exception) {\n            null\n        }\n    }\n\n    @OptIn(UnstableApi::class)\n    private fun applyVolumeForCurrentItem(measuredI: Double) {\n        Log.d(\"LoudnessNormalization\", \"measuredI: $measuredI\")\n        val player = player ?: return\n        volumeFadeJob?.cancel()\n        val isLoudnessNormalizationEnabled = GeneralStorage.isLoudnessNormalizationEnabled()\n        if (!isLoudnessNormalizationEnabled) return\n        val gain = run {\n            val target = -14.0 // bilibili 的这个值似乎是固定的\n            if (measuredI == 0.0) 1.0f else calculateLoudnessGain(measuredI, target)\n        }\n\n        val targetVol = 1.0f * gain\n        val currentVolume = player.volume\n\n        if (abs(currentVolume - targetVol) < 0.001f) {\n            return\n        }\n\n        volumeFadeJob = player.fadeInTo(targetVol, 600L, scope)\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/service/ShuffleManager.kt",
    "content": "package expo.modules.orpheus.service\n\nimport android.util.Log\nimport androidx.annotation.OptIn\nimport androidx.media3.common.C\nimport androidx.media3.common.Player\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder\nimport androidx.media3.exoplayer.ExoPlayer\nimport expo.modules.orpheus.util.GeneralStorage\n\n/**\n * Manages shuffle mode for Orpheus using Media3's built-in shuffle functionality.\n *\n * Instead of physically reordering the MediaItem list (which is O(n²) with moveMediaItem\n * and causes severe performance issues on large queues), this class delegates shuffle\n * traversal to Media3's internal ShuffleOrder via player.shuffleModeEnabled.\n *\n * We also maintain full control over the shuffle traversal order by explicitly calling\n * player.setShuffleOrder(DefaultShuffleOrder(...)) so that:\n *  - getTraversalOrder() can return the exact logical playback sequence to the UI.\n *  - repositionAsNext() can guarantee that a specific track plays immediately after\n *    the current one, regardless of where it sits in the physical queue.\n *\n * Behaviour:\n *  - On enable: generates a random permutation (current item first) and sets it via\n *    setShuffleOrder; then sets shuffleModeEnabled = true.\n *  - On disable: sets shuffleModeEnabled = false. Physical queue order is never touched.\n *  - getTraversalOrder(): reads the live shuffle traversal from the timeline (O(n)).\n *  - repositionAsNext(physicalIdx): moves physicalIdx to the position right after the\n *    current track in the shuffle traversal, then calls setShuffleOrder to persist it.\n */\n@OptIn(UnstableApi::class)\nclass ShuffleManager(private val getPlayer: () -> ExoPlayer?) {\n\n    private var isShuffleEnabled = false\n\n    val isEnabled: Boolean get() = isShuffleEnabled\n\n    /**\n     * Enable or disable shuffle mode.\n     * Call this from the main thread.\n     */\n    fun setShuffleEnabled(enabled: Boolean) {\n        val player = getPlayer() ?: return\n        isShuffleEnabled = enabled\n        GeneralStorage.saveShuffleMode(enabled)\n\n        if (enabled) {\n            val count = player.mediaItemCount\n            val currentPhysical = player.currentMediaItemIndex\n            // Build a shuffled order with the current item first so it isn't skipped.\n            val others = (0 until count).filter { it != currentPhysical }.shuffled()\n            val order = (listOf(currentPhysical) + others).toIntArray()\n            player.setShuffleOrder(DefaultShuffleOrder(order, System.currentTimeMillis()))\n        }\n\n        player.shuffleModeEnabled = enabled\n        Log.d(\"ShuffleManager\", \"Shuffle mode set to: $enabled\")\n    }\n\n    /**\n     * Restores the shuffle-enabled flag on cold-start without regenerating the order.\n     * Media3 will create a fresh random ShuffleOrder for the restored items.\n     */\n    fun restoreShuffleEnabled(enabled: Boolean) {\n        isShuffleEnabled = enabled\n        getPlayer()?.shuffleModeEnabled = enabled\n    }\n\n    /**\n     * Returns the full shuffle traversal as an array of physical indices.\n     * E.g. [3, 1, 0, 2] means: play physical-item-3 first, then 1, then 0, then 2.\n     * Returns null when shuffle is disabled or the player is unavailable.\n     */\n    fun getTraversalOrder(): IntArray? {\n        if (!isShuffleEnabled) return null\n        val player = getPlayer() ?: return null\n        val count = player.mediaItemCount\n        if (count == 0) return IntArray(0)\n\n        val timeline = player.currentTimeline\n        val result = mutableListOf<Int>()\n        var idx = timeline.getFirstWindowIndex(true)\n        while (idx != C.INDEX_UNSET) {\n            result.add(idx)\n            // Use REPEAT_MODE_OFF so we traverse each item exactly once (no infinite loop).\n            idx = timeline.getNextWindowIndex(idx, Player.REPEAT_MODE_OFF, true)\n        }\n\n        // Safety: if traversal doesn't cover all items, fall back to physical order.\n        if (result.size != count) {\n            Log.w(\"ShuffleManager\", \"Traversal size mismatch: got ${result.size}, expected $count\")\n            return (0 until count).toList().toIntArray()\n        }\n\n        return result.toIntArray()\n    }\n\n    /**\n     * Moves the item at [insertedPhysicalIndex] to play immediately after the current\n     * track in the shuffle traversal, then commits the new order via setShuffleOrder.\n     *\n     * Call this AFTER the item has been added to (or moved within) the player queue.\n     */\n    fun repositionAsNext(insertedPhysicalIndex: Int) {\n        if (!isShuffleEnabled) return\n        val player = getPlayer() ?: return\n        val currentPhysical = player.currentMediaItemIndex\n\n        // Read the live traversal (includes the newly inserted item at a random position).\n        val order = getTraversalOrder()?.toMutableList() ?: return\n\n        // Remove the item from wherever Media3 placed it.\n        order.remove(insertedPhysicalIndex)\n\n        // Insert it right after the current track's traversal position.\n        val currentPos = order.indexOf(currentPhysical)\n        if (currentPos == -1) {\n            // The current item should always appear in the traversal. If it doesn't,\n            // the shuffle state is inconsistent — bail out rather than silently appending.\n            Log.e(\"ShuffleManager\", \"Current physical[$currentPhysical] not found in traversal; skipping repositionAsNext\")\n            return\n        }\n        order.add(currentPos + 1, insertedPhysicalIndex)\n\n        player.setShuffleOrder(DefaultShuffleOrder(order.toIntArray(), System.currentTimeMillis()))\n        Log.d(\"ShuffleManager\", \"Repositioned physical[$insertedPhysicalIndex] as next in shuffle order\")\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/ConvertPlayerError.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.util.Log\nimport androidx.media3.common.PlaybackException\n\nfun PlaybackException.toJsMap(): Map<String, Any?> {\n    var rootCause: Throwable? = this\n    while (rootCause?.cause != null) {\n        rootCause = rootCause.cause\n    }\n\n    return mapOf(\n        \"errorCode\" to errorCode,\n        \"errorCodeName\" to errorCodeName,\n        \"timestamp\" to System.currentTimeMillis().toString(),\n        \"message\" to message,\n        \"stackTrace\" to Log.getStackTraceString(this),\n        \"rootCauseClass\" to (rootCause?.javaClass?.name ?: \"Unknown\"),\n        \"rootCauseMessage\" to (rootCause?.message ?: \"\")\n    )\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/CustomCommands.kt",
    "content": "package expo.modules.orpheus.util\n\nobject CustomCommands {\n    const val CMD_TOGGLE_REPEAT_MODE = \"cmd_toggle_repeat_mode\"\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/DirectoryPickerContract.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.app.Activity\nimport android.content.Context\nimport android.content.Intent\nimport android.net.Uri\nimport expo.modules.kotlin.activityresult.AppContextActivityResultContract\n\n/**\n * SAF directory picker contract for expo-modules-core's RegisterActivityContracts API.\n *\n * Input:  ignored (pass empty string \"\")\n * Output: URI string of the selected directory, or null if cancelled / error\n */\nclass DirectoryPickerContract : AppContextActivityResultContract<String, String?> {\n    override fun createIntent(context: Context, input: String): Intent =\n        Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {\n            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)\n        }\n\n    override fun parseResult(input: String, resultCode: Int, intent: Intent?): String? {\n        if (resultCode != Activity.RESULT_OK || intent == null) return null\n        return intent.data?.toString()\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/DownloadUtil.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.core.net.toUri\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.database.StandaloneDatabaseProvider\nimport androidx.media3.datasource.DataSource\nimport androidx.media3.datasource.DataSpec\nimport androidx.media3.datasource.ResolvingDataSource\nimport androidx.media3.datasource.cache.CacheDataSource\nimport androidx.media3.exoplayer.offline.DownloadManager\nimport androidx.media3.exoplayer.offline.DownloadNotificationHelper\nimport androidx.media3.exoplayer.scheduler.Requirements\nimport expo.modules.orpheus.OrpheusConfig\nimport expo.modules.orpheus.bilibili.BilibiliRepository\nimport expo.modules.orpheus.manager.DownloadCache\nimport expo.modules.orpheus.service.OrpheusDownloadService\nimport java.io.IOException\nimport java.util.concurrent.Executors\n\n@UnstableApi\nobject DownloadUtil {\n    private const val DEFAULT_MAX_PARALLEL_DOWNLOADS = 1\n    private const val MIN_MAX_PARALLEL_DOWNLOADS = 1\n    private const val MAX_MAX_PARALLEL_DOWNLOADS = 6\n\n    private var downloadManager: DownloadManager? = null\n    private var maxParallelDownloads = DEFAULT_MAX_PARALLEL_DOWNLOADS\n\n    private var playerDataSourceFactory: DataSource.Factory? = null\n    private var downloadDataSourceFactory: DataSource.Factory? = null\n\n    private var downloadNotificationHelper: DownloadNotificationHelper? = null\n\n    @Synchronized\n    fun getDownloadManager(context: Context): DownloadManager {\n        if (downloadManager == null) {\n            val databaseProvider = StandaloneDatabaseProvider(context)\n            downloadManager = DownloadManager(\n                context,\n                databaseProvider,\n                DownloadCache.getStableCache(context),\n                getDownloadDataSourceFactory(),\n                Executors.newFixedThreadPool(6)\n            ).apply {\n                maxParallelDownloads = this@DownloadUtil.maxParallelDownloads\n                requirements = Requirements(0)\n            }\n        }\n        return downloadManager!!\n    }\n\n    @Synchronized\n    fun setMaxParallelDownloads(context: Context, value: Int) {\n        maxParallelDownloads = value.coerceIn(\n            MIN_MAX_PARALLEL_DOWNLOADS,\n            MAX_MAX_PARALLEL_DOWNLOADS\n        )\n        getDownloadManager(context).maxParallelDownloads = maxParallelDownloads\n    }\n\n    @Synchronized\n    fun getPlayerDataSourceFactory(context: Context): DataSource.Factory {\n        if (playerDataSourceFactory == null) {\n            val upstreamFactory = getUpstreamFactory()\n\n            val downloadCache = DownloadCache.getStableCache(context)\n            val lruCache = DownloadCache.getLruCache(context)\n\n            val cacheFactory = CacheDataSource.Factory()\n                .setCache(lruCache)\n                .setUpstreamDataSourceFactory(upstreamFactory)\n                .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)\n\n            val downloadFactory = CacheDataSource.Factory()\n                .setCache(downloadCache)\n                .setUpstreamDataSourceFactory(cacheFactory)\n                .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)\n                .setCacheWriteDataSinkFactory(null)\n\n            playerDataSourceFactory = downloadFactory\n        }\n        return playerDataSourceFactory!!\n    }\n\n\n    @Synchronized\n    private fun getDownloadDataSourceFactory(): DataSource.Factory {\n        if (downloadDataSourceFactory == null) {\n            downloadDataSourceFactory = getUpstreamFactory()\n        }\n        return downloadDataSourceFactory!!\n    }\n\n    private fun getUpstreamFactory(): DataSource.Factory {\n        val httpDataSourceFactory = androidx.media3.datasource.okhttp.OkHttpDataSource.Factory(\n            expo.modules.orpheus.network.OkHttpClientManager.okHttpClient\n        )\n            .setUserAgent(\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36\")\n\n        return ResolvingDataSource.Factory(\n            httpDataSourceFactory,\n            BilibiliResolver()\n        )\n    }\n\n    @Synchronized\n    fun getDownloadNotificationHelper(context: Context): DownloadNotificationHelper {\n        if (downloadNotificationHelper == null) {\n            downloadNotificationHelper =\n                DownloadNotificationHelper(context, OrpheusDownloadService.CHANNEL_ID)\n        }\n        return downloadNotificationHelper!!\n    }\n\n    fun getReadOnlyCacheDataSource(context: Context): CacheDataSource {\n        val cache = DownloadCache.getStableCache(context)\n        return CacheDataSource.Factory()\n            .setCache(cache)\n            .setUpstreamDataSourceFactory(null) // No upstream, only cache\n            .setCacheReadDataSourceFactory(androidx.media3.datasource.FileDataSource.Factory())\n            .setFlags(CacheDataSource.FLAG_BLOCK_ON_CACHE)\n            .createDataSource()\n    }\n\n    private class BilibiliResolver :\n        ResolvingDataSource.Resolver {\n        override fun resolveDataSpec(dataSpec: DataSpec): DataSpec {\n            val uri = dataSpec.uri\n            if (uri.scheme == \"orpheus\" && uri.host == \"bilibili\") {\n                try {\n                    val bvid = uri.getQueryParameter(\"bvid\")\n                    val cid = uri.getQueryParameter(\"cid\")?.toLongOrNull()\n                    val quality = uri.getQueryParameter(\"quality\")?.toIntOrNull() ?: 30280\n                    val (realUrl, volume) = BilibiliRepository.resolveAudioUrl(\n                        bvid = bvid!!,\n                        cid = cid,\n                        audioQuality = quality,\n                        enableDolby = uri.getQueryParameter(\"dolby\") == \"1\",\n                        enableHiRes = uri.getQueryParameter(\"hires\") == \"1\",\n                        cookie = OrpheusConfig.bilibiliCookie\n                    )\n                    // 在这里保存响度均衡数据，并且直接发一个事件，在 OrpheusMusicService 监听\n                    if (volume !== null) {\n                        Log.d(\n                            \"LoudnessNormalization\",\n                            \"uri: ${dataSpec.uri}, measuredI: ${volume.measuredI}\"\n                        )\n                        LoudnessStorage.setLoudnessData(dataSpec.uri.toString(), volume.measuredI)\n                    }\n\n                    val headers = HashMap<String, String>()\n                    headers[\"Referer\"] = \"https://www.bilibili.com/\"\n\n                    return dataSpec.buildUpon()\n                        .setUri(realUrl.toUri())\n                        .setHttpRequestHeaders(headers)\n                        .setKey(uri.toString())\n                        .build()\n                } catch (e: Exception) {\n                    throw IOException(\"Resolve Url Failed: ${e.message}\", e)\n                }\n            }\n            return dataSpec\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/ExportDownloadsHelper.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.content.ContentValues\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.net.Uri\nimport android.os.Build\nimport android.os.Environment\nimport android.provider.MediaStore\nimport com.bumptech.glide.Glide\nimport com.bumptech.glide.load.resource.bitmap.CenterCrop\nimport com.bumptech.glide.request.RequestOptions\nimport android.util.Log\nimport androidx.documentfile.provider.DocumentFile\nimport androidx.media3.common.util.UnstableApi\nimport androidx.media3.datasource.DataSpec\nimport androidx.media3.datasource.cache.CacheDataSource\nimport androidx.media3.exoplayer.offline.Download\nimport androidx.media3.exoplayer.offline.DownloadIndex\nimport expo.modules.orpheus.manager.CoverDownloadManager\nimport expo.modules.orpheus.model.TrackRecord\nimport expo.modules.orpheus.model.LyricFileCache\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.launch\nimport kotlinx.serialization.json.Json\nimport org.jaudiotagger.audio.AudioFileIO\nimport org.jaudiotagger.tag.FieldKey\nimport org.jaudiotagger.tag.images.ArtworkFactory\nimport java.io.File\n\n\ndata class ExportOptions(\n    val filenamePattern: String?,\n    val embedLyrics: Boolean,\n    val convertToLrc: Boolean,\n    val cropCoverArt: Boolean = false,\n)\n\nprivate const val PUBLIC_MUSIC_DESTINATION_HOST = \"public-music\"\n\nprivate fun isPublicMusicDestination(uri: Uri): Boolean {\n    return uri.scheme == \"orpheus\" && uri.host == PUBLIC_MUSIC_DESTINATION_HOST\n}\n\n\n@UnstableApi\nfun runExportDownloads(\n    ids: List<String>,\n    destinationUri: String,\n    context: Context,\n    options: ExportOptions,\n    json: Json,\n    ioScope: CoroutineScope,\n    sendEvent: (name: String, payload: Map<String, Any?>) -> Unit,\n) {\n    val downloadManager = DownloadUtil.getDownloadManager(context)\n    val downloadIndex = downloadManager.downloadIndex\n    val dataSource = DownloadUtil.getReadOnlyCacheDataSource(context)\n    val treeUri = Uri.parse(destinationUri)\n    val isPublicMusic = isPublicMusicDestination(treeUri)\n    val pickedDir =\n        if (isPublicMusic) {\n            null\n        } else {\n            DocumentFile.fromTreeUri(context, treeUri)\n        }\n\n    if (!isPublicMusic && (pickedDir == null || !pickedDir.canWrite())) {\n        Log.e(\"OrpheusExport\", \"Destination directory is not writable: $destinationUri\")\n        return\n    }\n\n    ioScope.launch {\n        val totalFiles = ids.size\n        ids.forEachIndexed { index, id ->\n            exportSingleItem(\n                id = id,\n                index = index,\n                totalFiles = totalFiles,\n                context = context,\n                downloadIndex = downloadIndex,\n                dataSource = dataSource,\n                pickedDir = pickedDir,\n                isPublicMusic = isPublicMusic,\n                options = options,\n                json = json,\n                sendEvent = sendEvent,\n            )\n        }\n    }\n}\n\n\n@UnstableApi\nprivate suspend fun exportSingleItem(\n    id: String,\n    index: Int,\n    totalFiles: Int,\n    context: Context,\n    downloadIndex: DownloadIndex,\n    dataSource: CacheDataSource,\n    pickedDir: DocumentFile?,\n    isPublicMusic: Boolean,\n    options: ExportOptions,\n    json: Json,\n    sendEvent: (name: String, payload: Map<String, Any?>) -> Unit,\n) {\n    var tempM4a: File? = null\n    try {\n        val download = downloadIndex.getDownload(id)\n        if (download == null || download.state != Download.STATE_COMPLETED) {\n            sendEvent(\n                \"onExportProgress\", mapOf(\n                    \"currentId\" to id,\n                    \"status\" to \"error\",\n                    \"message\" to \"Download not found or not completed\",\n                )\n            )\n            return\n        }\n\n        // 1. 将缓存数据直接写入临时 m4a 文件（m4s 与 m4a 同为 ISOBMFF 容器，无需转码）\n        tempM4a = File(context.cacheDir, \"$id.m4a\")\n        if (tempM4a.exists()) tempM4a.delete()\n        val dataSpec = DataSpec(download.request.uri)\n        try {\n            dataSource.open(dataSpec)\n            tempM4a.outputStream().use { outputStream ->\n                val buffer = ByteArray(64 * 1024)\n                var bytesRead: Int\n                while (dataSource.read(buffer, 0, buffer.size).also { bytesRead = it } != -1) {\n                    outputStream.write(buffer, 0, bytesRead)\n                }\n            }\n        } finally {\n            dataSource.close()\n        }\n\n        // 2. 提前解码 TrackRecord（用于文件名，不依赖元数据写入是否成功）\n        val track: TrackRecord? = download.request.data\n            .takeIf { it.isNotEmpty() }\n            ?.let { runCatching { json.decodeFromString<TrackRecord>(String(it)) }.getOrNull() }\n\n        // 3. 写入元数据（Title / Artist / Cover / Lyrics）\n        writeMetadata(\n            id = id,\n            tempM4a = tempM4a,\n            track = track,\n            context = context,\n            options = options,\n            json = json,\n        )\n\n        // 4. 拷贝到 SAF 目标路径\n        val fileName = buildFileName(id, download, track, options.filenamePattern)\n        writeExportedFile(\n            context = context,\n            tempM4a = tempM4a,\n            fileName = fileName,\n            pickedDir = pickedDir,\n            isPublicMusic = isPublicMusic,\n        )\n\n        sendEvent(\n            \"onExportProgress\", mapOf(\n                \"progress\" to (index + 1).toDouble() / totalFiles,\n                \"currentId\" to id,\n                \"index\" to index + 1,\n                \"total\" to totalFiles,\n                \"status\" to \"success\",\n            )\n        )\n    } catch (e: Exception) {\n        Log.e(\"OrpheusExport\", \"Failed to export $id: ${e.message}\")\n        sendEvent(\n            \"onExportProgress\", mapOf(\n                \"currentId\" to id,\n                \"index\" to index + 1,\n                \"total\" to totalFiles,\n                \"status\" to \"error\",\n                \"message\" to e.message,\n            )\n        )\n    } finally {\n        tempM4a?.delete()\n    }\n}\n\nprivate fun writeExportedFile(\n    context: Context,\n    tempM4a: File,\n    fileName: String,\n    pickedDir: DocumentFile?,\n    isPublicMusic: Boolean,\n) {\n    if (isPublicMusic) {\n        writeToPublicMusic(context, tempM4a, fileName)\n        return\n    }\n\n    val targetDir = pickedDir ?: throw Exception(\"Destination directory is not available\")\n    val newFile = targetDir.createFile(\"audio/mp4\", fileName) ?: throw Exception(\"Failed to create file $fileName in destination\")\n    try {\n        context.contentResolver.openOutputStream(newFile.uri)?.use { outputStream ->\n            tempM4a.inputStream().use { it.copyTo(outputStream) }\n        } ?: throw Exception(\"Failed to open output stream for $fileName\")\n    } catch (e: Exception) {\n        runCatching { newFile.delete() }\n        throw e\n    }\n}\n\nprivate fun writeToPublicMusic(\n    context: Context,\n    tempM4a: File,\n    fileName: String,\n) {\n    val resolver = context.contentResolver\n\n    // Pre-Q (API < 29): Use legacy external storage directory\n    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {\n        val musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)\n        val bbplayerDir = File(musicDir, \"BBPlayer\")\n        if (!bbplayerDir.exists()) {\n            bbplayerDir.mkdirs()\n        }\n\n        val targetFile = File(bbplayerDir, fileName)\n        try {\n            tempM4a.inputStream().use { input ->\n                targetFile.outputStream().use { output ->\n                    input.copyTo(output)\n                }\n            }\n        } catch (e: Exception) {\n            // Clean up partially written file on failure\n            runCatching { targetFile.delete() }\n            throw Exception(\"Failed to write file to public music directory: ${e.message}\", e)\n        }\n\n        // Insert into MediaStore with DATA column (legacy approach)\n        val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI\n        val values = ContentValues().apply {\n            put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)\n            put(MediaStore.MediaColumns.MIME_TYPE, \"audio/mp4\")\n            put(MediaStore.MediaColumns.DATA, targetFile.absolutePath)\n        }\n\n        val itemUri = resolver.insert(collection, values)\n        if (itemUri == null) {\n            // Clean up file if MediaStore insertion failed\n            runCatching { targetFile.delete() }\n            throw Exception(\"Failed to insert public Music item for $fileName into MediaStore\")\n        }\n\n        return\n    }\n\n    // Q+ (API >= 29): Use RELATIVE_PATH approach\n    val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI\n    val itemUri = resolver.insert(\n        collection,\n        ContentValues().apply {\n            put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)\n            put(MediaStore.MediaColumns.MIME_TYPE, \"audio/mp4\")\n            put(\n                MediaStore.MediaColumns.RELATIVE_PATH,\n                \"${Environment.DIRECTORY_MUSIC}/BBPlayer\",\n            )\n            put(MediaStore.MediaColumns.IS_PENDING, 1)\n        },\n    ) ?: throw Exception(\"Failed to create public Music item for $fileName\")\n\n    try {\n        resolver.openOutputStream(itemUri)?.use { outputStream ->\n            tempM4a.inputStream().use { it.copyTo(outputStream) }\n        } ?: throw Exception(\"Failed to open public Music output stream for $fileName\")\n\n        resolver.update(\n            itemUri,\n            ContentValues().apply {\n                put(MediaStore.MediaColumns.IS_PENDING, 0)\n            },\n            null,\n            null,\n        )\n    } catch (e: Exception) {\n        resolver.delete(itemUri, null, null)\n        throw e\n    }\n}\n\n// ─────────────────────────────────────────────────────────────\n// 元数据写入（文件级私有）\n// ─────────────────────────────────────────────────────────────\n\n@UnstableApi\nprivate fun writeMetadata(\n    id: String,\n    tempM4a: File,\n    track: TrackRecord?,\n    context: Context,\n    options: ExportOptions,\n    json: Json,\n) {\n    if (track == null) return\n\n    try {\n        val audioFile = AudioFileIO.read(tempM4a)\n        val tag = audioFile.tagOrCreateAndSetDefault\n\n        tag.setField(FieldKey.TITLE, track.title ?: id)\n        tag.setField(FieldKey.ARTIST, track.artist ?: \"Unknown\")\n        tag.setField(FieldKey.ALBUM, track.title ?: \"\")\n\n        // 封面\n        val coverFile = CoverDownloadManager.getCoverFile(context, id)\n        if (coverFile != null && coverFile.exists()) {\n            try {\n                val artwork = if (options.cropCoverArt) {\n                    // 使用 Glide 加载并 centerCrop 裁剪为正方形，\n                    // 能正确处理 WebP / HEIF 等各种格式及 EXIF 旋转。\n                    val squareBitmap = Glide.with(context)\n                        .asBitmap()\n                        .load(coverFile)\n                        .apply(RequestOptions().transform(CenterCrop()))\n                        .submit(1200, 1200)\n                        .get()\n                    val tmpFile = File(context.cacheDir, \"${id}_cover_sq.jpg\")\n                    try {\n                        tmpFile.outputStream().use {\n                            squareBitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)\n                        }\n                        squareBitmap.recycle()\n                        ArtworkFactory.createArtworkFromFile(tmpFile)\n                    } finally {\n                        tmpFile.delete()\n                    }\n                } else {\n                    ArtworkFactory.createArtworkFromFile(coverFile)\n                }\n                tag.setField(artwork)\n            } catch (e: Exception) {\n                Log.w(\"OrpheusExport\", \"Cover embed skipped for $id: ${e.message}\")\n            }\n        } else {\n            Log.w(\"OrpheusExport\", \"Cover file not found for $id, skipping artwork embed\")\n        }\n\n        // 歌词（仅在已缓存且 embedLyrics=true 时写入）\n        if (options.embedLyrics) {\n            writeLyrics(id, tag, options.convertToLrc, context, json)\n        }\n\n        audioFile.commit()\n    } catch (e: Exception) {\n        Log.e(\"OrpheusExport\", \"Failed to write metadata for $id: ${e.message}\")\n    }\n}\n\n// ─────────────────────────────────────────────────────────────\n// 歌词写入（文件级私有）\n// ─────────────────────────────────────────────────────────────\n\nprivate fun writeLyrics(\n    id: String,\n    tag: org.jaudiotagger.tag.Tag,\n    convertToLrc: Boolean,\n    context: Context,\n    json: Json,\n) {\n    try {\n        val lyricsDir = File(context.filesDir, \"lyrics\")\n        val lyricFile = File(lyricsDir, \"${id.replace(\"::\", \"--\")}.json\")\n        Log.d(\"OrpheusExport\", \"Checking lyrics file: $lyricFile\")\n        if (!lyricFile.exists()) return\n\n        val lyricJson = lyricFile.readText()\n        val lrcContent0 = json.decodeFromString<LyricFileCache>(lyricJson).lrc\n        if (lrcContent0 == null) {\n            Log.w(\"OrpheusExport\", \"No 'lrc' field found in lyrics JSON for $id\")\n            return\n        }\n        Log.d(\"OrpheusExport\", \"Extracted lyrics: ${lrcContent0.take(100)}\")\n\n        var lrcContent = lrcContent0\n\n        if (convertToLrc) {\n            lrcContent = SplConverter.toStandardLrc(lrcContent)\n        }\n\n        tag.setField(FieldKey.LYRICS, lrcContent)\n    } catch (e: Exception) {\n        Log.e(\"OrpheusExport\", \"Failed to embed lyrics for $id: ${e.message}\")\n    }\n}\n\n// ─────────────────────────────────────────────────────────────\n// 文件名构建（文件级私有）\n// ─────────────────────────────────────────────────────────────\n\n@UnstableApi\nprivate fun buildFileName(\n    id: String,\n    download: Download,\n    track: TrackRecord?,\n    filenamePattern: String?,\n): String {\n    val pattern = filenamePattern?.takeIf { it.isNotBlank() } ?: \"{name}\"\n    var name = pattern\n        .replace(\"{id}\", id)\n        .replace(\"{name}\", track?.title ?: id)\n        .replace(\"{artist}\", track?.artist ?: \"Unknown\")\n\n    val uri = download.request.uri\n    if (uri.scheme == \"orpheus\" && uri.host == \"bilibili\") {\n        name = name\n            .replace(\"{bvid}\", uri.getQueryParameter(\"bvid\") ?: \"\")\n            .replace(\"{cid}\", uri.getQueryParameter(\"cid\") ?: \"\")\n    } else {\n        name = name.replace(\"{bvid}\", \"\").replace(\"{cid}\", \"\")\n    }\n\n    val safeName = name.replace(Regex(\"[\\\\\\\\/:*?\\\"<>|]\"), \"_\").trim()\n    return if (safeName.isEmpty()) \"$id.m4a\" else \"$safeName.m4a\"\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/GeneralStorage.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.core.graphics.toColorInt\nimport androidx.media3.common.MediaItem\nimport com.tencent.mmkv.MMKV\nimport expo.modules.orpheus.model.TrackRecord\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\n\nobject GeneralStorage {\n    private var kv: MMKV? = null\n    private val json = Json { ignoreUnknownKeys = true }\n    private var lastSavedQueueSnapshot: List<String>? = null\n    private const val KEY_RESTORE_POSITION_ENABLED = \"config_restore_position_enabled\"\n\n    private const val KEY_LOUDNESS_NORMALIZATION_ENABLED = \"config_loudness_normalization_enabled\"\n    private const val KEY_SAVED_QUEUE = \"saved_queue_json_list\"\n    private const val KEY_SAVED_INDEX = \"saved_index\"\n    private const val KEY_SAVED_POSITION = \"saved_position\"\n    private const val KEY_SAVED_REPEAT_MODE = \"saved_repeat_mode\"\n    private const val KEY_SAVED_SHUFFLE_MODE = \"saved_shuffle_mode\"\n    private const val KEY_AUTOPLAY_ON_START_ENABLED = \"config_autoplay_on_start_enabled\"\n    private const val KEY_DESKTOP_LYRICS_SHOWN = \"state_desktop_lyrics_shown\"\n    private const val KEY_DESKTOP_LYRICS_LOCKED = \"state_desktop_lyrics_locked\"\n    private const val KEY_STATUS_BAR_LYRICS_ENABLED = \"config_status_bar_lyrics_enabled\"\n    private const val KEY_STATUS_BAR_LYRICS_PROVIDER = \"config_status_bar_lyrics_provider\"\n    private const val KEY_CAR_LYRICS_ENABLED = \"config_car_lyrics_enabled\"\n    private const val KEY_DESKTOP_LYRICS_DISPLAY_MODE = \"config_desktop_lyrics_display_mode\"\n    private const val KEY_DESKTOP_LYRICS_HIGHLIGHT_COLOR = \"config_desktop_lyrics_highlight_color\"\n    private const val KEY_DESKTOP_LYRICS_TEXT_SIZE = \"config_desktop_lyrics_text_size\"\n    private const val KEY_DESKTOP_LYRICS_Y = \"config_desktop_lyrics_y\"\n\n\n    @Synchronized\n    fun initialize(context: Context) {\n        if (kv == null) {\n            MMKV.initialize(context)\n            kv = MMKV.mmkvWithID(\"player_queue_store\")\n        }\n    }\n\n    private val safeKv: MMKV\n        get() = kv ?: throw IllegalStateException(\"MediaItemStorer not initialized\")\n\n    fun setRestoreEnabled(enabled: Boolean) {\n        try {\n            safeKv.encode(KEY_RESTORE_POSITION_ENABLED, enabled)\n        } catch (e: Exception) {\n            Log.e(\"MediaItemStorer\", \"Failed to set restore position enabled\", e)\n        }\n    }\n\n    fun setLoudnessNormalizationEnabled(enabled: Boolean) {\n        try {\n            safeKv.encode(KEY_LOUDNESS_NORMALIZATION_ENABLED, enabled)\n        } catch (e: Exception) {\n            Log.e(\"MediaItemStorer\", \"Failed to set loudness normalization enabled\", e)\n        }\n    }\n\n    fun isRestoreEnabled(): Boolean {\n        return safeKv.decodeBool(KEY_RESTORE_POSITION_ENABLED, false)\n    }\n\n    fun isLoudnessNormalizationEnabled(): Boolean {\n        return safeKv.decodeBool(KEY_LOUDNESS_NORMALIZATION_ENABLED, true)\n    }\n\n    fun isAutoplayOnStartEnabled(): Boolean {\n        return safeKv.decodeBool(KEY_AUTOPLAY_ON_START_ENABLED, false)\n    }\n\n    fun setAutoplayOnStartEnabled(enabled: Boolean) {\n        try {\n            safeKv.encode(KEY_AUTOPLAY_ON_START_ENABLED, enabled)\n        } catch (e: Exception) {\n            Log.e(\"MediaItemStorer\", \"Failed to set autoplay on start enabled\", e)\n        }\n    }\n\n    fun saveQueue(mediaItems: List<MediaItem>) {\n        try {\n            val jsonList = mediaItems.mapNotNull { item ->\n                item.mediaMetadata.extras?.getString(\"track_json\")\n            }\n\n            if (jsonList == lastSavedQueueSnapshot) return\n            lastSavedQueueSnapshot = jsonList\n\n            val jsonListString = json.encodeToString(jsonList)\n            safeKv.encode(KEY_SAVED_QUEUE, jsonListString)\n\n        } catch (e: Exception) {\n            Log.e(\"MediaItemStorer\", \"Failed to save queue\", e)\n        }\n    }\n\n    fun restoreQueue(context: Context): List<MediaItem> {\n        return try {\n            val jsonListString = kv?.decodeString(KEY_SAVED_QUEUE)\n\n            if (jsonListString.isNullOrEmpty()) return emptyList()\n\n            val trackJsonList: List<String> = json.decodeFromString(jsonListString)\n            lastSavedQueueSnapshot = trackJsonList\n\n            trackJsonList.mapNotNull { trackJson ->\n                try {\n                    val track = json.decodeFromString<TrackRecord>(trackJson)\n\n                    track.toMediaItem(context)\n\n                } catch (e: Exception) {\n                    Log.e(\"MediaItemStorer\", \"Failed to parse track json: $trackJson\", e)\n                    null\n                }\n            }\n        } catch (e: Exception) {\n            Log.e(\"MediaItemStorer\", \"Failed to restore queue\", e)\n            emptyList()\n        }\n    }\n\n    fun savePosition(index: Int, position: Long) {\n        safeKv.encode(KEY_SAVED_INDEX, index)\n        safeKv.encode(KEY_SAVED_POSITION, position)\n    }\n\n    fun saveRepeatMode(repeatMode: Int) = safeKv.encode(KEY_SAVED_REPEAT_MODE, repeatMode)\n    fun saveShuffleMode(shuffleMode: Boolean) = safeKv.encode(KEY_SAVED_SHUFFLE_MODE, shuffleMode)\n    fun getShuffleMode() = kv?.decodeBool(KEY_SAVED_SHUFFLE_MODE, false) ?: false\n\n    fun getSavedIndex() = kv?.decodeInt(KEY_SAVED_INDEX, 0) ?: 0\n    fun getSavedPosition() = kv?.decodeLong(KEY_SAVED_POSITION, 0L) ?: 0L\n    fun getRepeatMode() = kv?.decodeInt(KEY_SAVED_REPEAT_MODE, 0) ?: 0\n\n    fun isDesktopLyricsShown() = kv?.decodeBool(KEY_DESKTOP_LYRICS_SHOWN, false) ?: false\n    fun setDesktopLyricsShown(shown: Boolean) = safeKv.encode(KEY_DESKTOP_LYRICS_SHOWN, shown)\n\n    fun isDesktopLyricsLocked() = kv?.decodeBool(KEY_DESKTOP_LYRICS_LOCKED, false) ?: false\n    fun setDesktopLyricsLocked(locked: Boolean) = safeKv.encode(KEY_DESKTOP_LYRICS_LOCKED, locked)\n\n    fun isStatusBarLyricsEnabled() = kv?.decodeBool(KEY_STATUS_BAR_LYRICS_ENABLED, false) ?: false\n    fun setStatusBarLyricsEnabled(enabled: Boolean) = safeKv.encode(KEY_STATUS_BAR_LYRICS_ENABLED, enabled)\n\n    /** Returns \"superlyric\" or \"lyricon\" */\n    fun getStatusBarLyricsProvider() = kv?.decodeString(KEY_STATUS_BAR_LYRICS_PROVIDER, \"superlyric\") ?: \"superlyric\"\n    fun setStatusBarLyricsProvider(provider: String) = safeKv.encode(KEY_STATUS_BAR_LYRICS_PROVIDER, provider)\n\n    fun isCarLyricsEnabled() = kv?.decodeBool(KEY_CAR_LYRICS_ENABLED, false) ?: false\n    fun setCarLyricsEnabled(enabled: Boolean) = safeKv.encode(KEY_CAR_LYRICS_ENABLED, enabled)\n\n    /**\n     * Desktop Lyrics Display Mode:\n     * 0: Translation\n     * 1: Romaji\n     * 2: None\n     */\n    fun getDesktopLyricsMode() = kv?.decodeInt(KEY_DESKTOP_LYRICS_DISPLAY_MODE, 0) ?: 0\n    fun setDesktopLyricsMode(mode: Int) = safeKv.encode(KEY_DESKTOP_LYRICS_DISPLAY_MODE, mode)\n\n    fun getDesktopLyricsHighlightColor() = kv?.decodeInt(KEY_DESKTOP_LYRICS_HIGHLIGHT_COLOR, \"#FFC107\".toColorInt()) ?: \"#FFC107\".toColorInt()\n    fun setDesktopLyricsHighlightColor(color: Int) = safeKv.encode(KEY_DESKTOP_LYRICS_HIGHLIGHT_COLOR, color)\n\n    fun getDesktopLyricsTextSize() = kv?.decodeFloat(KEY_DESKTOP_LYRICS_TEXT_SIZE, 18f) ?: 18f\n    fun setDesktopLyricsTextSize(size: Float) = safeKv.encode(KEY_DESKTOP_LYRICS_TEXT_SIZE, size)\n\n    fun getDesktopLyricsY() = kv?.decodeInt(KEY_DESKTOP_LYRICS_Y, 200) ?: 200\n    fun setDesktopLyricsY(y: Int) = safeKv.encode(KEY_DESKTOP_LYRICS_Y, y)\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/GlideBitmapLoader.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.net.Uri\nimport android.util.Log\nimport androidx.media3.common.MediaMetadata\nimport androidx.media3.common.util.BitmapLoader\nimport com.bumptech.glide.Glide\nimport com.bumptech.glide.load.engine.DiskCacheStrategy\nimport com.google.common.util.concurrent.ListenableFuture\nimport com.google.common.util.concurrent.ListeningExecutorService\nimport com.google.common.util.concurrent.MoreExecutors\nimport java.util.concurrent.Executors\n\n@androidx.media3.common.util.UnstableApi\nclass GlideBitmapLoader(private val context: Context) : BitmapLoader {\n    private val executorService: ListeningExecutorService =\n        MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor())\n\n    override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap> {\n        val uri = metadata.artworkUri ?: return executorService.submit<Bitmap> {\n            throw IllegalArgumentException(\"Metadata artworkUri is null\")\n        }\n        return loadBitmap(uri)\n    }\n\n    override fun supportsMimeType(mimeType: String): Boolean {\n        return true\n    }\n\n    override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {\n        return executorService.submit<Bitmap> {\n            throw UnsupportedOperationException(\"Not implemented for raw bytes\")\n        }\n    }\n\n    override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {\n        return executorService.submit<Bitmap> {\n            Log.d(\"GlideBitmapLoader\", \"load image $uri\")\n            val glideBitmap = Glide.with(context)\n                .asBitmap()\n                .load(uri)\n                .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)\n                .submit(512, 512)\n                .get()\n\n            if (glideBitmap != null && !glideBitmap.isRecycled) {\n                val safeBitmap = glideBitmap.copy(Bitmap.Config.ARGB_8888, false)\n\n                return@submit safeBitmap\n            } else {\n                throw IllegalStateException(\"Bitmap load failed or recycled\")\n            }\n        }\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/LoudnessStorage.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.content.Context\nimport android.util.Log\nimport com.tencent.mmkv.MMKV\n\nobject LoudnessStorage {\n    private var kv: MMKV? = null\n\n\n    @Synchronized\n    fun initialize(context: Context) {\n        if (kv == null) {\n            MMKV.initialize(context)\n            kv = MMKV.mmkvWithID(\"loudness_normalization_store\")\n        }\n    }\n\n    private val safeKv: MMKV\n        get() = kv ?: throw IllegalStateException(\"LoudnessStorage not initialized\")\n\n    fun setLoudnessData(key: String, measuredI: Double) {\n        try {\n            Log.d(\"LoudnessNormalization\", \"setLoudnessData: $key, $measuredI\")\n            safeKv.encode(key, measuredI)\n        } catch (e: Exception) {\n            Log.e(\"LoudnessStorage\", \"Failed to set loudness data\", e)\n        }\n    }\n\n    fun getLoudnessData(key: String): Double {\n        try {\n            Log.d(\"LoudnessNormalization\", \"getLoudnessData: $key\")\n            return safeKv.decodeDouble(key)\n        } catch (e: Exception) {\n            Log.e(\"LoudnessStorage\", \"Failed to get loudness data\", e)\n            return 0.0\n        }\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/SleepTimeController.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.os.Handler\nimport android.os.Looper\nimport android.os.SystemClock\nimport androidx.media3.common.Player\n\nclass SleepTimeController(private val player: Player) {\n\n    private val handler = Handler(Looper.getMainLooper())\n\n    private var internalStopTargetMs: Long? = null\n\n    private val stopRunnable = Runnable {\n        performStop()\n    }\n\n    /**\n     * 开启定时器\n     * @param durationMs 多少毫秒后停止\n     */\n    fun start(durationMs: Long) {\n        if (durationMs <= 0) {\n            cancel()\n            return\n        }\n\n        cancel()\n\n        internalStopTargetMs = SystemClock.elapsedRealtime() + durationMs\n\n        handler.postDelayed(stopRunnable, durationMs)\n    }\n\n    /**\n     * 取消定时器\n     */\n    fun cancel() {\n        internalStopTargetMs = null\n        handler.removeCallbacks(stopRunnable)\n    }\n\n    /**\n     * @return 返回的是标准的 UTC 时间戳 (System.currentTimeMillis 格式)，如果没开启则返回 null\n     */\n    fun getStopTimeMs(): Long? {\n        val target = internalStopTargetMs ?: return null\n        val nowElapsed = SystemClock.elapsedRealtime()\n\n        val remainingMs = target - nowElapsed\n\n        if (remainingMs <= 0) {\n            return null\n        }\n\n        return System.currentTimeMillis() + remainingMs\n    }\n\n    private fun performStop() {\n        player.pause()\n        cancel()\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/SplConverter.kt",
    "content": "package expo.modules.orpheus.util\n\n/**\n * SPL（Salt Player Lyrics）→ 标准 LRC 转换工具。\n *\n * SPL 是 LRC 的超集，支持两种逐字（卡拉 OK）时间戳写法：\n *   1. 行内 `[mm:ss.ms]`（标准 SPL，即非行首的方括号时间戳）\n *   2. 行内 `<mm:ss.ms>`（SPL 兼容写法，仅用于中间逐字位置）\n *\n * 转换规则：\n *   - 行首 `[mm:ss.ms]` 块（可能有多个，对应重复行）→ **保留**，这是标准 LRC 行时间戳\n *   - 行体中的 `[mm:ss.ms]` 或 `<mm:ss.ms>` → **剥离**，这是逐字时间戳\n *   - 元数据行（如 `[ti:Title]`、空行）→ 原样保留\n */\nobject SplConverter {\n\n    /**\n     * 匹配行首一个或多个 [mm:ss.ms] 块（标准 LRC 行时间戳或重复行写法），必须锚定在行首。\n     * 示例：`[05:20.22]` 或 `[05:20.22][05:30.22]`（重复行）。\n     */\n    private val LEADING_TIMESTAMPS_REGEX = Regex(\"^(?:\\\\[\\\\d{1,3}:\\\\d{1,2}\\\\.\\\\d{1,6}\\\\])+\")\n\n    /**\n     * 匹配行体（非行首位置）中的逐字时间戳，兼容两种写法：\n     *   - `[mm:ss.ms]` —— 标准 SPL 逐字标记\n     *   - `<mm:ss.ms>` —— SPL 兼容逐字标记\n     */\n    private val INLINE_TIMESTAMP_REGEX = Regex(\"(?:\\\\[\\\\d{1,3}:\\\\d{1,2}\\\\.\\\\d{1,6}\\\\])|(?:<\\\\d{1,3}:\\\\d{1,2}\\\\.\\\\d{1,6}>)\")\n\n    /**\n     * 元数据行 / 空行中的逐字时间戳正则，避免分配。\n     */\n    private val METADATA_TIMESTAMP_REGEX = Regex(\"<\\\\d{1,3}:\\\\d{1,2}\\\\.\\\\d{1,6}>\")\n\n    /**\n     * 将 SPL 内容转换为标准 LRC：\n     * 保留行首时间戳，剥离所有行内逐字时间戳。\n     */\n    fun toStandardLrc(spl: String): String =\n        spl.lines().joinToString(\"\\n\") { line ->\n            val leadingMatch = LEADING_TIMESTAMPS_REGEX.find(line)\n            if (leadingMatch != null) {\n                // 保留行首时间戳，对行体剥离所有逐字时间戳\n                val body = line.substring(leadingMatch.range.last + 1)\n                leadingMatch.value + INLINE_TIMESTAMP_REGEX.replace(body, \"\")\n            } else {\n                // 元数据行 / 空行：仅剥除 <...> 形式（不会误伤 [ti:Title] 等元数据标签）\n                METADATA_TIMESTAMP_REGEX.replace(line, \"\")\n            }\n        }\n}\n\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/TrackRecordExtension.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.os.Bundle\nimport androidx.core.net.toUri\nimport androidx.media3.common.MediaItem\nimport androidx.media3.common.MediaMetadata\nimport expo.modules.orpheus.model.TrackRecord\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\n\nfun TrackRecord.toMediaItem(context: android.content.Context? = null): MediaItem {\n    val trackJson = Json.encodeToString(this)\n\n    val extras = Bundle()\n    extras.putString(\"track_json\", trackJson)\n\n    val downloadedCoverUri = context?.let { \n        expo.modules.orpheus.manager.CoverDownloadManager.getCoverFile(it, this.id)?.absolutePath?.let { path -> \"file://$path\" }\n    }\n\n    val finalArtUri = downloadedCoverUri ?: this.artwork\n\n    val artUri = if (!finalArtUri.isNullOrEmpty()) finalArtUri.toUri() else null\n\n    val metadata = MediaMetadata.Builder()\n        .setTitle(this.title)\n        .setArtist(this.artist)\n        .setArtworkUri(artUri)\n        .setExtras(extras)\n        .build()\n\n    return MediaItem.Builder()\n        .setMediaId(this.id)\n        .setUri(this.url)\n        .setMediaMetadata(metadata)\n        .build()\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/util/Volume.kt",
    "content": "package expo.modules.orpheus.util\n\nimport android.util.Log\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\nimport kotlin.math.pow\n\n/**\n * 响度均衡计算\n * @param measuredI 实测响度\n * @param targetI 目标响度\n * @return gain\n */\nfun calculateLoudnessGain(measuredI: Double, targetI: Double = -14.0): Float {\n\n    if (measuredI == 0.0) {\n        return 1.0f\n    }\n\n    val gainDb = targetI - measuredI\n    val linearFactor = 10.0.pow(gainDb / 20.0).toFloat()\n\n    val finalResult = linearFactor.coerceIn(0.0f, 1.0f)\n\n    return finalResult\n}\n\n/**\n * Volume Fade In\n * @param targetVolume 最终要达到的音量\n * @param durationMs 淡入持续时间\n * @param scope 协程作用域\n */\nfun androidx.media3.common.Player.fadeInTo(\n    targetVolume: Float,\n    durationMs: Long = 600L,\n    scope: CoroutineScope\n): Job {\n    this.volume = 0f\n\n    return scope.launch {\n        val stepInterval = 16L\n        val steps = (durationMs / stepInterval).toInt()\n        val volumeStep = targetVolume / steps\n\n        for (i in 1..steps) {\n            val newVol = volumeStep * i\n            val finalVol = newVol.coerceAtMost(targetVolume)\n            Log.d(\"Loudness\", \"finalVol $finalVol\")\n            volume = finalVol\n            delay(stepInterval)\n        }\n        volume = targetVolume\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/expo/modules/orpheus/view/LyricView.kt",
    "content": "package expo.modules.orpheus.view\n\nimport android.content.Context\nimport android.graphics.Canvas\nimport android.graphics.Color\nimport android.graphics.Paint\nimport android.util.AttributeSet\nimport android.view.View\nimport androidx.core.graphics.ColorUtils\nimport expo.modules.orpheus.model.LyricsLine\n\n/**\n * LyricView with Smart Global Coloring.\n * Handles both verbatim and standard lines, ensuring chosen color is always visible.\n */\nclass LyricView @JvmOverloads constructor(\n    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0\n) : View(context, attrs, defStyleAttr) {\n\n    private var currentLine: LyricsLine? = null\n    private var displayMode: Int = 0 // 0: Trans, 1: Roma, 2: None\n\n    private var lastUpdateMs: Long = 0\n    private var lastSystemTime: Long = 0\n    private var isPlaying: Boolean = false\n\n    private var trackTitle: String = \"\"\n    private var trackArtist: String = \"\"\n\n    private val mainPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }\n    private val highlightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }\n    private val subPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }\n    private val outlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {\n        textAlign = Paint.Align.CENTER\n        style = Paint.Style.STROKE\n        color = Color.BLACK\n        alpha = 160\n    }\n\n    private var mainTextSize: Float = 60f\n    private var chosenHighlightColor: Int = Color.parseColor(\"#FFC107\")\n    private var baseTextColor: Int = Color.WHITE\n\n    fun setStyle(textSize: Float, chosenColor: Int) {\n        this.mainTextSize = textSize * 3\n        this.chosenHighlightColor = chosenColor\n        \n        val hsl = FloatArray(3)\n        ColorUtils.colorToHSL(chosenColor, hsl)\n        val isWhite = hsl[2] > 0.9f \n\n        // If user chose White as theme, we make base text grey/transparent white.\n        // Otherwise, base is always pure white for high contrast.\n        this.baseTextColor = if (isWhite) ColorUtils.setAlphaComponent(Color.WHITE, 140) else Color.WHITE\n\n        mainPaint.textSize = mainTextSize\n        highlightPaint.textSize = mainTextSize\n        highlightPaint.color = chosenHighlightColor\n        \n        subPaint.textSize = mainTextSize * 0.85f\n        // Sub-text always follows the chosen color with transparency\n        subPaint.color = ColorUtils.setAlphaComponent(chosenHighlightColor, 200)\n        \n        requestLayout()\n        invalidate()\n    }\n\n    fun setTrackInfo(title: String, artist: String) {\n        this.trackTitle = title\n        this.trackArtist = artist\n        invalidate()\n    }\n\n    fun setDisplayMode(mode: Int) {\n        this.displayMode = mode\n        invalidate()\n    }\n\n    fun setPlaybackState(playing: Boolean) {\n        this.isPlaying = playing\n        if (playing) {\n            lastSystemTime = System.currentTimeMillis()\n            postInvalidateOnAnimation()\n        }\n    }\n\n    fun setLine(line: LyricsLine?) {\n        this.currentLine = line\n        lastSystemTime = System.currentTimeMillis()\n        invalidate()\n    }\n\n    fun updateProgress(progressMs: Long) {\n        this.lastUpdateMs = progressMs\n        this.lastSystemTime = System.currentTimeMillis()\n        invalidate()\n    }\n\n    override fun onDraw(canvas: Canvas) {\n        super.onDraw(canvas)\n        val centerX = width / 2f\n        val centerY = height / 2f\n        \n        val now = System.currentTimeMillis()\n        val effectiveProgressMs = if (isPlaying && lastSystemTime > 0) {\n            lastUpdateMs + (now - lastSystemTime)\n        } else {\n            lastUpdateMs\n        }\n\n        val line = currentLine\n        if (line == null) {\n            if (trackTitle.isNotEmpty()) {\n                val titleY = centerY + (mainPaint.descent() - mainPaint.ascent()) / 4f\n                // Track Title uses the chosen color\n                mainPaint.color = chosenHighlightColor\n                drawTextWithOutline(canvas, trackTitle, centerX, titleY, mainPaint)\n                if (trackArtist.isNotEmpty()) {\n                    val subY = titleY + (mainPaint.descent() - mainPaint.ascent()) * 0.35f + subPaint.textSize * 1.05f\n                    drawTextWithOutline(canvas, trackArtist, centerX, subY, subPaint)\n                }\n            }\n            return\n        }\n        \n        val subText = when (displayMode) {\n            0 -> line.translation\n            1 -> line.romaji\n            else -> null\n        }?.takeIf { it.isNotBlank() }\n\n        val mainLineHeight = mainPaint.descent() - mainPaint.ascent()\n        val subLineHeight = subPaint.textSize * 1.05f\n        val mainTextY = if (subText != null) centerY - (subLineHeight * 0.25f) else centerY + (mainLineHeight / 4f)\n\n        // 1. Determine base coloring for main text\n        val isVerbatim = line.spans != null && line.spans.isNotEmpty()\n        mainPaint.color = if (isVerbatim) baseTextColor else chosenHighlightColor\n\n        // 2. Draw Main Text Outline & Fill\n        drawTextWithOutline(canvas, line.text, centerX, mainTextY, mainPaint)\n        \n        // 3. Draw Verbatim Highlight (if applicable)\n        if (isVerbatim) {\n            drawVerbatimHighlight(canvas, line, centerX, mainTextY, effectiveProgressMs)\n        }\n        \n        // 4. Draw Sub Text\n        if (subText != null) {\n            val currentSubY = mainTextY + (mainLineHeight * 0.35f) + subLineHeight\n            val truncatedSub = truncateText(subText, subPaint, width * 0.95f)\n            drawTextWithOutline(canvas, truncatedSub, centerX, currentSubY, subPaint)\n        }\n\n        if (isPlaying && isVerbatim) {\n            postInvalidateOnAnimation()\n        }\n    }\n\n    private fun drawTextWithOutline(canvas: Canvas, text: String, x: Float, y: Float, paint: Paint) {\n        outlinePaint.textSize = paint.textSize\n        outlinePaint.strokeWidth = paint.textSize / 15f\n        canvas.drawText(text, x, y, outlinePaint)\n        canvas.drawText(text, x, y, paint)\n    }\n\n    private fun truncateText(text: String, paint: Paint, maxWidth: Float): String {\n        return if (paint.measureText(text) > maxWidth) {\n            val end = paint.breakText(text, true, maxWidth - 20f, null)\n            text.substring(0, end) + \"...\"\n        } else text\n    }\n\n    private fun drawVerbatimHighlight(canvas: Canvas, line: LyricsLine, x: Float, y: Float, progressMs: Long) {\n        val spans = line.spans ?: return\n        val fullWidth = mainPaint.measureText(line.text)\n        val startX = x - fullWidth / 2f\n        var accumulatedX = startX\n        \n        for (span in spans) {\n            val spanWidth = mainPaint.measureText(span.text)\n            val spanProgress = when {\n                progressMs < span.startTime -> 0f\n                progressMs > span.endTime -> 1f\n                else -> (progressMs - span.startTime).toFloat() / span.duration.toFloat()\n            }\n\n            if (spanProgress > 0) {\n                canvas.save()\n                canvas.clipRect(accumulatedX, y + mainPaint.ascent(), accumulatedX + (spanWidth * spanProgress), y + mainPaint.descent())\n                outlinePaint.textSize = mainTextSize\n                outlinePaint.strokeWidth = mainTextSize / 15f\n                canvas.drawText(span.text, accumulatedX + spanWidth / 2f, y, outlinePaint)\n                canvas.drawText(span.text, accumulatedX + spanWidth / 2f, y, highlightPaint)\n                canvas.restore()\n            }\n            accumulatedX += spanWidth\n        }\n    }\n\n    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {\n        val width = MeasureSpec.getSize(widthMeasureSpec)\n        val mainLineHeight = mainPaint.descent() - mainPaint.ascent()\n        val subLineHeight = subPaint.textSize * 1.1f\n        setMeasuredDimension(width, (mainLineHeight + subLineHeight + 60f).toInt())\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/LyricLine.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.lyric.model\n\nimport io.github.proify.lyricon.lyric.model.extensions.deepCopy\nimport io.github.proify.lyricon.lyric.model.extensions.normalize\nimport io.github.proify.lyricon.lyric.model.interfaces.DeepCopyable\nimport io.github.proify.lyricon.lyric.model.interfaces.ILyricLine\nimport io.github.proify.lyricon.lyric.model.interfaces.Normalize\nimport kotlinx.serialization.Serializable\n\n/**\n * 歌词行\n *\n * @property begin 开始时间\n * @property end 结束时间\n * @property duration 持续时间\n * @property isAlignedRight 是否渲染显示在右边\n * @property metadata 元数据\n * @property text 文本\n * @property words 文本单词列表\n */\n@Serializable\ndata class LyricLine(\n    override var begin: Long = 0,\n    override var end: Long = 0,\n    override var duration: Long = 0,\n    override var isAlignedRight: Boolean = false,\n    override var metadata: LyricMetadata? = null,\n    override var text: String? = null,\n    override var words: List<LyricWord>? = null,\n) : ILyricLine, DeepCopyable<LyricLine>, Normalize<LyricLine> {\n\n    init {\n        if (duration == 0L && end > begin) duration = end - begin\n    }\n\n    override fun deepCopy(): LyricLine = copy(\n        words = words?.deepCopy()\n    )\n\n    override fun normalize(): LyricLine = deepCopy().apply {\n        words = words?.normalize()\n        text = words\n            ?.takeIf { it.isNotEmpty() }\n            ?.joinToString(\"\") { it.text.orEmpty() }\n            ?: text\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/LyricMetadata.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\n@file:Suppress(\"unused\")\n\npackage io.github.proify.lyricon.lyric.model\n\nimport kotlinx.serialization.Serializable\n\n/**\n * 歌词元数据模型类\n * 使用委托模式继承自 [Map]，用于存储和获取歌词相关的配置信息。\n * 提供了多种基本类型的扩展获取方法，并包含默认值处理。\n *\n * @property map 存储元数据的底层映射表，键和值均为字符串类型（值可为空）\n */\n@Serializable\ndata class LyricMetadata(\n    private val map: Map<String, String?> = emptyMap(),\n) : Map<String, String?> by map {\n\n    /**\n     * 获取 Double 类型的值\n     * @param key 元数据键名\n     * @param default 转换失败或键不存在时的默认值\n     * @return 对应的 Double 数值\n     */\n    fun getDouble(key: String, default: Double = 0.0): Double =\n        map[key]?.toDoubleOrNull() ?: default\n\n    /**\n     * 获取 Boolean 类型的值\n     * @param key 元数据键名\n     * @param default 转换失败或键不存在时的默认值\n     * @return 对应的 Boolean 布尔值\n     */\n    fun getBoolean(key: String, default: Boolean = false): Boolean =\n        map[key]?.toBoolean() ?: default\n\n    /**\n     * 获取 Float 类型的值\n     * @param key 元数据键名\n     * @param default 转换失败或键不存在时的默认值\n     * @return 对应的 Float 数值\n     */\n    fun getFloat(key: String, default: Float = 0f): Float = map[key]?.toFloatOrNull() ?: default\n\n    /**\n     * 获取 Long 类型的值\n     * @param key 元数据键名\n     * @param default 转换失败或键不存在时的默认值\n     * @return 对应的 Long 数值\n     */\n    fun getLong(key: String, default: Long = 0): Long = map[key]?.toLongOrNull() ?: default\n\n    /**\n     * 获取 Int 类型的值\n     * @param key 元数据键名\n     * @param default 转换失败或键不存在时的默认值\n     * @return 对应的 Int 数值\n     */\n    fun getInt(key: String, default: Int = 0): Int = map[key]?.toIntOrNull() ?: default\n\n    /**\n     * 获取 String 类型的值\n     * @param key 元数据键名\n     * @param default 键不存在时的默认值\n     * @return 对应的字符串值\n     */\n    fun getString(key: String, default: String? = null): String? = map[key] ?: default\n\n    /**\n     * 判断对象是否相等\n     * 基于底层的 map 内容进行比对\n     */\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (other !is LyricMetadata) return false\n        return map == other.map\n    }\n\n    /**\n     * 生成哈希值\n     * 基于底层的 map 生成，确保与 equals 逻辑一致\n     */\n    override fun hashCode(): Int {\n        return map.hashCode()\n    }\n\n    /**\n     * 返回对象的字符串表示\n     */\n    override fun toString(): String {\n        return \"LyricMetadata(map=$map)\"\n    }\n}\n\n/**\n * 构建 [LyricMetadata] 的便捷工厂函数\n * * @param pairs 键值对序列\n * @return 包含指定数据的 LyricMetadata 实例\n */\nfun lyricMetadataOf(vararg pairs: Pair<String, String?>): LyricMetadata =\n    LyricMetadata(mapOf(*pairs))"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/LyricTiming.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.lyric.model\n\nimport io.github.proify.lyricon.lyric.model.interfaces.ILyricTiming\nimport kotlinx.serialization.Serializable\n\n/**\n * 歌词时间信息\n *\n * @property begin 开始时间\n * @property end 结束时间\n * @property duration 持续时间\n */\n@Serializable\ndata class LyricTiming(\n    override var begin: Long,\n    override var end: Long,\n    override var duration: Long\n) : ILyricTiming"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/LyricWord.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.lyric.model\n\nimport io.github.proify.lyricon.lyric.model.interfaces.DeepCopyable\nimport io.github.proify.lyricon.lyric.model.interfaces.ILyricWord\nimport kotlinx.serialization.Serializable\n\n/**\n * 歌词单词\n *\n * @property begin 开始时间\n * @property end 结束时间\n * @property duration 持续时间\n * @property text 文本\n * @property metadata 元数据\n */\n@Serializable\ndata class LyricWord(\n    override var begin: Long = 0,\n    override var end: Long = 0,\n    override var duration: Long = 0,\n    override var text: String? = null,\n    override var metadata: LyricMetadata? = null,\n) : ILyricWord, DeepCopyable<LyricWord> {\n\n    init {\n        if (duration == 0L && end > begin) duration = end - begin\n    }\n\n    override fun deepCopy(): LyricWord = copy()\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/RichLyricLine.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.lyric.model\n\nimport io.github.proify.lyricon.lyric.model.extensions.deepCopy\nimport io.github.proify.lyricon.lyric.model.extensions.normalize\nimport io.github.proify.lyricon.lyric.model.interfaces.DeepCopyable\nimport io.github.proify.lyricon.lyric.model.interfaces.IRichLyricLine\nimport io.github.proify.lyricon.lyric.model.interfaces.Normalize\nimport kotlinx.serialization.Serializable\n\n/**\n * 富歌词\n *\n * @property begin 开始时间\n * @property end 结束时间\n * @property duration 持续时间\n * @property isAlignedRight 是否显示在右边\n * @property metadata 元数据\n * @property text 主文本\n * @property words 主文本单词列表\n * @property secondary 次要文本\n * @property secondaryWords 次要文本单词列表\n * @property translation 主要翻译文本\n * @property translationWords 主要翻译文本单词列表\n * @property roma 罗马音\n */\n@Serializable\ndata class RichLyricLine(\n    override var begin: Long = 0,\n    override var end: Long = 0,\n    override var duration: Long = 0,\n    override var isAlignedRight: Boolean = false,\n    override var metadata: LyricMetadata? = null,\n    override var text: String? = null,\n    override var words: List<LyricWord>? = null,\n    override var secondary: String? = null,\n    override var secondaryWords: List<LyricWord>? = null,\n    override var translation: String? = null,\n    override var translationWords: List<LyricWord>? = null,\n    override var roma: String? = null\n) : IRichLyricLine, DeepCopyable<RichLyricLine>, Normalize<RichLyricLine> {\n\n    init {\n        if (duration == 0L && end > begin) duration = end - begin\n    }\n\n    override fun deepCopy(): RichLyricLine = copy(\n        words = words?.deepCopy(),\n        secondaryWords = secondaryWords?.deepCopy(),\n        translationWords = translationWords?.deepCopy(),\n    )\n\n    override fun normalize(): RichLyricLine = deepCopy().apply {\n        words = words?.normalize()\n        text = words.toText(text)\n        secondaryWords = secondaryWords?.normalize()\n        secondary = secondaryWords.toText(secondary)\n        translationWords = translationWords?.normalize()\n        translation = translationWords.toText(translation)\n    }\n\n    private fun List<LyricWord>?.toText(default: String?): String? =\n        if (isNullOrEmpty()) default else joinToString(\"\") { it.text.orEmpty() }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/Song.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model\n\nimport io.github.proify.lyricon.lyric.model.extensions.deepCopy\nimport io.github.proify.lyricon.lyric.model.extensions.normalizeSortByTime\nimport io.github.proify.lyricon.lyric.model.interfaces.DeepCopyable\nimport io.github.proify.lyricon.lyric.model.interfaces.Normalize\nimport kotlinx.serialization.Serializable\n\n/**\n * 歌曲信息\n *\n * @property id 歌曲ID\n * @property name 歌曲名\n * @property artist 艺术家\n * @property duration 歌曲时长\n * @property metadata 元数据\n * @property lyrics 歌词列表\n */\n@Serializable\ndata class Song(\n    var id: String? = null,\n    var name: String? = null,\n    var artist: String? = null,\n    var duration: Long = 0,\n    var metadata: LyricMetadata? = null,\n    var lyrics: List<RichLyricLine>? = null,\n) : DeepCopyable<Song>, Normalize<Song> {\n\n    override fun deepCopy(): Song = copy(lyrics = lyrics?.deepCopy())\n\n    override fun normalize(): Song = deepCopy().apply {\n        lyrics = lyrics?.mapNotNull { line ->\n            if (line.duration <= 0) line.duration = line.end - line.begin\n\n            val isValid = line.begin >= 0\n                    && line.begin < line.end\n                    && line.duration > 0\n                    && !line.text.isNullOrBlank()\n            if (isValid) line else null\n        }?.normalizeSortByTime()\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/extensions/Extensions.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n@file:Suppress(\"unused\")\n\npackage io.github.proify.lyricon.lyric.model.extensions\n\nimport io.github.proify.lyricon.lyric.model.interfaces.DeepCopyable\nimport io.github.proify.lyricon.lyric.model.interfaces.ILyricTiming\nimport io.github.proify.lyricon.lyric.model.interfaces.Normalize\n\n/**\n * 规范化排序\n */\nfun <T : ILyricTiming> List<T>.normalizeSortByTime(): List<T> = sortedBy { it.begin }\n\n/**\n * 深拷贝对象\n */\nfun <T : DeepCopyable<T>> List<T>.deepCopy(): List<T> = map { it.deepCopy() }\n\n/**\n * 规范化对象\n */\nfun <T : Normalize<T>> List<T>.normalize(): List<T> = map { it.normalize() }"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/extensions/LyricWord.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model.extensions\n\nimport io.github.proify.lyricon.lyric.model.LyricWord\n\n/**\n * 规范化歌词单词列表。\n * 处理无效的时间戳、修正持续时间、合并碎片单词以及填充空隙。\n *\n * 规则说明：\n * - 空文本单词会被丢弃，空白文本会保留为分隔符。\n * - 时间有效的单词必须满足 begin >= 0 且 end > begin。\n * - 时间无效的单词会先缓存，之后按可用时间空隙填充，或合并到相邻有效单词。\n * - 正数 duration 会保留；duration 非正数时使用 end - begin 兜底。\n * - ASCII 字母/数字片段之间如果没有空白分隔符，会按同一个英文单词合并。\n */\nfun List<LyricWord>.normalize(): List<LyricWord> {\n    // 1. 过滤掉没有文本内容的单词\n    val validTextWords = this.filter { !it.text.isNullOrEmpty() }\n\n    if (validTextWords.isEmpty()) {\n        return emptyList()\n    }\n\n    val result = ArrayList<LyricWord>()\n    val invalidBuffer = ArrayList<LyricWord>()\n\n    var lastEndTime = 0L\n\n    for (word in validTextWords) {\n        // 判断单词时间是否有效: 开始时间必须非负,且结束时间必须大于开始时间\n        val isTimeValid = word.begin >= 0 && word.end > word.begin\n\n        if (isTimeValid) {\n            // --- 处理堆积的无效单词 ---\n            if (invalidBuffer.isNotEmpty()) {\n                val combinedText = invalidBuffer.joinToString(\"\") { it.text ?: \"\" }\n                val gap = word.begin - lastEndTime\n\n                if (gap > 0) {\n                    // 情况 A: 有足够的空间 (Gap > 1),创建一个填补单词\n                    val filler = LyricWord().apply {\n                        this.text = combinedText\n                        this.begin = lastEndTime\n                        this.end = word.begin\n                        this.duration = this.end - this.begin\n                    }\n                    result.add(filler)\n                } else {\n                    // 情况 B: 空间不足,需要合并文本\n                    if (result.isNotEmpty()) {\n                        // 如果有前一个单词,合并到前一个单词后面 (Suffix)\n                        val prev = result.last()\n                        prev.text = (prev.text ?: \"\") + combinedText\n                    } else {\n                        // 如果没有前一个单词(即无效单词在整个列表最前面且空间不足),合并到当前单词前面 (Prefix)\n                        word.text = combinedText + (word.text ?: \"\")\n                    }\n                }\n                invalidBuffer.clear()\n            }\n\n            // --- 处理当前有效单词 ---\n            // 强制修正 duration 字段\n            if (word.duration <= 0) word.duration = word.end - word.begin\n            result.add(word)\n\n            // 更新最后结束时间\n            lastEndTime = word.end\n        } else {\n            // 当前单词时间无效,加入缓冲区等待处理\n            invalidBuffer.add(word)\n        }\n    }\n\n    // --- 处理列表末尾残留的无效单词 ---\n    if (invalidBuffer.isNotEmpty()) {\n        val combinedText = invalidBuffer.joinToString(\"\") { it.text ?: \"\" }\n\n        if (result.isNotEmpty()) {\n            // 如果前面有单词,合并到最后一个单词的后缀\n            val lastWord = result.last()\n            lastWord.text = (lastWord.text ?: \"\") + combinedText\n        } else {\n            // 如果全是无效单词 (孤立情况),创建一个新单词\n            val newWord = LyricWord().apply {\n                this.text = combinedText\n                this.begin = 0\n                this.end = 100\n                this.duration = 100\n            }\n            result.add(newWord)\n        }\n    }\n\n    return result.normalizeSortByTime().mergeAsciiWordFragments()\n}\n\n/**\n * 合并没有空白分隔的 ASCII 字母/数字片段。\n *\n * 部分歌词源会把英文复合词拆成多个有时间戳的片段，例如 under + ground。\n * 这些片段之间没有独立空格词，因此规范化后应作为同一个词显示。\n */\nprivate fun List<LyricWord>.mergeAsciiWordFragments(): List<LyricWord> {\n    if (size < 2) return this\n\n    val result = ArrayList<LyricWord>(size)\n\n    for (word in this) {\n        val previous = result.lastOrNull()\n\n        if (previous != null && previous.canMergeAsciiWordFragmentWith(word)) {\n            previous.text = previous.text.orEmpty() + word.text.orEmpty()\n            previous.end = maxOf(previous.end, word.end)\n            previous.duration += word.duration\n        } else {\n            result.add(word)\n        }\n    }\n\n    return result\n}\n\nprivate fun LyricWord.canMergeAsciiWordFragmentWith(next: LyricWord): Boolean =\n    text.isAsciiWordFragment() && next.text.isAsciiWordFragment() && end == next.begin\n\nprivate fun String?.isAsciiWordFragment(): Boolean =\n    !isNullOrEmpty() && all { it.isLetterOrDigit() && it.code < 128 }\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/extensions/TimingNavigator.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.lyric.model.extensions\n\nimport io.github.proify.lyricon.lyric.model.interfaces.ILyricTiming\n\n/**\n * 毫秒级时间轴导航器，支持重叠歌词的高效检索。\n *\n * @property source 必须按 [ILyricTiming.begin] 升序排列的数据源。\n * @param T 实现 [ILyricTiming] 接口的数据类型。\n */\nclass TimingNavigator<T : ILyricTiming>(\n    val source: Array<T>\n) {\n    /** 歌词源总数 */\n    val size: Int = source.size\n\n    /**\n     * 记录 0..i 范围内的最大结束时间。\n     * 该数组具有单调递增属性，用于在 [resolveOverlapping] 中进行二分查找。\n     */\n    val maxEndSoFar: LongArray = LongArray(size)\n\n    init {\n        var currentMax = -1L\n        for (i in source.indices) {\n            val end = source[i].end\n            if (end > currentMax) {\n                currentMax = end\n            }\n            maxEndSoFar[i] = currentMax\n        }\n    }\n\n    /** 缓存最后一次匹配成功的索引，用于顺序播放优化 */\n    var lastMatchedIndex: Int = -1\n        private set\n\n    /** 记录最后一次查询的时间戳 */\n    var lastQueryPosition: Long = -1L\n        private set\n\n    /**\n     * 获取指定位置 [position] 匹配的第一条记录。\n     */\n    fun first(position: Long): T? {\n        val index = findTargetIndex(position)\n        updateCache(position, index)\n        if (index == -1) return null\n\n        if (position <= source[index].end) {\n            return source[index]\n        }\n\n        resolveOverlapping(position, index) { return it }\n        return null\n    }\n\n    /**\n     * 遍历指定位置 [position] 处的所有有效记录（包含重叠部分）。\n     * @param action 对每个匹配项执行的回调。\n     * @return 找到的匹配项总数。\n     */\n    inline fun forEachAt(position: Long, action: (T) -> Unit): Int {\n        if (size == 0) return 0\n\n        val anchorIndex = findTargetIndex(position)\n        updateCache(position, anchorIndex)\n\n        if (anchorIndex == -1) return 0\n\n        return resolveOverlapping(position, anchorIndex, action)\n    }\n\n    /**\n     * 遍历 [position] 处的记录，若当前点无记录，则返回最近的一条历史记录。\n     */\n    inline fun forEachAtOrPrevious(position: Long, action: (T) -> Unit): Int {\n        val count = forEachAt(position, action)\n        if (count > 0) return count\n\n        val previous = findPreviousEntry(position) ?: return 0\n        action(previous)\n        return 1\n    }\n\n    /**\n     * 寻找起始时间小于等于 [position] 的最后一条记录。\n     */\n    fun findPreviousEntry(position: Long): T? {\n        val idx = findUpperBound(position)\n        return if (idx >= 0) source[idx] else null\n    }\n\n    /**\n     * 手动重置缓存，在手动跳进度或切换歌曲时使用。\n     */\n    @Suppress(\"unused\")\n    fun resetCache() {\n        lastMatchedIndex = -1\n        lastQueryPosition = -1L\n    }\n\n    /**\n     * 定位起始时间小于等于 [position] 的最后一个索引。\n     * 包含短步长顺序扫描优化。\n     */\n    fun findTargetIndex(position: Long): Int {\n        if (size == 0 || position < source[0].begin) return -1\n\n        val lastIdx = lastMatchedIndex\n        // 顺序播放优化：短步长前向探测\n        if (lastIdx >= 0 && position >= lastQueryPosition && position >= source[lastIdx].begin) {\n            var currIdx = lastIdx\n            var steps = 0\n            // 阈值设为 4，超过则切换为二分查找以维持 logN 效率\n            while (currIdx + 1 < size && source[currIdx + 1].begin <= position) {\n                currIdx++\n                steps++\n                if (steps > 4) return findUpperBound(position)\n            }\n            return currIdx\n        }\n\n        return findUpperBound(position)\n    }\n\n    /**\n     * 标准二分查找，定位第一个起始时间大于 [position] 的索引的前一个位置。\n     */\n    private fun findUpperBound(position: Long): Int {\n        var low = 0\n        var high = size - 1\n        var ans = -1\n        while (low <= high) {\n            val mid = (low + high) ushr 1\n            if (source[mid].begin <= position) {\n                ans = mid\n                low = mid + 1\n            } else {\n                high = mid - 1\n            }\n        }\n        return ans\n    }\n\n    /**\n     * 解决重叠检索的核心逻辑。\n     * 利用 [maxEndSoFar] 的单调性排除不可能重叠的区间。\n     */\n    @PublishedApi\n    internal inline fun resolveOverlapping(\n        position: Long,\n        anchorIndex: Int,\n        action: (T) -> Unit\n    ): Int {\n        var low = 0\n        var high = anchorIndex\n        var start = anchorIndex\n\n        // 二分定位第一个满足 maxEndSoFar >= position 的索引\n        while (low <= high) {\n            val mid = (low + high) ushr 1\n            if (maxEndSoFar[mid] >= position) {\n                start = mid\n                high = mid - 1\n            } else {\n                low = mid + 1\n            }\n        }\n\n        var count = 0\n        for (i in start..anchorIndex) {\n            val entry = source[i]\n            if (position <= entry.end && position >= entry.begin) {\n                action(entry)\n                count++\n            }\n        }\n        return count\n    }\n\n    /**\n     * 更新播放状态缓存。\n     */\n    @PublishedApi\n    internal fun updateCache(position: Long, index: Int) {\n        lastQueryPosition = position\n        lastMatchedIndex = index\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/interfaces/DeepCopyable.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.lyric.model.interfaces\n\ninterface DeepCopyable<T : DeepCopyable<T>> {\n    /**\n     * 返回当前对象的深拷贝\n     */\n    fun deepCopy(): T\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/interfaces/ILyricLine.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model.interfaces\n\nimport io.github.proify.lyricon.lyric.model.LyricMetadata\nimport io.github.proify.lyricon.lyric.model.LyricWord\n\ninterface ILyricLine : ILyricTiming {\n    var isAlignedRight: Boolean\n    var metadata: LyricMetadata?\n    var text: String?\n    var words: List<LyricWord>?\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/interfaces/ILyricTiming.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model.interfaces\n\n/**\n * 歌词时间\n *\n * @property begin 开始时间\n * @property end 结束时间\n * @property duration 持续时间\n */\ninterface ILyricTiming {\n    var begin: Long\n    var end: Long\n    var duration: Long\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/interfaces/ILyricWord.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model.interfaces\n\nimport io.github.proify.lyricon.lyric.model.LyricMetadata\n\ninterface ILyricWord : ILyricTiming {\n    var text: String?\n    var metadata: LyricMetadata?\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/interfaces/IRichLyricLine.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model.interfaces\n\nimport io.github.proify.lyricon.lyric.model.LyricWord\n\ninterface IRichLyricLine : ILyricLine {\n    var secondary: String?\n    var secondaryWords: List<LyricWord>?\n    var translation: String?\n    var translationWords: List<LyricWord>?\n    var roma: String?\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/lyric/model/interfaces/Normalize.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.lyric.model.interfaces\n\ninterface Normalize<T : Normalize<T>> {\n    /**\n     * 规范化对象\n     */\n    fun normalize(): T\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/CachedRemotePlayer.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.media.session.PlaybackState\nimport io.github.proify.lyricon.lyric.model.Song\nimport io.github.proify.lyricon.provider.CachedRemotePlayer.PlaybackStateSyncType.Auto\nimport io.github.proify.lyricon.provider.CachedRemotePlayer.PlaybackStateSyncType.Manually\n\n/**\n * [RemotePlayer] 的装饰器实现，支持断线重连后的状态恢复。\n *\n * 内部维护最近一次设置的播放上下文。当远程连接断开时，外部调用仍能更新这些缓存值；\n * 当连接恢复并调用 [syncs] 时，缓存的状态将原子化地同步至远程播放器。\n *\n * @property player 实际的远程播放器实例。\n */\ninternal class CachedRemotePlayer(\n    val player: RemotePlayer\n) : RemotePlayer {\n\n    /** 最近设置的歌曲（发送纯文本后会被清空） */\n    @Volatile\n    var lastSong: Song? = null\n        private set\n\n    /** 最近的播放状态 */\n    @Volatile\n    var isPlaying: Boolean = false\n        private set\n\n    /** 最近的播放位置（毫秒） */\n    @Volatile\n    var lastPosition: Long = 0\n        private set\n\n    /** 最近设置的位置更新间隔（毫秒） */\n    @Volatile\n    var lastPositionUpdateInterval: Int = -1\n        private set\n\n    /** 最近发送的文本内容（设置歌曲对象后会被清空） */\n    @Volatile\n    var lastText: String? = null\n        private set\n\n    /** 是否显示翻译内容 */\n    @Volatile\n    var lastDisplayTranslation: Boolean? = null\n        private set\n\n    /** 最近的罗马音显示配置。 */\n    @Volatile\n    var lastDisplayRoma: Boolean? = null\n\n    @Volatile\n    private var lastLyricType = LastLyricType.NONE\n\n    @Volatile\n    private var lastPlaybackState: PlaybackState? = null\n\n    @Volatile\n    private var lastPlaybackStateSyncType = Manually\n\n    private enum class LastLyricType {\n        SONG, TEXT, NONE\n    }\n\n    private enum class PlaybackStateSyncType {\n        Manually, Auto\n    }\n\n    /**\n     * 根据当前缓存的状态同步至 [player]。\n     */\n    @Synchronized\n    internal fun syncs() {\n        val interval = lastPositionUpdateInterval\n        if (interval >= 0) setPositionUpdateInterval(interval)\n\n        lastDisplayTranslation?.let { setDisplayTranslation(it) }\n        lastDisplayRoma?.let { setDisplayRoma(it) }\n\n        when (lastLyricType) {\n            LastLyricType.SONG -> setSong(lastSong)\n            LastLyricType.TEXT -> sendText(lastText)\n            else -> Unit\n        }\n\n        when (lastPlaybackStateSyncType) {\n            Manually -> {\n                setPlaybackState(isPlaying)\n                seekTo(lastPosition.coerceAtLeast(0))\n            }\n\n            Auto -> {\n                setPlaybackState(lastPlaybackState)\n            }\n        }\n    }\n\n    override val isActive: Boolean get() = player.isActive\n\n    override fun setSong(song: Song?): Boolean {\n        lastLyricType = LastLyricType.SONG\n        lastSong = song\n        return player.setSong(song)\n    }\n\n    override fun setPlaybackState(playing: Boolean): Boolean {\n        lastPlaybackStateSyncType = Manually\n        isPlaying = playing\n        return player.setPlaybackState(playing)\n    }\n\n    override fun seekTo(position: Long): Boolean {\n        lastPlaybackStateSyncType = Manually\n        lastPosition = position\n        return player.seekTo(position)\n    }\n\n    override fun setPosition(position: Long): Boolean {\n        lastPlaybackStateSyncType = Manually\n        lastPosition = position\n        return player.setPosition(position)\n    }\n\n    override fun setPositionUpdateInterval(interval: Int): Boolean {\n        lastPositionUpdateInterval = interval\n        return player.setPositionUpdateInterval(interval)\n    }\n\n    override fun sendText(text: String?): Boolean {\n        lastLyricType = LastLyricType.TEXT\n        lastText = text\n        return player.sendText(text)\n    }\n\n    override fun setDisplayTranslation(isDisplayTranslation: Boolean): Boolean {\n        lastDisplayTranslation = isDisplayTranslation\n        return player.setDisplayTranslation(isDisplayTranslation)\n    }\n\n    override fun setDisplayRoma(isDisplayRoma: Boolean): Boolean {\n        lastDisplayRoma = isDisplayRoma\n        return player.setDisplayRoma(isDisplayRoma)\n    }\n\n    override fun setPlaybackState(state: PlaybackState?): Boolean {\n        lastPlaybackStateSyncType = Auto\n        lastPlaybackState = state\n        return player.setPlaybackState(state)\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/CentralServiceReceiver.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.content.BroadcastReceiver\nimport android.content.Context\nimport android.content.Intent\nimport android.content.IntentFilter\nimport androidx.core.content.ContextCompat\nimport java.util.concurrent.CopyOnWriteArraySet\n\n/**\n * 中央服务状态广播接收器，用于协调服务启动状态的通知。\n */\ninternal object CentralServiceReceiver {\n\n    @Volatile\n    var isInitialized = false\n        private set\n\n    private val listeners = CopyOnWriteArraySet<ServiceListener>()\n\n    /**\n     * 内部广播处理器，过滤并分发指定的系统或应用广播。\n     */\n    private val innerReceiver = object : BroadcastReceiver() {\n        override fun onReceive(context: Context?, intent: Intent?) {\n            if (intent?.action == ProviderConstants.ACTION_CENTRAL_BOOT_COMPLETED) {\n                notifyServiceBootCompleted()\n            }\n        }\n    }\n\n    /**\n     * 注册服务启动监听器。\n     */\n    fun addServiceListener(listener: ServiceListener) {\n        listeners.add(listener)\n    }\n\n    /**\n     * 移除服务启动监听器。\n     */\n    fun removeServiceListener(listener: ServiceListener) {\n        listeners.remove(listener)\n    }\n\n    /**\n     * 执行广播接收器的初始化与系统注册。\n     *\n     * @param context 建议传入 Application Context。\n     */\n    fun initialize(context: Context) {\n        if (isInitialized) return\n\n        synchronized(this) {\n            if (isInitialized) return\n\n            val filter = IntentFilter(ProviderConstants.ACTION_CENTRAL_BOOT_COMPLETED)\n            ContextCompat.registerReceiver(\n                context.applicationContext,\n                innerReceiver,\n                filter,\n                ContextCompat.RECEIVER_EXPORTED\n            )\n            isInitialized = true\n        }\n    }\n\n    /**\n     * 遍历并回调所有已注册监听器的启动完成事件。\n     */\n    fun notifyServiceBootCompleted() {\n        for (listener in listeners) {\n            listener.onServiceBootCompleted()\n        }\n    }\n\n    /**\n     * 服务状态变更回调接口。\n     */\n    interface ServiceListener {\n        /**\n         * 当接收到中央服务启动完成信号时触发。\n         */\n        fun onServiceBootCompleted()\n    }\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ConnectionListener.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\n/**\n * 中央服务连接状态监听器。\n */\ninterface ConnectionListener {\n\n    /**\n     * 当提供者与中心服务首次成功建立连接时回调。\n     *\n     * @param provider 触发回调的提供者实例\n     */\n    fun onConnected(provider: LyriconProvider)\n\n    /**\n     * 当提供者在断开后重新建立连接时回调。\n     *\n     * @param provider 触发回调的提供者实例\n     */\n    fun onReconnected(provider: LyriconProvider)\n\n    /**\n     * 当提供者与中心服务连接断开时回调。\n     *\n     * @param provider 触发回调的提供者实例\n     */\n    fun onDisconnected(provider: LyriconProvider)\n\n    /**\n     * 当提供者在规定时间内未能完成连接注册时回调。\n     *\n     * @param provider 触发回调的提供者实例\n     */\n    fun onConnectTimeout(provider: LyriconProvider)\n}"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ConnectionStatus.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\n@file:Suppress(\"unused\")\n\npackage io.github.proify.lyricon.provider\n\n/** 提供端与中心服务之间的连接状态。 */\nenum class ConnectionStatus {\n    /** 未连接或被内部替换连接。 */\n    DISCONNECTED,\n\n    /** 远端 Binder 死亡或中心服务主动断开。 */\n    DISCONNECTED_REMOTE,\n\n    /** 用户主动调用 [LyriconProvider.unregister] 断开。 */\n    DISCONNECTED_USER,\n\n    /** 注册广播已发送，正在等待中心服务回调。 */\n    CONNECTING,\n\n    /** 已完成注册并绑定远端服务。 */\n    CONNECTED,\n}\n\n/** 是否处于任意断开状态。 */\nfun ConnectionStatus.isDisconnected(): Boolean =\n    this == ConnectionStatus.DISCONNECTED\n            || isDisconnectedByRemote()\n            || isDisconnectedByUser()\n\n/** 是否由本地主动断开。 */\nfun ConnectionStatus.isDisconnectedByUser(): Boolean = this == ConnectionStatus.DISCONNECTED_USER\n\n/** 是否由远端断开。 */\nfun ConnectionStatus.isDisconnectedByRemote(): Boolean =\n    this == ConnectionStatus.DISCONNECTED_REMOTE\n\n/** 是否已连接。 */\nfun ConnectionStatus.isConnected(): Boolean = this == ConnectionStatus.CONNECTED\n\n/** 是否正在连接。 */\nfun ConnectionStatus.isConnecting(): Boolean = this == ConnectionStatus.CONNECTING\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/Extensions.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport kotlinx.serialization.json.Json\nimport java.io.ByteArrayOutputStream\nimport java.util.zip.Deflater\n\n/** 模块内统一使用的宽松 JSON 编解码器。 */\ninternal val json: Json = Json {\n    coerceInputValues = true     // 尝试转换类型\n    ignoreUnknownKeys = true     // 忽略未知字段\n    isLenient = true             // 宽松的 JSON 语法\n    explicitNulls = false        // 不序列化 null\n    encodeDefaults = false       // 不序列化默认值\n}\n\n/** 使用 ZLIB 压缩字节数组。 */\ninternal fun ByteArray.deflate(): ByteArray {\n    if (isEmpty()) return byteArrayOf()\n\n    return Deflater().run {\n        setInput(this@deflate)\n        finish()\n\n        ByteArrayOutputStream().use { output ->\n            val buffer = ByteArray(4096)\n            while (!finished()) {\n                output.write(buffer, 0, deflate(buffer))\n            }\n            output.toByteArray()\n        }.also { end() }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/LocalProviderService.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.content.Intent\nimport android.os.Bundle\n\n/** 将 [ProviderService] 适配为提供给中心服务调用的 AIDL Binder。 */\ninternal class LocalProviderService(var callback: ProviderService? = null) :\n    IProviderService.Stub() {\n\n    override fun onRunCommand(intent: Intent?): Bundle? = callback?.onRunCommand(intent)\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/LyriconFactory.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.app.ActivityManager\nimport android.app.Application\nimport android.content.Context\nimport android.os.Build\nimport io.github.proify.lyricon.provider.impl.EmptyProvider\nimport io.github.proify.lyricon.provider.impl.LyriconProviderImpl\n\n/** 创建 [LyriconProvider] 的工厂。 */\nobject LyriconFactory {\n\n    /**\n     * 使用基础字段创建歌词提供端。\n     *\n     * Android 8.1 以下系统不支持当前 Binder/SharedMemory 通道，会返回空实现。\n     *\n     * @param context 用于注册中心服务广播接收器和读取进程信息的上下文。\n     * @param providerPackageName 提供端应用包名，默认使用当前应用包名。\n     * @param playerPackageName 播放器应用包名，默认与 [providerPackageName] 相同。\n     * @param logo 提供端或播放器图标。\n     * @param metadata 提供端附加元数据。\n     * @param processName 播放器进程名，默认读取当前进程名。\n     * @param providerService 暴露给中心服务调用的本地命令处理器。\n     * @param centralPackageName 中心服务所在包名。\n     */\n    fun createProvider(\n        context: Context,\n        providerPackageName: String = context.packageName,\n        playerPackageName: String = providerPackageName,\n        logo: ProviderLogo? = null,\n        metadata: ProviderMetadata? = null,\n        processName: String? = getCurrentProcessName(context),\n        providerService: ProviderService? = null,\n        centralPackageName: String = ProviderConstants.SYSTEM_UI_PACKAGE_NAME,\n    ): LyriconProvider = createProvider(\n        context,\n        ProviderInfo(\n            providerPackageName = providerPackageName,\n            playerPackageName = playerPackageName,\n            logo = logo,\n            metadata = metadata,\n            processName = processName\n        ),\n        providerService,\n        centralPackageName\n    )\n\n    /**\n     * 使用完整 [ProviderInfo] 创建歌词提供端。\n     *\n     * @param context 用于注册中心服务广播接收器的上下文。\n     * @param providerInfo 提供端注册信息。\n     * @param providerService 暴露给中心服务调用的本地命令处理器。\n     * @param centralPackageName 中心服务所在包名。\n     * @return 可用于注册中心服务的提供端实例。\n     */\n    fun createProvider(\n        context: Context,\n        providerInfo: ProviderInfo,\n        providerService: ProviderService? = null,\n        centralPackageName: String = ProviderConstants.SYSTEM_UI_PACKAGE_NAME,\n    ): LyriconProvider {\n\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {\n            initialize(context)\n\n            return LyriconProviderImpl(\n                context,\n                providerInfo,\n                providerService,\n                centralPackageName\n            )\n        }\n\n        return EmptyProvider(providerInfo)\n    }\n\n    private fun initialize(context: Context) {\n        if (!CentralServiceReceiver.isInitialized) {\n            CentralServiceReceiver.initialize(context)\n        }\n    }\n\n    private fun getCurrentProcessName(context: Context): String? {\n        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {\n            Application.getProcessName()\n        } else {\n            val pid = android.os.Process.myPid()\n            val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager\n            am.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/LyriconProvider.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport io.github.proify.lyricon.provider.service.RemoteService\n\n/**\n * Lyricon 提供端入口。\n *\n * 提供端负责把播放器状态、歌曲和歌词显示配置发送给中心服务。实例通常由\n * [LyriconFactory.createProvider] 创建。\n */\ninterface LyriconProvider {\n    /** 注册到中心服务时上报的提供端信息。 */\n    val providerInfo: ProviderInfo\n\n    /** 与中心服务的远端连接入口，可读取连接状态并注册连接监听。 */\n    val service: RemoteService\n\n    /** 播放器状态发送入口。 */\n    val player: RemotePlayer\n\n    /** 连接或重连成功后是否自动同步最近一次缓存的播放器状态。 */\n    var autoSync: Boolean\n\n    /** 暴露给中心服务调用的本地命令处理器。 */\n    var providerService: ProviderService?\n\n    /** 向中心服务发送注册请求。 */\n    fun register(): Boolean\n\n    /** 主动断开当前中心服务连接。 */\n    fun unregister(): Boolean\n\n    /** 释放监听器、注册回调和远端连接资源。 */\n    fun destroy(): Boolean\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ProviderBinder.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport io.github.proify.lyricon.provider.service.RemoteServiceBinder\nimport kotlinx.serialization.encodeToString\nimport java.util.concurrent.CopyOnWriteArraySet\n\n/**\n * 提供端暴露给中心服务的 AIDL 适配器。\n *\n * 该类负责向中心服务提供注册信息、本地服务 Binder，并接收中心服务返回的远端服务 Binder。\n */\ninternal class ProviderBinder(\n    private val provider: LyriconProvider,\n    private val localProviderService: LocalProviderService,\n    private val remoteServiceBinder: RemoteServiceBinder<IRemoteService?>?\n) : IProviderBinder.Stub() {\n    private val registrationCallbacks = CopyOnWriteArraySet<OnRegistrationCallback>()\n\n    private val providerInfoByteArray by lazy {\n        json.encodeToString(provider.providerInfo).toByteArray()\n    }\n\n    /** 添加注册完成回调。 */\n    fun addRegistrationCallback(callback: OnRegistrationCallback) =\n        registrationCallbacks.add(callback)\n\n    /** 移除注册完成回调。 */\n    fun removeRegistrationCallback(callback: OnRegistrationCallback) =\n        registrationCallbacks.remove(callback)\n\n    override fun onRegistrationCallback(remoteProviderService: IRemoteService?) {\n        remoteServiceBinder?.bindRemoteService(remoteProviderService)\n        registrationCallbacks.forEach { it.onRegistered() }\n    }\n\n    override fun getProviderService(): IProviderService = localProviderService\n    override fun getProviderInfo(): ByteArray = providerInfoByteArray\n\n    /** 中心服务完成注册后触发的内部回调。 */\n    interface OnRegistrationCallback {\n        fun onRegistered()\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ProviderConstants.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\npackage io.github.proify.lyricon.provider\n\n/** 提供端与中心服务交互使用的常量。 */\nobject ProviderConstants {\n\n    /** 默认播放进度写入间隔，约 24 FPS。 */\n    const val DEFAULT_POSITION_UPDATE_INTERVAL: Long = 1000L / 24\n\n    internal const val DEBUG: Boolean = false\n\n    /** 注册提供端广播动作。 */\n    internal const val ACTION_REGISTER_PROVIDER: String =\n        \"io.github.proify.lyricon.lyric.bridge.REGISTER_PROVIDER\"\n\n    /** 中心服务启动完成广播动作。 */\n    internal const val ACTION_CENTRAL_BOOT_COMPLETED: String =\n        \"io.github.proify.lyricon.lyric.bridge.CENTRAL_BOOT_COMPLETED\"\n\n    /** 广播中承载 Binder 的 Bundle key。 */\n    internal const val EXTRA_BUNDLE: String = \"bundle\"\n\n    /** Bundle 中提供端 Binder 的 key。 */\n    internal const val EXTRA_BINDER: String = \"binder\"\n\n    /** 默认中心服务包名。 */\n    const val SYSTEM_UI_PACKAGE_NAME: String = \"com.android.systemui\"\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ProviderInfo.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.os.Parcelable\nimport kotlinx.parcelize.Parcelize\nimport kotlinx.serialization.Serializable\n\n/**\n * 提供端注册信息。\n *\n * 中心服务通过该对象识别歌词提供端。相等性仅比较包名、播放器包名和进程名，\n * [logo] 与 [metadata] 只作为展示信息，不参与身份判断。\n *\n * @property providerPackageName 提供端应用包名。\n * @property playerPackageName 播放器应用包名。\n * @property logo 提供端或播放器图标。\n * @property metadata 提供端附加元数据。\n * @property processName 播放器所在进程名。\n */\n@Serializable\n@Parcelize\ndata class ProviderInfo(\n    val providerPackageName: String,\n    val playerPackageName: String,\n    val logo: ProviderLogo? = null,\n    val metadata: ProviderMetadata? = null,\n    val processName: String? = null\n) : Parcelable {\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (other !is ProviderInfo) return false\n        return providerPackageName == other.providerPackageName\n                && playerPackageName == other.playerPackageName\n                && processName == other.processName\n    }\n\n    override fun hashCode(): Int {\n        var result = providerPackageName.hashCode()\n        result = 31 * result + playerPackageName.hashCode()\n        result = 31 * result + processName.hashCode()\n        return result\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ProviderLogo.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\n@file:Suppress(\"unused\", \"MemberVisibilityCanBePrivate\")\n\npackage io.github.proify.lyricon.provider\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.graphics.Bitmap.Config\nimport android.graphics.BitmapFactory\nimport android.graphics.drawable.Drawable\nimport android.os.Parcelable\nimport androidx.annotation.DrawableRes\nimport androidx.annotation.Px\nimport androidx.appcompat.content.res.AppCompatResources\nimport androidx.core.graphics.drawable.toBitmap\nimport io.github.proify.lyricon.provider.ProviderLogo.Companion.TYPE_BITMAP\nimport io.github.proify.lyricon.provider.ProviderLogo.Companion.TYPE_SVG\nimport kotlinx.parcelize.Parcelize\nimport kotlinx.serialization.Serializable\nimport java.io.ByteArrayOutputStream\nimport kotlin.io.encoding.Base64\n\n/**\n * 提供端图标数据。\n *\n * 支持位图和 SVG 两种格式。该类会跨进程传输，因此只保存原始字节和格式标记。\n *\n * @property data 图标原始字节数据。\n * @property type 图标类型，取值见 [TYPE_BITMAP]、[TYPE_SVG]。\n * @property colorful 是否为彩色图标；中心服务可据此决定是否应用着色。\n */\n@Serializable\n@Parcelize\ndata class ProviderLogo(\n    val data: ByteArray,\n    val type: Int,\n    val colorful: Boolean = false\n) : Parcelable {\n\n    /** 将位图格式的 [data] 解码为 [Bitmap]，非 [TYPE_BITMAP] 类型返回 `null`。 */\n    fun toBitmap(): Bitmap? = if (type == TYPE_BITMAP) {\n        runCatching {\n            BitmapFactory.decodeByteArray(\n                data, 0, data.size,\n                BitmapFactory.Options().apply {\n                    inPreferredConfig = Config.ARGB_8888\n                }\n            )\n        }.getOrNull()\n    } else null\n\n    /** 将 SVG 格式的 [data] 解码为字符串，非 [TYPE_SVG] 类型返回 `null`。 */\n    fun toSvg(): String? = if (type == TYPE_SVG) data.toString(Charsets.UTF_8) else null\n\n    companion object {\n        /** PNG/Bitmap 字节图标。 */\n        const val TYPE_BITMAP: Int = 0\n\n        /** SVG 文本图标。 */\n        const val TYPE_SVG: Int = 1\n\n        /**\n         * 由 [Bitmap] 构建 [ProviderLogo]。\n         *\n         * @param bitmap 源 Bitmap\n         * @param recycle 是否回收源 Bitmap\n         */\n        fun fromBitmap(bitmap: Bitmap, recycle: Boolean = true): ProviderLogo =\n            ProviderLogo(bitmap.toPngBytes(recycle), TYPE_BITMAP)\n\n        /** 由 [Drawable] 构建 [ProviderLogo]。 */\n        fun fromDrawable(\n            drawable: Drawable,\n            @Px width: Int = drawable.intrinsicWidth,\n            @Px height: Int = drawable.intrinsicHeight,\n            config: Config? = null,\n        ): ProviderLogo =\n            fromBitmap(drawable.toBitmap(width, height, config))\n\n        /** 由 drawable 资源 ID 构建 [ProviderLogo]。 */\n        fun fromDrawable(\n            context: Context,\n            @DrawableRes id: Int,\n            @Px width: Int = -1,\n            @Px height: Int = -1,\n            config: Config? = null,\n        ): ProviderLogo {\n            val drawable = AppCompatResources.getDrawable(context, id)\n            require(drawable != null) { \"Drawable not found\" }\n            return if (width > 0 && height > 0) fromBitmap(drawable.toBitmap(width, height, config))\n            else fromBitmap(drawable.toBitmap(config = config))\n        }\n\n        /** 由 SVG 字符串构建 [ProviderLogo]。 */\n        fun fromSvg(svg: String): ProviderLogo =\n            ProviderLogo(svg.toByteArray(Charsets.UTF_8), TYPE_SVG)\n\n        /** 由 Base64 编码的 PNG 数据构建 [ProviderLogo]。 */\n        fun fromBase64(base64: String): ProviderLogo =\n            ProviderLogo(Base64.decode(base64), TYPE_BITMAP)\n\n        /** 将 Bitmap 转为 PNG 字节数组 */\n        private fun Bitmap.toPngBytes(recycle: Boolean): ByteArray =\n            ByteArrayOutputStream().use { out ->\n                compress(Bitmap.CompressFormat.PNG, 100, out)\n                out.toByteArray()\n            }.also { if (recycle) recycle() }\n\n        /** 获取图标类型名称 */\n        internal fun typeName(type: Int): String =\n            when (type) {\n                TYPE_BITMAP -> \"Bitmap\"\n                TYPE_SVG -> \"SVG\"\n                else -> \"Unknown\"\n            }\n    }\n\n    override fun toString(): String =\n        \"ProviderLogo(type=${typeName(type)}, colorful=$colorful, data=${data.size} bytes)\"\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (javaClass != other?.javaClass) return false\n\n        other as ProviderLogo\n\n        if (type != other.type) return false\n        if (colorful != other.colorful) return false\n        if (!data.contentEquals(other.data)) return false\n\n        return true\n    }\n\n    override fun hashCode(): Int {\n        var result = type\n        result = 31 * result + colorful.hashCode()\n        result = 31 * result + data.contentHashCode()\n        return result\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ProviderMetadata.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n@file:Suppress(\"unused\")\n\npackage io.github.proify.lyricon.provider\n\nimport android.os.Parcelable\nimport kotlinx.parcelize.Parcelize\nimport kotlinx.serialization.Serializable\n\n/**\n * 提供端元数据。\n *\n * 用键值对携带额外展示或能力信息。该类型委托实现 [Map]，可直接按普通 Map 读取。\n *\n * @property map 元数据键值对。\n */\n@Parcelize\n@Serializable\nclass ProviderMetadata(\n    private val map: Map<String, String?> = emptyMap()\n) : Map<String, String?> by map, Parcelable\n\n/** 使用键值对快速创建 [ProviderMetadata]。 */\nfun providerMetadataOf(vararg pairs: Pair<String, String?>): ProviderMetadata =\n    ProviderMetadata(mapOf(*pairs))\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/ProviderService.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.content.Intent\nimport android.os.Bundle\n\n/**\n * 提供端本地命令处理器。\n *\n * 中心服务可通过该接口向提供端发起扩展命令。当前核心歌词同步流程不依赖该接口，\n * 它主要用于后续能力扩展。\n */\ninterface ProviderService {\n\n    /**\n     * 处理中心服务发送的命令。\n     *\n     * @param intent 命令参数，可为空。\n     * @return 命令结果，可为空。\n     */\n    fun onRunCommand(intent: Intent?): Bundle?\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/RemotePlayer.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider\n\nimport android.media.session.PlaybackState\nimport androidx.annotation.IntRange\nimport io.github.proify.lyricon.lyric.model.RichLyricLine\nimport io.github.proify.lyricon.lyric.model.Song\n\n/**\n * 远端播放器状态发送接口。\n *\n * 提供端通过该接口把歌曲、播放状态、播放位置和歌词显示配置同步给中心服务。\n */\ninterface RemotePlayer {\n\n    /**\n     * 检查远程播放器连接是否仍然有效。\n     */\n    val isActive: Boolean\n\n    /**\n     * 设置远程播放器当前播放的歌曲信息。\n     *\n     * @param song 歌曲对象，null 表示清空当前播放\n     * @return 命令是否成功发送\n     */\n    fun setSong(song: Song?): Boolean\n\n    /**\n     * 设置远程播放器的播放状态。\n     *\n     * @param playing true 表示播放中，false 表示暂停\n     * @return 命令是否成功发送\n     */\n    fun setPlaybackState(playing: Boolean): Boolean\n\n    /**\n     * 立即跳转到指定播放位置。\n     *\n     * 通常在用户拖动进度条或主动调整播放位置时调用。\n     *\n     * @param position 播放位置，单位毫秒，最小值为 0\n     * @return 操作是否成功\n     */\n    fun seekTo(@IntRange(from = 0) position: Long): Boolean\n\n    /**\n     * 更新播放位置到共享内存待读取区。\n     *\n     * @param position 播放位置，单位毫秒，最小值为 0。\n     * @return 是否成功写入。\n     * @see setPositionUpdateInterval\n     */\n    fun setPosition(@IntRange(from = 0) position: Long): Boolean\n\n    /**\n     * 设置中心服务读取播放位置的间隔，一般不用修改。\n     *\n     * @param interval 间隔毫秒数。\n     * @return 命令是否成功发送。\n     */\n    fun setPositionUpdateInterval(@IntRange(from = 0) interval: Int): Boolean\n\n    /**\n     * 向远程播放器发送文本消息。\n     *\n     * 调用此方法会清除之前设置的歌曲信息，播放器进入纯文本模式。\n     *\n     * @param text 要发送的文本内容，可为 null\n     * @return 命令是否成功发送\n     */\n    fun sendText(text: String?): Boolean\n\n    /**\n     * 设置是否显示翻译。\n     *\n     * 如果 [RichLyricLine] 中有翻译信息，则中心服务可显示翻译内容。\n     *\n     * @param isDisplayTranslation 是否显示翻译。\n     * @return 命令是否成功发送。\n     */\n    fun setDisplayTranslation(isDisplayTranslation: Boolean): Boolean\n\n    /**\n     * 设置是否显示罗马音。\n     *\n     * 如果 [RichLyricLine] 中有罗马音信息，则中心服务可显示罗马音内容。\n     *\n     * @param isDisplayRoma 是否显示罗马音。\n     * @return 命令是否成功发送。\n     */\n    fun setDisplayRoma(isDisplayRoma: Boolean): Boolean\n\n    /**\n     * 使用 [PlaybackState] 同步播放状态。\n     *\n     * 中心服务可根据 [PlaybackState.position]、播放速度和更新时间计算实时进度。\n     *\n     * @param state 播放状态，传入 `null` 表示停止使用该模式。\n     * @return 命令是否成功发送。\n     */\n    fun setPlaybackState(state: PlaybackState?): Boolean\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/impl/EmptyProvider.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider.impl\n\nimport android.media.session.PlaybackState\nimport io.github.proify.lyricon.lyric.model.Song\nimport io.github.proify.lyricon.provider.ConnectionListener\nimport io.github.proify.lyricon.provider.ConnectionStatus\nimport io.github.proify.lyricon.provider.LyriconProvider\nimport io.github.proify.lyricon.provider.ProviderInfo\nimport io.github.proify.lyricon.provider.ProviderService\nimport io.github.proify.lyricon.provider.RemotePlayer\nimport io.github.proify.lyricon.provider.service.RemoteService\n\n/** 不支持当前运行环境时返回的提供端空实现。 */\nclass EmptyProvider(override val providerInfo: ProviderInfo) : LyriconProvider {\n    override val service: RemoteService = EmptyRemoteService\n    override val player = service.player\n    override var autoSync: Boolean = true\n    override var providerService: ProviderService? = null\n    override fun register(): Boolean = false\n    override fun unregister() = false\n    override fun destroy() = false\n\n    private object EmptyRemoteService : RemoteService {\n        override val player: RemotePlayer = EmptyRemotePlayer\n        override val isActive: Boolean = false\n        override val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED\n        override fun addConnectionListener(listener: ConnectionListener): Boolean = false\n        override fun removeConnectionListener(listener: ConnectionListener): Boolean = false\n    }\n\n    private object EmptyRemotePlayer : RemotePlayer {\n        override val isActive: Boolean = false\n        override fun setSong(song: Song?): Boolean = false\n        override fun setPlaybackState(playing: Boolean): Boolean = false\n        override fun seekTo(position: Long): Boolean = false\n        override fun setPosition(position: Long): Boolean = false\n        override fun setPositionUpdateInterval(interval: Int): Boolean = false\n        override fun sendText(text: String?): Boolean = false\n        override fun setDisplayTranslation(isDisplayTranslation: Boolean): Boolean = false\n        override fun setDisplayRoma(isDisplayRoma: Boolean): Boolean = false\n        override fun setPlaybackState(state: PlaybackState?): Boolean = false\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/impl/LyriconProviderImpl.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider.impl\n\nimport android.content.Context\nimport android.content.Intent\nimport android.os.Build\nimport android.os.Bundle\nimport androidx.annotation.RequiresApi\nimport io.github.proify.lyricon.provider.CentralServiceReceiver\nimport io.github.proify.lyricon.provider.ConnectionListener\nimport io.github.proify.lyricon.provider.ConnectionStatus\nimport io.github.proify.lyricon.provider.LocalProviderService\nimport io.github.proify.lyricon.provider.LyriconProvider\nimport io.github.proify.lyricon.provider.ProviderBinder\nimport io.github.proify.lyricon.provider.ProviderConstants\nimport io.github.proify.lyricon.provider.ProviderConstants.ACTION_REGISTER_PROVIDER\nimport io.github.proify.lyricon.provider.ProviderConstants.EXTRA_BINDER\nimport io.github.proify.lyricon.provider.ProviderInfo\nimport io.github.proify.lyricon.provider.ProviderService\nimport io.github.proify.lyricon.provider.RemotePlayer\nimport io.github.proify.lyricon.provider.isConnecting\nimport io.github.proify.lyricon.provider.service.RemoteService\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.cancel\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\nimport java.util.concurrent.atomic.AtomicBoolean\n\n/**\n * 默认提供端实现。\n *\n * 负责发送注册广播、处理连接超时、维护本地服务 Binder，并把远端服务交给\n * [ProviderRemoteEndpoint] 管理。\n */\n@RequiresApi(Build.VERSION_CODES.O_MR1)\ninternal class LyriconProviderImpl(\n    private val context: Context,\n    override val providerInfo: ProviderInfo,\n    providerService: ProviderService? = null,\n    private val centralPackageName: String,\n) : LyriconProvider, ConnectionListener {\n    private val destroyed = AtomicBoolean(false)\n    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)\n    private val localService = LocalProviderService(providerService)\n    private val remote = ProviderRemoteEndpoint(this)\n    private val registration = Registration()\n    private val binder = ProviderBinder(this, localService, remote)\n\n    override var providerService: ProviderService? = providerService\n        set(value) {\n            field = value\n            localService.callback = value\n        }\n\n    override val service: RemoteService = remote\n    override val player: RemotePlayer get() = service.player\n    override var autoSync: Boolean = true\n\n    init {\n        service.addConnectionListener(this)\n    }\n\n    override fun register(): Boolean = registration.start()\n\n    override fun unregister(): Boolean {\n        if (destroyed.get()) return false\n        disconnect(ProviderRemoteEndpoint.DisconnectReason.USER)\n        return true\n    }\n\n    override fun destroy(): Boolean {\n        if (!destroyed.compareAndSet(false, true)) return false\n        registration.close()\n        disconnect(ProviderRemoteEndpoint.DisconnectReason.USER)\n        service.removeConnectionListener(this)\n        scope.cancel()\n        return true\n    }\n\n    override fun onConnected(provider: LyriconProvider) {\n        if (autoSync) remote.syncPlayer()\n    }\n\n    override fun onReconnected(provider: LyriconProvider) {\n        if (autoSync) remote.syncPlayer()\n    }\n\n    override fun onDisconnected(provider: LyriconProvider) = Unit\n    override fun onConnectTimeout(provider: LyriconProvider) = Unit\n\n    private fun disconnect(reason: ProviderRemoteEndpoint.DisconnectReason) {\n        registration.cancelTimeout()\n        remote.disconnect(reason)\n    }\n\n    /** 管理注册广播、超时和中心服务重启后的恢复注册。 */\n    private inner class Registration : CentralServiceReceiver.ServiceListener {\n        private var timeoutJob: Job? = null\n        private val callback = object : ProviderBinder.OnRegistrationCallback {\n            override fun onRegistered() {\n                cancelTimeout()\n                binder.removeRegistrationCallback(this)\n            }\n        }\n\n        init {\n            CentralServiceReceiver.addServiceListener(this)\n        }\n\n        fun start(): Boolean {\n            if (destroyed.get() || centralPackageName.isBlank()) return false\n            if (remote.connectionStatus in setOf(\n                    ConnectionStatus.CONNECTED,\n                    ConnectionStatus.CONNECTING\n                )\n            ) {\n                return false\n            }\n\n            remote.connectionStatus = ConnectionStatus.CONNECTING\n            binder.addRegistrationCallback(callback)\n            scheduleTimeout()\n            context.sendBroadcast(Intent(ACTION_REGISTER_PROVIDER).apply {\n                setPackage(centralPackageName)\n                putExtra(\n                    ProviderConstants.EXTRA_BUNDLE,\n                    Bundle().apply {\n                        putBinder(EXTRA_BINDER, binder)\n                    }\n                )\n            })\n            return true\n        }\n\n        override fun onServiceBootCompleted() {\n            if (remote.connectionStatus == ConnectionStatus.DISCONNECTED_REMOTE) start()\n        }\n\n        fun cancelTimeout() {\n            timeoutJob?.cancel()\n            timeoutJob = null\n        }\n\n        fun close() {\n            cancelTimeout()\n            binder.removeRegistrationCallback(callback)\n            CentralServiceReceiver.removeServiceListener(this)\n        }\n\n        private fun scheduleTimeout() {\n            cancelTimeout()\n            timeoutJob = scope.launch {\n                delay(CONNECTION_TIMEOUT_MS)\n                if (!remote.connectionStatus.isConnecting()) return@launch\n                remote.connectionStatus = ConnectionStatus.DISCONNECTED\n                binder.removeRegistrationCallback(callback)\n                remote.forEachConnectionListener { it.onConnectTimeout(this@LyriconProviderImpl) }\n            }\n        }\n    }\n\n    private companion object {\n        private const val CONNECTION_TIMEOUT_MS = 4_000L\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/impl/ProviderRemoteEndpoint.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider.impl\n\nimport android.os.Build\nimport android.os.IBinder\nimport android.os.RemoteException\nimport android.util.Log\nimport androidx.annotation.RequiresApi\nimport io.github.proify.lyricon.provider.CachedRemotePlayer\nimport io.github.proify.lyricon.provider.ConnectionListener\nimport io.github.proify.lyricon.provider.ConnectionStatus\nimport io.github.proify.lyricon.provider.IRemoteService\nimport io.github.proify.lyricon.provider.LyriconProvider\nimport io.github.proify.lyricon.provider.ProviderConstants\nimport io.github.proify.lyricon.provider.RemotePlayer\nimport io.github.proify.lyricon.provider.isConnected\nimport io.github.proify.lyricon.provider.service.RemoteService\nimport io.github.proify.lyricon.provider.service.RemoteServiceBinder\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport java.util.concurrent.CopyOnWriteArraySet\n\n/**\n * 提供端远端连接端点。\n *\n * 负责维护中心服务返回的 [IRemoteService]、监听 Binder 死亡、分发连接状态，\n * 并向外提供缓存后的 [RemotePlayer]。\n */\n@RequiresApi(Build.VERSION_CODES.O_MR1)\ninternal class ProviderRemoteEndpoint(\n    private val provider: LyriconProvider,\n) : RemoteService, RemoteServiceBinder<IRemoteService?> {\n    private val playerProxy = RemotePlayerProxy()\n    private val playerCache = CachedRemotePlayer(playerProxy)\n    private val listeners = CopyOnWriteArraySet<ConnectionListener>()\n    private val callbackScope = CoroutineScope(Dispatchers.Main.immediate)\n    private val deathRecipient = IBinder.DeathRecipient { disconnect(DisconnectReason.REMOTE) }\n\n    @Volatile\n    private var remoteService: IRemoteService? = null\n\n    private var hasConnectedHistory = false\n\n    override val player: RemotePlayer = playerCache\n\n    @Volatile\n    override var connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED\n        set(value) {\n            field = value\n            playerProxy.allowSending = value.isConnected()\n        }\n\n    override val isActive: Boolean\n        get() = remoteService?.asBinder()?.isBinderAlive == true\n\n    /** 绑定中心服务返回的远端服务 Binder。 */\n    override fun bindRemoteService(service: IRemoteService?) {\n        if (ProviderConstants.DEBUG) Log.d(TAG, \"Bind remote service\")\n        disconnect(DisconnectReason.REPLACE)\n\n        if (service == null) {\n            Log.w(TAG, \"Service is null\")\n            return\n        }\n        val binder = service.asBinder()\n        if (!binder.isBinderAlive) {\n            Log.w(TAG, \"Binder is not alive\")\n            return\n        }\n\n        try {\n            binder.linkToDeath(deathRecipient, 0)\n        } catch (e: RemoteException) {\n            Log.e(TAG, \"Failed to link death recipient\", e)\n            return\n        }\n\n        remoteService = service\n        playerProxy.bindRemoteService(service.player)\n        connectionStatus = ConnectionStatus.CONNECTED\n        dispatchConnected()\n    }\n\n    /** 将缓存的播放器状态同步到当前远端播放器。 */\n    fun syncPlayer() {\n        playerCache.syncs()\n    }\n\n    /** 遍历当前连接监听器，用于注册超时等外部状态分发。 */\n    inline fun forEachConnectionListener(block: (ConnectionListener) -> Unit) {\n        listeners.forEach(block)\n    }\n\n    /** 按指定原因断开当前远端服务。 */\n    fun disconnect(reason: DisconnectReason) {\n        connectionStatus = when (reason) {\n            DisconnectReason.USER -> ConnectionStatus.DISCONNECTED_USER\n            DisconnectReason.REMOTE -> ConnectionStatus.DISCONNECTED_REMOTE\n            DisconnectReason.REPLACE -> ConnectionStatus.DISCONNECTED\n        }\n\n        if (ProviderConstants.DEBUG) Log.d(TAG, \"Disconnect: $reason\")\n        playerProxy.bindRemoteService(null)\n\n        val service = remoteService ?: return\n        remoteService = null\n\n        runCatching { service.asBinder().unlinkToDeath(deathRecipient, 0) }\n            .onFailure { Log.w(TAG, \"Failed to unlink death recipient\", it) }\n        runCatching { service.disconnect() }\n            .onFailure { Log.e(TAG, \"Failed to disconnect remote service\", it) }\n\n        callbackScope.launch {\n            listeners.forEach { it.onDisconnected(provider) }\n        }\n    }\n\n    override fun addConnectionListener(listener: ConnectionListener): Boolean =\n        listeners.add(listener)\n\n    override fun removeConnectionListener(listener: ConnectionListener): Boolean =\n        listeners.remove(listener)\n\n    private fun dispatchConnected() {\n        callbackScope.launch {\n            listeners.forEach {\n                if (hasConnectedHistory) it.onReconnected(provider) else it.onConnected(provider)\n            }\n            hasConnectedHistory = true\n        }\n    }\n\n    /** 内部断开原因，用于映射为公开连接状态。 */\n    enum class DisconnectReason {\n        /** 用户主动断开。 */\n        USER,\n\n        /** 远端 Binder 死亡或服务主动断开。 */\n        REMOTE,\n\n        /** 新服务绑定前替换旧连接。 */\n        REPLACE\n    }\n\n    private companion object {\n        private const val TAG = \"ProviderRemoteEndpoint\"\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/impl/RemotePlayerProxy.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider.impl\n\nimport android.media.session.PlaybackState\nimport android.os.Build\nimport android.os.SharedMemory\nimport android.util.Log\nimport androidx.annotation.RequiresApi\nimport io.github.proify.lyricon.lyric.model.Song\nimport io.github.proify.lyricon.provider.IRemotePlayer\nimport io.github.proify.lyricon.provider.RemotePlayer\nimport io.github.proify.lyricon.provider.deflate\nimport io.github.proify.lyricon.provider.json\nimport java.nio.ByteBuffer\n\n/**\n * [RemotePlayer] 的 Binder 代理实现。\n *\n * 普通播放器命令通过 [IRemotePlayer] 发送，播放进度写入共享内存，减少高频 Binder 调用。\n */\n@RequiresApi(Build.VERSION_CODES.O_MR1)\ninternal class RemotePlayerProxy : RemotePlayer {\n    /** 当前连接状态是否允许发送播放器命令。 */\n    @Volatile\n    var allowSending: Boolean = false\n\n    private var remotePlayer: IRemotePlayer? = null\n    private var positionMemory: SharedMemory? = null\n    private var positionBuffer: ByteBuffer? = null\n\n    override val isActive: Boolean\n        get() = remotePlayer?.asBinder()?.isBinderAlive == true\n\n    /** 绑定或清空远端播放器 Binder。 */\n    fun bindRemoteService(player: IRemotePlayer?) {\n        closePositionMemory()\n        remotePlayer = player\n        positionMemory = runCatching { player?.positionMemory }\n            .onFailure { Log.e(TAG, \"Failed to get position memory\", it) }\n            .getOrNull()\n        positionBuffer = runCatching { positionMemory?.mapReadWrite() }\n            .onFailure { Log.e(TAG, \"Failed to map position memory\", it) }\n            .getOrNull()\n    }\n\n    override fun setSong(song: Song?): Boolean = send {\n        setSong(song?.let { json.encodeToString(it).toByteArray().deflate() })\n    }\n\n    override fun setPlaybackState(playing: Boolean): Boolean = send {\n        setPlaybackState(playing)\n    }\n\n    override fun seekTo(position: Long): Boolean = send {\n        seekTo(position.coerceAtLeast(0L))\n    }\n\n    override fun setPosition(position: Long): Boolean {\n        if (!allowSending) return false\n\n        return try {\n            positionBuffer?.putLong(0, position.coerceAtLeast(0L))\n            true\n        } catch (e: Exception) {\n            Log.e(TAG, \"Failed to write position\", e)\n            false\n        }\n    }\n\n    override fun setPositionUpdateInterval(interval: Int): Boolean = send {\n        setPositionUpdateInterval(interval.coerceAtLeast(0))\n    }\n\n    override fun sendText(text: String?): Boolean = send {\n        sendText(text)\n    }\n\n    override fun setDisplayTranslation(isDisplayTranslation: Boolean): Boolean = send {\n        setDisplayTranslation(isDisplayTranslation)\n    }\n\n    override fun setDisplayRoma(isDisplayRoma: Boolean): Boolean = send {\n        setDisplayRoma(isDisplayRoma)\n    }\n\n    override fun setPlaybackState(state: PlaybackState?): Boolean = send {\n        setPlaybackState2(state)\n    }\n\n    private inline fun send(block: IRemotePlayer.() -> Unit): Boolean {\n        val player = remotePlayer\n        if (!allowSending || player == null) return false\n\n        return try {\n            block(player)\n            true\n        } catch (it: Exception) {\n            Log.e(TAG, \"Failed to send player command\", it)\n            false\n        }\n    }\n\n    private fun closePositionMemory() {\n        positionBuffer = null\n        positionMemory?.close()\n        positionMemory = null\n    }\n\n    private companion object {\n        private const val TAG = \"RemotePlayerProxy\"\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/service/RemoteService.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider.service\n\nimport io.github.proify.lyricon.provider.ConnectionListener\nimport io.github.proify.lyricon.provider.ConnectionStatus\nimport io.github.proify.lyricon.provider.LyriconProvider\nimport io.github.proify.lyricon.provider.RemotePlayer\n\n/** 提供端连接中心服务后的远端服务入口。 */\ninterface RemoteService {\n\n    /** 播放器状态发送接口。 */\n    val player: RemotePlayer\n\n    /** 当前远端 Binder 是否仍然可用。 */\n    val isActive: Boolean\n\n    /** 当前连接状态。 */\n    val connectionStatus: ConnectionStatus\n\n    /**\n     * 注册连接状态监听器。\n     *\n     * @param listener 监听器实例。\n     * @return 是否成功添加。\n     */\n    fun addConnectionListener(listener: ConnectionListener): Boolean\n\n    /**\n     * 移除已注册的连接状态监听器。\n     *\n     * @param listener 之前注册的监听器实例。\n     * @return 是否成功移除。\n     */\n    fun removeConnectionListener(listener: ConnectionListener): Boolean\n}\n\n/**\n * 构建连接状态监听器的便捷函数。\n *\n * 使用 [ConnectionListenerBuilder] 定义各类事件回调。\n */\nfun buildConnectionListener(block: ConnectionListenerBuilder.() -> Unit): ConnectionListener {\n    val builder = ConnectionListenerBuilder().apply(block)\n    return object : ConnectionListener {\n        override fun onConnected(provider: LyriconProvider) {\n            builder.onConnected?.invoke(provider)\n        }\n\n        override fun onReconnected(provider: LyriconProvider) {\n            builder.onReconnected?.invoke(provider)\n        }\n\n        override fun onDisconnected(provider: LyriconProvider) {\n            builder.onDisconnected?.invoke(provider)\n        }\n\n        override fun onConnectTimeout(provider: LyriconProvider) {\n            builder.onConnectTimeout?.invoke(provider)\n        }\n    }\n}\n\n/**\n * 向 [RemoteService] 注册由 DSL 构建的连接状态监听器。\n *\n * @param block 使用 [ConnectionListenerBuilder] 定义回调\n * @return 注册的监听器实例\n */\nfun RemoteService.addConnectionListener(block: ConnectionListenerBuilder.() -> Unit)\n        : ConnectionListener {\n    val listener = buildConnectionListener(block)\n    addConnectionListener(listener)\n    return listener\n}\n\n/**\n * 连接状态监听器构建器。\n *\n * 用于按需设置各类连接状态回调。\n *\n * @property onConnected 服务首次连接回调\n * @property onReconnected 服务重连回调\n * @property onDisconnected 服务断开回调\n * @property onConnectTimeout 连接超时回调\n */\nclass ConnectionListenerBuilder(\n    var onConnected: ((LyriconProvider) -> Unit)? = null,\n    var onReconnected: ((LyriconProvider) -> Unit)? = null,\n    var onDisconnected: ((LyriconProvider) -> Unit)? = null,\n    var onConnectTimeout: ((LyriconProvider) -> Unit)? = null\n) {\n    fun onConnected(block: (LyriconProvider) -> Unit): ConnectionListenerBuilder =\n        apply { onConnected = block }\n\n    fun onReconnected(block: (LyriconProvider) -> Unit): ConnectionListenerBuilder =\n        apply { onReconnected = block }\n\n    fun onDisconnected(block: (LyriconProvider) -> Unit): ConnectionListenerBuilder =\n        apply { onDisconnected = block }\n\n    fun onConnectTimeout(block: (LyriconProvider) -> Unit): ConnectionListenerBuilder =\n        apply { onConnectTimeout = block }\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/java/io/github/proify/lyricon/provider/service/RemoteServiceBinder.kt",
    "content": "/*\n * Copyright 2026 Proify, Tomakino\n * Licensed under the Apache License, Version 2.0\n * http://www.apache.org/licenses/LICENSE-2.0\n */\n\npackage io.github.proify.lyricon.provider.service\n\n/**\n * 远端服务绑定器接口。\n *\n * 用于把 AIDL 注册回调返回的远端服务实例交给内部 endpoint。\n *\n * @param T 远程服务类型\n */\ninternal interface RemoteServiceBinder<T> {\n\n    /**\n     * 绑定远程服务实例。\n     *\n     * @param service 远程服务实例\n     */\n    fun bindRemoteService(service: T)\n}\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/baseline_download_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"24\"\n    android:viewportWidth=\"24\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_close_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"960\"\n    android:viewportWidth=\"960\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_lock_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"960\"\n    android:viewportWidth=\"960\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M240,880Q207,880 183.5,856.5Q160,833 160,800L160,400Q160,367 183.5,343.5Q207,320 240,320L280,320L280,240Q280,157 338.5,98.5Q397,40 480,40Q563,40 621.5,98.5Q680,157 680,240L680,320L720,320Q753,320 776.5,343.5Q800,367 800,400L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,400Q720,400 720,400Q720,400 720,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800ZM480,680Q513,680 536.5,656.5Q560,633 560,600Q560,567 536.5,543.5Q513,520 480,520Q447,520 423.5,543.5Q400,567 400,600Q400,633 423.5,656.5Q447,680 480,680ZM360,320L600,320L600,240Q600,190 565,155Q530,120 480,120Q430,120 395,155Q360,190 360,240L360,320ZM240,800Q240,800 240,800Q240,800 240,800L240,400Q240,400 240,400Q240,400 240,400L240,400Q240,400 240,400Q240,400 240,400L240,800Q240,800 240,800Q240,800 240,800Z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_lyrics_off_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\"\n    android:tint=\"?attr/colorControlNormal\">\n  <path\n      android:fillColor=\"@android:color/white\"\n      android:pathData=\"M644,512L588,454L710,360L480,182L386,254L330,196L480,80L840,360L644,512ZM759,626L701,568L774,512L840,562L759,626ZM792,884L632,724L480,842L120,562L186,512L480,740L574,667L517,611L480,640L120,360L203,295L55,149L112,92L848,828L792,884ZM487,354L487,354L487,354L487,354Z\"/>\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_pause_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"960\"\n    android:viewportWidth=\"960\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_play_arrow_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"960\"\n    android:viewportWidth=\"960\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_repeat_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"24\"\n    android:viewportHeight=\"24\"\n    android:tint=\"?attr/colorControlNormal\">\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M7,7h10v3l4,-4l-4,-4v3H5v6h2V7zM17,17H7v-3l-4,4l4,4v-3h12v-6h-2V17z\" />\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_repeat_off_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"24\"\n    android:viewportHeight=\"24\"\n    android:tint=\"?attr/colorControlNormal\"\n    android:alpha=\"0.3\">\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M7,7h10v3l4,-4l-4,-4v3H5v6h2V7zM17,17H7v-3l-4,4l4,4v-3h12v-6h-2V17z\" />\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_repeat_one_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"24\"\n    android:viewportHeight=\"24\"\n    android:tint=\"?attr/colorControlNormal\">\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M7,7h10v3l4,-4l-4,-4v3H5v6h2V7zM17,17H7v-3l-4,4l4,4v-3h12v-6h-2V17zM13,15V9h-1l-2,1v1h1.5v4H13z\" />\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_skip_next_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"960\"\n    android:viewportWidth=\"960\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_skip_previous_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:tint=\"#000000\"\n    android:viewportHeight=\"960\"\n    android:viewportWidth=\"960\"\n    android:width=\"24dp\">\n\n    <path\n        android:fillColor=\"@android:color/white\"\n        android:pathData=\"M220,720L220,240L300,240L300,720L220,720ZM740,720L380,480L740,240L740,720ZM660,480L660,480L660,480ZM660,570L660,390L524,480L660,570Z\" />\n\n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/drawable/outline_translate_24.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\" android:height=\"24dp\" android:tint=\"#000000\" android:viewportHeight=\"960\" android:viewportWidth=\"960\" android:width=\"24dp\">\n      \n    <path android:fillColor=\"@android:color/white\" android:pathData=\"M476,880L658,400L742,400L924,880L840,880L797,758L603,758L560,880L476,880ZM160,760L104,704L306,502Q271,467 242.5,422Q214,377 190,320L274,320Q294,359 314,388Q334,417 362,446Q395,413 430.5,353.5Q466,294 484,240L40,240L40,160L320,160L320,80L400,80L400,160L680,160L680,240L564,240Q543,312 501,388Q459,464 418,504L514,602L484,684L362,559L160,760ZM628,688L772,688L700,484L628,688Z\"/>\n    \n</vector>\n"
  },
  {
    "path": "packages/orpheus/android/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <string name=\"desktop_lyrics\">桌面歌词</string>\n    <string name=\"size\">大小</string>\n    <string name=\"lock\">锁定</string>\n    <string name=\"close\">关闭</string>\n    <string name=\"clear_lyrics\">清空歌词</string>\n    \n    <string name=\"lyric_mode_all\">全显</string>\n    <string name=\"lyric_mode_trans\">翻译</string>\n    <string name=\"lyric_mode_roma\">罗马音</string>\n    <string name=\"lyric_mode_none\">无</string>\n\n    <string-array name=\"lyricon_module_tags\">\n        <item>$translation</item>\n    </string-array>\n</resources>\n"
  },
  {
    "path": "packages/orpheus/docs/API-Events.md",
    "content": "# 事件与后台任务\n\n## 事件监听 (Events)\n\n使用 `Orpheus.addListener(eventName, callback)` 进行监听。\n\n| 事件名                   | 参数                                   | 描述                                         |\n| :----------------------- | :------------------------------------- | :------------------------------------------- |\n| `onPlaybackStateChanged` | `{ state: PlaybackState }`             | 播放状态改变 (IDLE, BUFFERING, READY, ENDED) |\n| `onIsPlayingChanged`     | `{ status: boolean }`                  | 播放/暂停状态改变                            |\n| `onTrackFinished`        | `{ trackId, finalPosition, duration }` | 歌曲播放完成                                 |\n| `onPositionUpdate`       | `{ position, duration, buffered }`     | 进度更新 (约 500ms 一次)                     |\n| `onPlayerError`          | `PlaybackErrorEvent`                   | 播放器报错 (包含堆栈和平台相关信息)          |\n| `onHeadlessEvent`        | `OrpheusHeadlessEvent`                 | 后台任务事件                                 |\n| `onDownloadUpdated`      | `DownloadTask`                         | 下载进度更新                                 |\n| `onPlaybackSpeedChanged` | `{ speed: number }`                    | 倍速改变                                     |\n| `onPositionUpdate`       | `{ position, duration, buffered }`     | 进度更新 (约 500ms 一次)                     |\n\n**注意**: `onTrackStarted` 事件在 v0.9.0+ 已移除，请使用 Headless Task。\n\n## 后台任务 (Headless Task)\n\n为了在 App 后台或被杀掉进程时仍能处理切歌等逻辑（如更新通知栏或以前的 `onTrackStarted` 逻辑），你需要注册 Headless Task。\n\n```typescript\nimport { registerOrpheusHeadlessTask } from '@bbplayer/orpheus'\n\nregisterOrpheusHeadlessTask(async (event) => {\n\t// 目前主要处理 TrackStarted 事件\n\tif (event.eventName === 'onTrackStarted') {\n\t\tconsole.log('开始播放:', event.trackId)\n\t\tconsole.log('原因:', event.reason) // 0: REPEAT, 1: AUTO, 2: SEEK, 3: PLAYLIST_CHANGED\n\t}\n})\n```\n\n必须在 `index.js` 或应用启动的最早时期注册。\n"
  },
  {
    "path": "packages/orpheus/docs/API-Methods.md",
    "content": "# API 方法\n\n获得 `Orpheus` 模块实例后可调用的方法。\n\n## 模块属性 (Module Properties)\n\n- **`restorePlaybackPositionEnabled: boolean`**\n  是否开启恢复播放进度。\n- **`loudnessNormalizationEnabled: boolean`**\n  是否开启响度标准化。\n- **`autoplayOnStartEnabled: boolean`**\n  是否开启启动时自动播放。\n- **`isDesktopLyricsShown: boolean`**\n  桌面歌词是否显示中。\n- **`isDesktopLyricsLocked: boolean`**\n  桌面歌词是否锁定（不可拖动）。\n\n## 播放控制 (Playback Control)\n\n- **`play(): Promise<void>`**\n  恢复播放。\n\n- **`pause(): Promise<void>`**\n  暂停播放。\n\n- **`skipToNext(): Promise<void>`**\n  跳至下一首。\n\n- **`skipToPrevious(): Promise<void>`**\n  跳至上一首。\n\n- **`skipTo(index: number): Promise<void>`**\n  跳至播放队列中的指定索引。\n\n- **`seekTo(seconds: number): Promise<void>`**\n  跳转到当前曲目的指定时间（单位：秒）。\n\n- **`setPlaybackSpeed(speed: number): Promise<void>`**\n  设置播放倍速 (如 1.0, 1.25, 2.0)。\n\n- **`getPlaybackSpeed(): Promise<number>`**\n  获取当前倍速。\n\n- **`getPosition(): Promise<number>`**\n  获取当前播放进度（秒）。\n\n- **`getDuration(): Promise<number>`**\n  获取当前曲目总时长（秒）。\n\n- **`getBuffered(): Promise<number>`**\n  获取当前缓冲进度（秒）。\n\n- **`getIsPlaying(): Promise<boolean>`**\n  获取当前是否正在播放。\n\n- **`getShuffleMode(): Promise<boolean>`**\n  获取随机模式状态。\n\n- **`getRepeatMode(): Promise<RepeatMode>`**\n  获取当前重复模式。\n\n- **`setRepeatMode(mode: RepeatMode): Promise<void>`**\n  设置重复模式。\n\n- **`setShuffleMode(enabled: boolean): Promise<void>`**\n  设置随机模式。\n\n## 队列管理 (Queue Management)\n\n- **`addToEnd(tracks: Track[], startFromId?: string, clearQueue?: boolean): Promise<void>`**\n  将歌曲添加到队列末尾。\n  - `tracks`: 歌曲列表。\n  - `startFromId` (可选): 添加后由该 ID 开始播放。\n  - `clearQueue` (可选): 是否先清空队列。\n\n- **`playNext(track: Track): Promise<void>`**\n  插队播放（下一首）。\n\n- **`clear(): Promise<void>`**\n  清空队列。\n\n- **`removeTrack(index: number): Promise<void>`**\n  移除指定索引的歌曲。\n\n- **`getQueue(): Promise<Track[]>`**\n  获取完整播放队列。\n\n- **`getCurrentIndex(): Promise<number>`**\n  获取当前播放索引。\n\n- **`getCurrentTrack(): Promise<Track | null>`**\n  获取当前播放对象。\n\n- **`getIndexTrack(index: number): Promise<Track | null>`**\n  获取指定索引的对象。\n\n## 下载管理 (Downloads)\n\nOrpheus 使用 Media3 DownloadManager。\n\n- **`downloadTrack(track: Track): Promise<void>`**\n  下载单曲。\n\n- **`multiDownload(tracks: Track[]): Promise<void>`**\n  批量下载。\n\n- **`removeDownload(id: string): Promise<void>`**\n  移除下载。\n\n- **`removeAllDownloads(): Promise<void>`**\n  清空下载缓存。\n\n- **`getDownloads(): Promise<DownloadTask[]>`**\n  获取所有下载任务。\n\n- **`getDownloadStatusByIds(ids: string[]): Promise<Record<string, DownloadState>>`**\n  批量查询下载状态。\n\n- **`getUncompletedDownloadTasks(): Promise<DownloadTask[]>`**\n  获取未完成任务。\n\n- **`clearUncompletedDownloadTasks(): Promise<void>`**\n  清除未完成（失败/停止）的任务。\n\n## 杂项与配置 (Misc)\n\n- **`setSleepTimer(durationMs: number): Promise<void>`**\n  设置睡眠定时器（毫秒）。\n\n- **`getSleepTimerEndTime(): Promise<number | null>`**\n  获取定时器结束时间戳。\n\n- **`cancelSleepTimer(): Promise<void>`**\n  取消定时器。\n\n- **`setBilibiliCookie(cookie: string): void`**\n  设置 Bilibili Cookie。\n\n- **`showDesktopLyrics() / hideDesktopLyrics()`**\n  显示/隐藏桌面歌词。\n\n- **`checkOverlayPermission() / requestOverlayPermission()`**\n  权限检查与请求。\n\n- **`setDesktopLyrics(json: string)`**\n  更新桌面悬浮窗歌词内容。\n\n- **`setStatusBarLyrics(json: string)`**\n  更新状态栏歌词内容（需要系统支持及相关模块）。\n\n## 频谱数据 (Spectrum)\n\n- **`updateSpectrumData(destination: Float32Array): void`**\n  同步更新提供的 `Float32Array` 为最新的频谱频率数据。建议在 JS 端创建一次并在动画循环中重复使用。\n"
  },
  {
    "path": "packages/orpheus/docs/API-Types.md",
    "content": "# 数据类型 (Types)\n\n## Track\n\n核心音频对象结构。\n\n```typescript\nexport interface Track {\n\t/** 唯一标识符 */\n\tid: string\n\n\t/**\n\t * 音频流地址。\n\t * 特殊协议: orpheus://bilibili?bvid=...\n\t */\n\turl: string\n\n\t/** 标题 */\n\ttitle?: string\n\n\t/** 艺术家 */\n\tartist?: string\n\n\t/** 封面图 URL */\n\tartwork?: string\n\n\t/** 时长 (秒) */\n\tduration?: number\n}\n```\n\n## TransitionReason\n\n触发切歌的原因。\n\n```typescript\nexport enum TransitionReason {\n\tREPEAT = 0, // 重复播放\n\tAUTO = 1, // 自动切下一首\n\tSEEK = 2, // 跳转\n\tPLAYLIST_CHANGED = 3, // 播放列表改变\n}\n```\n\n## PlaybackState\n\n播放器状态枚举。\n\n```typescript\nexport enum PlaybackState {\n\tIDLE = 1, // 空闲 / 无资源\n\tBUFFERING = 2, // 缓冲中\n\tREADY = 3, // 准备就绪 / 可播放\n\tENDED = 4, // 播放结束\n}\n```\n\n## RepeatMode\n\n重复模式。\n\n```typescript\nexport enum RepeatMode {\n\tOFF = 0, // 不重复\n\tTRACK = 1, // 单曲循环\n\tQUEUE = 2, // 列表循环\n}\n```\n\n## DownloadTask & DownloadState\n\n下载任务详情。\n\n```typescript\nexport enum DownloadState {\n\tQUEUED = 0,\n\tSTOPPED = 1,\n\tDOWNLOADING = 2,\n\tCOMPLETED = 3,\n\tFAILED = 4,\n\tREMOVING = 5,\n\tRESTARTING = 7,\n}\n\nexport interface DownloadTask {\n\tid: string\n\tstate: DownloadState\n\tpercentDownloaded: number // 0 - 100\n\tbytesDownloaded: number\n\tcontentLength: number\n\ttrack?: Track // 关联的 Track 对象信息\n}\n```\n\n## PlaybackErrorEvent\n\n播放错误详情。\n\n```typescript\nexport interface AndroidPlaybackErrorEvent {\n\tplatform: 'android'\n\terrorCode: number\n\terrorCodeName: string | null\n\ttimestamp: string\n\tmessage: string | null\n\tstackTrace: string\n\trootCauseClass: string\n\trootCauseMessage: string\n}\n\nexport interface IosPlaybackErrorEvent {\n\tplatform: 'ios'\n\terror: string\n}\n\nexport type PlaybackErrorEvent =\n\t| AndroidPlaybackErrorEvent\n\t| IosPlaybackErrorEvent\n```\n\n## OrpheusHeadlessEvent\n\n后台任务接收的事件类型。\n\n```typescript\nexport interface OrpheusHeadlessTrackStartedEvent {\n\teventName: 'onTrackStarted'\n\ttrackId: string\n\treason: TransitionReason\n}\n\nexport interface OrpheusHeadlessTrackFinishedEvent {\n\teventName: 'onTrackFinished'\n\ttrackId: string\n\tfinalPosition: number\n\tduration: number\n}\n\nexport interface OrpheusHeadlessTrackPausedEvent {\n\teventName: 'onTrackPaused'\n}\n\nexport interface OrpheusHeadlessTrackResumedEvent {\n\teventName: 'onTrackResumed'\n}\n\nexport type OrpheusHeadlessEvent =\n\t| OrpheusHeadlessTrackStartedEvent\n\t| OrpheusHeadlessTrackFinishedEvent\n\t| OrpheusHeadlessTrackPausedEvent\n\t| OrpheusHeadlessTrackResumedEvent\n```\n"
  },
  {
    "path": "packages/orpheus/docs/Home.md",
    "content": "# 欢迎使用 Orpheus\n\n**BBPlayer 内部音频模块**\n\nOrpheus 是一个为 BBPlayer 构建的高性能音频播放库，基于 Android Media3 (ExoPlayer) 和 AVFoundation。\n\n## 目录\n\n- [API 方法 (Methods)](orpheus-API-Methods)\n- [数据类型 (Types)](orpheus-API-Types)\n- [事件与后台任务 (Events)](orpheus-API-Events)\n\n## 快速开始\n\nOrpheus 主要用于处理复杂的音频播放需求，特别是 Bilibili 音频流和本地缓存管理。\n\n### 核心特性\n\n- **Bilibili 支持**: 如果提供了 Bilibili Cookie，Orpheus 可以自动获取高音质流。\n- **缓存系统**: 内置 LRU 缓存（边下边播）和持久化下载管理。\n- **桌面歌词**: Android 系统级悬浮窗歌词支持。\n"
  },
  {
    "path": "packages/orpheus/example/.gitignore",
    "content": "# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files\n\n# dependencies\nnode_modules/\n\n# Expo\n.expo/\ndist/\nweb-build/\nexpo-env.d.ts\n\n# Native\n.kotlin/\n*.orig.*\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n\n# Metro\n.metro-health-check*\n\n# debug\nnpm-debug.*\nyarn-debug.*\nyarn-error.*\n\n# macOS\n.DS_Store\n*.pem\n\n# local env files\n.env*.local\n\n# typescript\n*.tsbuildinfo\n\n# generated native folders\n/ios\n/android\n"
  },
  {
    "path": "packages/orpheus/example/App.tsx",
    "content": "import {\n\tOrpheus,\n\tPlaybackState,\n\tRepeatMode,\n\tuseCurrentTrack,\n} from '@bbplayer/orpheus'\nimport { useEffect, useState, useCallback } from 'react'\nimport { StyleSheet, SafeAreaView, ScrollView, Alert, View } from 'react-native'\n\nimport { DebugSection } from './src/components/DebugSection'\nimport { PlayerControls } from './src/components/PlayerControls'\nimport { SpectrumVisualizer } from './src/components/SpectrumVisualizer'\nimport { TEST_TRACKS } from './src/constants'\n\nexport default function OrpheusTestScreen() {\n\t// --- State ---\n\tconst [isPlaying, setIsPlaying] = useState(false)\n\tconst [playbackState, setPlaybackState] = useState<PlaybackState>(\n\t\tPlaybackState.IDLE,\n\t)\n\tconst [progress, setProgress] = useState({\n\t\tposition: 0,\n\t\tduration: 0,\n\t\tbuffered: 0,\n\t})\n\n\tconst [repeatMode, setRepeatMode] = useState<RepeatMode>(RepeatMode.OFF)\n\tconst [shuffleMode, setShuffleMode] = useState(false)\n\tconst [playbackSpeed, setPlaybackSpeed] = useState(1.0)\n\tconst { track: currentTrack } = useCurrentTrack()\n\tconst [restorePlaybackPositionEnabled, setRestorePlaybackPositionEnabled] =\n\t\tuseState(false)\n\tconst [autoplay, setAutoplay] = useState(false)\n\tconst [desktopLyricsShown, setDesktopLyricsShown] = useState(false)\n\tconst [desktopLyricsLocked, setDesktopLyricsLocked] = useState(false)\n\n\t// Debug Info\n\tconst [lastEventLog, setLastEventLog] = useState<string>('Ready')\n\n\t// --- Initialization & Listeners ---\n\tconst syncDesktopLyricsStatus = useCallback(async () => {\n\t\ttry {\n\t\t\tconst shown = Orpheus.isDesktopLyricsShown\n\t\t\tsetDesktopLyricsShown(shown)\n\t\t\tconst locked = Orpheus.isDesktopLyricsLocked\n\t\t\tsetDesktopLyricsLocked(locked)\n\t\t\tawait Promise.resolve()\n\t\t} catch (e) {\n\t\t\tconsole.error('Sync Lyrics Error:', e)\n\t\t}\n\t}, [])\n\n\tconst syncFullState = useCallback(async () => {\n\t\ttry {\n\t\t\tconst playing = await Orpheus.getIsPlaying()\n\t\t\tsetIsPlaying(playing)\n\n\t\t\tconst shuffle = await Orpheus.getShuffleMode()\n\t\t\tsetShuffleMode(shuffle)\n\n\t\t\tconst speed = await Orpheus.getPlaybackSpeed()\n\t\t\tsetPlaybackSpeed(speed)\n\n\t\t\tconst repeat = await Orpheus.getRepeatMode()\n\t\t\tsetRepeatMode(repeat)\n\n\t\t\tawait syncDesktopLyricsStatus()\n\t\t} catch (e) {\n\t\t\tconsole.error('Sync Error:', e)\n\t\t\tif (e instanceof Error) {\n\t\t\t\tsetLastEventLog(`Sync Error: ${e.message}`)\n\t\t\t}\n\t\t}\n\t}, [syncDesktopLyricsStatus])\n\n\tuseEffect(() => {\n\t\tsetRestorePlaybackPositionEnabled(Orpheus.restorePlaybackPositionEnabled)\n\t\tsetAutoplay(Orpheus.autoplayOnStartEnabled)\n\t}, [restorePlaybackPositionEnabled, autoplay])\n\n\tuseEffect(() => {\n\t\tvoid syncFullState()\n\n\t\tconst subState = Orpheus.addListener('onPlaybackStateChanged', (event) => {\n\t\t\tconsole.log('State Changed:', event.state)\n\t\t\tsetPlaybackState(event.state)\n\t\t})\n\n\t\tconst subTrackFinish = Orpheus.addListener('onTrackFinished', (event) => {\n\t\t\tconsole.log('Track Finished:', event)\n\t\t\tsetLastEventLog(`Track Finished: ${event.trackId}`)\n\t\t})\n\n\t\tconst subPlaying = Orpheus.addListener('onIsPlayingChanged', (event) => {\n\t\t\tsetIsPlaying(event.status)\n\t\t\tconsole.log('IsPlaying Changed:', event.status)\n\t\t})\n\n\t\tconst subProgress = Orpheus.addListener('onPositionUpdate', (event) => {\n\t\t\tsetProgress({\n\t\t\t\tposition: event.position,\n\t\t\t\tduration: event.duration,\n\t\t\t\tbuffered: event.buffered,\n\t\t\t})\n\t\t})\n\n\t\tconst subError = Orpheus.addListener('onPlayerError', (event) => {\n\t\t\tif (event.platform === 'android') {\n\t\t\t\tAlert.alert(\n\t\t\t\t\t'Player Error',\n\t\t\t\t\t`Code: ${event.errorCode}\\nMessage: ${event.message}\\nCause: ${event.rootCauseMessage}\\nStack: ${event.stackTrace}`,\n\t\t\t\t)\n\t\t\t\tsetLastEventLog(`Error: ${event.errorCode}`)\n\t\t\t} else {\n\t\t\t\tAlert.alert('Player Error', `Error: ${event.error}`)\n\t\t\t\tsetLastEventLog(`Error: iOS Error`)\n\t\t\t}\n\t\t})\n\n\t\tconst subDownload = Orpheus.addListener('onDownloadUpdated', (task) => {\n\t\t\tconsole.log(\n\t\t\t\t`Download [${task.id}]: ${task.percentDownloaded.toFixed(1)}% (State: ${task.state})`,\n\t\t\t)\n\t\t})\n\n\t\tconst subSpeed = Orpheus.addListener('onPlaybackSpeedChanged', (event) => {\n\t\t\tconsole.log('Speed Changed:', event.speed)\n\t\t\tsetPlaybackSpeed(event.speed)\n\t\t})\n\n\t\treturn () => {\n\t\t\tsubState.remove()\n\t\t\t// subTrackStart.remove();\n\t\t\tsubTrackFinish.remove()\n\t\t\tsubPlaying.remove()\n\t\t\tsubProgress.remove()\n\t\t\tsubError.remove()\n\t\t\tsubDownload.remove()\n\t\t\tsubSpeed.remove()\n\t\t}\n\t}, [syncFullState])\n\n\t// --- Handlers ---\n\n\tconst handlePlayPause = async () => {\n\t\ttry {\n\t\t\tif (isPlaying) {\n\t\t\t\tawait Orpheus.pause()\n\t\t\t} else {\n\t\t\t\tawait Orpheus.play()\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tif (e instanceof Error) {\n\t\t\t\tAlert.alert('Action Failed', e.message)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst handleAddTracks = async () => {\n\t\ttry {\n\t\t\tawait Orpheus.addToEnd(TEST_TRACKS, undefined, false)\n\t\t\tsetLastEventLog('Tracks added to queue end')\n\t\t\tAlert.alert('Success', 'Tracks added to queue')\n\t\t} catch (e) {\n\t\t\tif (e instanceof Error) {\n\t\t\t\tAlert.alert('Add Failed', e.message)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst handleClearAndPlay = async () => {\n\t\ttry {\n\t\t\tawait Orpheus.addToEnd(TEST_TRACKS, TEST_TRACKS[0].id, true)\n\t\t\tsetLastEventLog('Queue cleared and playing new tracks')\n\t\t} catch (e) {\n\t\t\tif (e instanceof Error) {\n\t\t\t\tAlert.alert('Action Failed', e.message)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst handleTestIndexTrack = async () => {\n\t\ttry {\n\t\t\tconst track = await Orpheus.getIndexTrack(0)\n\t\t\tif (track) {\n\t\t\t\tAlert.alert(\n\t\t\t\t\t'Get Index 0 Success',\n\t\t\t\t\t`Title: ${track.title}\\nID: ${track.id}`,\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tAlert.alert('Get Index 0', 'Empty (Queue might be empty)')\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tif (e instanceof Error) {\n\t\t\t\tAlert.alert('Error', e.message)\n\t\t\t}\n\t\t}\n\t}\n\n\tconst toggleRepeat = async () => {\n\t\tconst nextMode = (repeatMode + 1) % 3\n\t\tawait Orpheus.setRepeatMode(nextMode)\n\t\tsetRepeatMode(nextMode)\n\t}\n\n\tconst toggleShuffle = async () => {\n\t\tconst nextState = !shuffleMode\n\t\tawait Orpheus.setShuffleMode(nextState)\n\t\tsetShuffleMode(nextState)\n\t}\n\n\tconst toggleSpeed = async () => {\n\t\tconst speeds = [0.5, 1.0, 1.25, 1.5, 2.0]\n\t\tlet nextSpeed = 1.0\n\n\t\tfor (const s of speeds) {\n\t\t\tif (playbackSpeed < s - 0.01) {\n\t\t\t\tnextSpeed = s\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif (playbackSpeed >= speeds[speeds.length - 1] - 0.01) {\n\t\t\tnextSpeed = speeds[0]\n\t\t}\n\n\t\tawait Orpheus.setPlaybackSpeed(nextSpeed)\n\t\tsetPlaybackSpeed(nextSpeed)\n\t}\n\n\tconst handleRemoveCurrent = async () => {\n\t\ttry {\n\t\t\tconst idx = await Orpheus.getCurrentIndex()\n\t\t\tif (idx !== -1) {\n\t\t\t\tawait Orpheus.removeTrack(idx)\n\t\t\t\tsetLastEventLog(`Removed track at index ${idx}`)\n\t\t\t} else {\n\t\t\t\tAlert.alert('Cannot Remove', 'No current index playing')\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tif (e instanceof Error) {\n\t\t\t\tAlert.alert('Error', e.message)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn (\n\t\t<SafeAreaView style={styles.container}>\n\t\t\t<ScrollView contentContainerStyle={styles.scrollContent}>\n\t\t\t\t<PlayerControls\n\t\t\t\t\tcurrentTrack={currentTrack}\n\t\t\t\t\tplaybackState={playbackState}\n\t\t\t\t\tisPlaying={isPlaying}\n\t\t\t\t\tprogress={progress}\n\t\t\t\t\trepeatMode={repeatMode}\n\t\t\t\t\tshuffleMode={shuffleMode}\n\t\t\t\t\tplaybackSpeed={playbackSpeed}\n\t\t\t\t\tlastEventLog={lastEventLog}\n\t\t\t\t\tonPlayPause={handlePlayPause}\n\t\t\t\t\tonToggleRepeat={toggleRepeat}\n\t\t\t\t\tonToggleShuffle={toggleShuffle}\n\t\t\t\t\tonToggleSpeed={toggleSpeed}\n\t\t\t\t/>\n\n\t\t\t\t<SpectrumVisualizer isPlaying={isPlaying} />\n\n\t\t\t\t<View style={{ marginTop: 20 }}>\n\t\t\t\t\t<DebugSection\n\t\t\t\t\t\tprogress={progress}\n\t\t\t\t\t\trestorePlaybackPositionEnabled={restorePlaybackPositionEnabled}\n\t\t\t\t\t\tsetRestorePlaybackPositionEnabled={\n\t\t\t\t\t\t\tsetRestorePlaybackPositionEnabled\n\t\t\t\t\t\t}\n\t\t\t\t\t\tautoplay={autoplay}\n\t\t\t\t\t\tsetAutoplay={setAutoplay}\n\t\t\t\t\t\tdesktopLyricsShown={desktopLyricsShown}\n\t\t\t\t\t\tdesktopLyricsLocked={desktopLyricsLocked}\n\t\t\t\t\t\tsetLastEventLog={setLastEventLog}\n\t\t\t\t\t\tsyncDesktopLyricsStatus={syncDesktopLyricsStatus}\n\t\t\t\t\t\tonAddTracks={handleAddTracks}\n\t\t\t\t\t\tonClearAndPlay={handleClearAndPlay}\n\t\t\t\t\t\tonRemoveCurrent={handleRemoveCurrent}\n\t\t\t\t\t\tonTestIndexTrack={handleTestIndexTrack}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t</ScrollView>\n\t\t</SafeAreaView>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: { flex: 1, backgroundColor: '#121212' },\n\tscrollContent: { padding: 20, paddingBottom: 50 },\n})\n"
  },
  {
    "path": "packages/orpheus/example/app.json",
    "content": "{\n\t\"expo\": {\n\t\t\"name\": \"expo-orpheus-example\",\n\t\t\"slug\": \"expo-orpheus-example\",\n\t\t\"version\": \"1.0.0\",\n\t\t\"orientation\": \"portrait\",\n\t\t\"icon\": \"./assets/icon.png\",\n\t\t\"userInterfaceStyle\": \"light\",\n\t\t\"newArchEnabled\": true,\n\t\t\"splash\": {\n\t\t\t\"image\": \"./assets/splash-icon.png\",\n\t\t\t\"resizeMode\": \"contain\",\n\t\t\t\"backgroundColor\": \"#ffffff\"\n\t\t},\n\t\t\"ios\": {\n\t\t\t\"supportsTablet\": true,\n\t\t\t\"bundleIdentifier\": \"expo.modules.orpheus.example\",\n\t\t\t\"infoPlist\": {\n\t\t\t\t\"UIBackgroundModes\": [\"audio\"]\n\t\t\t}\n\t\t},\n\t\t\"android\": {\n\t\t\t\"adaptiveIcon\": {\n\t\t\t\t\"foregroundImage\": \"./assets/adaptive-icon.png\",\n\t\t\t\t\"backgroundColor\": \"#ffffff\"\n\t\t\t},\n\t\t\t\"edgeToEdgeEnabled\": true,\n\t\t\t\"predictiveBackGestureEnabled\": false,\n\t\t\t\"package\": \"expo.modules.orpheus.example\"\n\t\t},\n\t\t\"web\": {\n\t\t\t\"favicon\": \"./assets/favicon.png\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/example/babel.config.js",
    "content": "module.exports = function (api) {\n\tapi.cache(true)\n\treturn {\n\t\tpresets: ['babel-preset-expo'],\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/example/index.ts",
    "content": "import { Orpheus, registerOrpheusHeadlessTask } from '@bbplayer/orpheus'\nimport { registerRootComponent } from 'expo'\n\nimport LYRICS_DATA from '../bilibili--BV1DL4y1V7xH--584235509.json'\n\nimport App from './App'\n\nconsole.log('1111')\n\nregisterOrpheusHeadlessTask(async (event) => {\n\tconsole.log('hey we are here.')\n\tif (event.eventName === 'onTrackStarted') {\n\t\tconsole.log(\n\t\t\t'[OrpheusHeadlessTask] Track Started:',\n\t\t\tevent.trackId,\n\t\t\tevent.reason,\n\t\t)\n\t\tif (event.trackId === 'bilibili--BV1DL4y1V7xH--584235509') {\n\t\t\tawait Orpheus.setLyrics(LYRICS_DATA, ['desktop'])\n\t\t}\n\t} else if (event.eventName === 'onTrackFinished') {\n\t\tconsole.log(\n\t\t\t'[OrpheusHeadlessTask] Track Finished:',\n\t\t\tevent.trackId,\n\t\t\t'Position:',\n\t\t\tevent.finalPosition,\n\t\t\t'Duration:',\n\t\t\tevent.duration,\n\t\t)\n\t}\n})\n\nregisterRootComponent(App)\n"
  },
  {
    "path": "packages/orpheus/example/metro.config.js",
    "content": "// Learn more https://docs.expo.io/guides/customizing-metro\nconst { getDefaultConfig } = require('expo/metro-config')\nconst path = require('path')\n\nconst config = getDefaultConfig(__dirname)\n\n// npm v7+ will install ../node_modules/react and ../node_modules/react-native because of peerDependencies.\n// To prevent the incompatible react-native between ./node_modules/react-native and ../node_modules/react-native,\n// excludes the one from the parent folder when bundling.\nconfig.resolver.blockList = [\n\t...Array.from(config.resolver.blockList ?? []),\n\tnew RegExp(path.resolve('..', 'node_modules', 'react')),\n\tnew RegExp(path.resolve('..', 'node_modules', 'react-native')),\n]\n\nconfig.resolver.nodeModulesPaths = [\n\tpath.resolve(__dirname, './node_modules'),\n\tpath.resolve(__dirname, '../node_modules'),\n]\n\nconfig.resolver.extraNodeModules = {\n\t'expo-orpheus': '..',\n}\n\nconfig.watchFolders = [path.resolve(__dirname, '..')]\n\nconfig.transformer.getTransformOptions = async () => ({\n\ttransform: {\n\t\texperimentalImportSupport: false,\n\t\tinlineRequires: true,\n\t},\n})\n\nmodule.exports = config\n"
  },
  {
    "path": "packages/orpheus/example/package.json",
    "content": "{\n\t\"name\": \"expo-orpheus-example\",\n\t\"version\": \"1.0.0\",\n\t\"private\": true,\n\t\"main\": \"index.ts\",\n\t\"scripts\": {\n\t\t\"android\": \"expo run:android\",\n\t\t\"ios\": \"expo run:ios\",\n\t\t\"start\": \"expo start\",\n\t\t\"web\": \"expo start --web\"\n\t},\n\t\"dependencies\": {\n\t\t\"@bbplayer/orpheus\": \"file:..\",\n\t\t\"expo\": \"~54.0.25\",\n\t\t\"react\": \"19.1.0\",\n\t\t\"react-native\": \"0.81.5\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/react\": \"~19.1.0\"\n\t},\n\t\"expo\": {\n\t\t\"autolinking\": {\n\t\t\t\"nativeModulesDir\": \"..\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/example/src/components/Buttons.tsx",
    "content": "import type { FC } from 'react'\nimport { TouchableOpacity, Text, StyleSheet } from 'react-native'\n\ninterface ControlButtonProps {\n\tlabel: string\n\tonPress: () => void\n}\n\nexport const ControlButton: FC<ControlButtonProps> = ({ label, onPress }) => (\n\t<TouchableOpacity\n\t\tstyle={styles.controlBtn}\n\t\tonPress={onPress}\n\t>\n\t\t<Text style={styles.controlBtnText}>{label}</Text>\n\t</TouchableOpacity>\n)\n\ninterface ButtonProps {\n\ttitle: string\n\tonPress: () => void\n\tprimary?: boolean\n\tdanger?: boolean\n\tsmall?: boolean\n\tactive?: boolean\n}\n\nexport const Button: FC<ButtonProps> = ({\n\ttitle,\n\tonPress,\n\tprimary,\n\tdanger,\n\tsmall,\n\tactive,\n}) => (\n\t<TouchableOpacity\n\t\tstyle={[\n\t\t\tstyles.btn,\n\t\t\tprimary && styles.btnPrimary,\n\t\t\tdanger && styles.btnDanger,\n\t\t\tactive && styles.btnActive,\n\t\t\tsmall && styles.btnSmall,\n\t\t]}\n\t\tonPress={onPress}\n\t>\n\t\t<Text\n\t\t\tstyle={[\n\t\t\t\tstyles.btnText,\n\t\t\t\tsmall && { fontSize: 12 },\n\t\t\t\t// oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing\n\t\t\t\t(primary || danger || active) && { color: '#fff' },\n\t\t\t]}\n\t\t>\n\t\t\t{title}\n\t\t</Text>\n\t</TouchableOpacity>\n)\n\nconst styles = StyleSheet.create({\n\tcontrolBtn: { padding: 10 },\n\tcontrolBtnText: { fontSize: 32, color: '#fff' },\n\tbtn: {\n\t\tbackgroundColor: '#333',\n\t\tpaddingVertical: 12,\n\t\tpaddingHorizontal: 16,\n\t\tborderRadius: 8,\n\t\tminWidth: 80,\n\t\talignItems: 'center',\n\t\tmarginBottom: 0,\n\t},\n\tbtnSmall: { paddingVertical: 8, paddingHorizontal: 12, minWidth: 60 },\n\tbtnPrimary: { backgroundColor: '#1DB954' },\n\tbtnDanger: { backgroundColor: '#E53935' },\n\tbtnActive: { backgroundColor: '#1DB954' },\n\tbtnText: { color: '#ddd', fontWeight: '600' },\n})\n"
  },
  {
    "path": "packages/orpheus/example/src/components/DebugSection.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport type { FC } from 'react'\nimport { View, Text, StyleSheet, Alert } from 'react-native'\n\nimport { TEST_TRACKS } from '../constants'\n\nimport { Button } from './Buttons'\n\ninterface DebugSectionProps {\n\tprogress: { position: number; duration: number; buffered: number }\n\trestorePlaybackPositionEnabled: boolean\n\tsetRestorePlaybackPositionEnabled: (val: boolean) => void\n\tautoplay: boolean\n\tsetAutoplay: (val: boolean) => void\n\tdesktopLyricsShown: boolean\n\tdesktopLyricsLocked: boolean\n\tsetLastEventLog: (log: string) => void\n\tsyncDesktopLyricsStatus: () => Promise<void>\n\n\t// Handlers from App.tsx\n\tonAddTracks: () => void\n\tonClearAndPlay: () => void\n\tonRemoveCurrent: () => void\n\tonTestIndexTrack: () => void\n}\n\nexport const DebugSection: FC<DebugSectionProps> = ({\n\tprogress,\n\trestorePlaybackPositionEnabled,\n\tsetRestorePlaybackPositionEnabled,\n\tautoplay,\n\tsetAutoplay,\n\tdesktopLyricsShown,\n\tdesktopLyricsLocked,\n\tsetLastEventLog,\n\tsyncDesktopLyricsStatus,\n\tonAddTracks,\n\tonClearAndPlay,\n\tonRemoveCurrent,\n\tonTestIndexTrack,\n}) => {\n\treturn (\n\t\t<View style={styles.actionsContainer}>\n\t\t\t<Text style={styles.sectionTitle}>Queue API</Text>\n\t\t\t<View style={styles.grid}>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Add to End'\n\t\t\t\t\tonPress={onAddTracks}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Clear & Play'\n\t\t\t\t\tonPress={onClearAndPlay}\n\t\t\t\t\tprimary\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Clear Queue'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.clear()\n\t\t\t\t\t}}\n\t\t\t\t\tdanger\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Remove Current'\n\t\t\t\t\tonPress={onRemoveCurrent}\n\t\t\t\t\tdanger\n\t\t\t\t/>\n\t\t\t</View>\n\n\t\t\t<Text style={[styles.sectionTitle, { marginTop: 15 }]}>Info & Seek</Text>\n\t\t\t<View style={styles.grid}>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Log Queue'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tconst q = await Orpheus.getQueue()\n\t\t\t\t\t\tconsole.log('Current Queue:', q)\n\t\t\t\t\t\tsetLastEventLog(`Queue Length: ${q.length}`)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Get Track [0]'\n\t\t\t\t\tonPress={onTestIndexTrack}\n\t\t\t\t/>\n\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Seek +15s'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.seekTo(progress.position + 15)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Seek to 0s'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.seekTo(0)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t<Button\n\t\t\t\t\ttitle={\n\t\t\t\t\t\t(restorePlaybackPositionEnabled ? 'Disable' : 'Enable') + ' Restore'\n\t\t\t\t\t}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tOrpheus.restorePlaybackPositionEnabled =\n\t\t\t\t\t\t\t!Orpheus.restorePlaybackPositionEnabled\n\t\t\t\t\t\tsetRestorePlaybackPositionEnabled(\n\t\t\t\t\t\t\tOrpheus.restorePlaybackPositionEnabled,\n\t\t\t\t\t\t)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t<Button\n\t\t\t\t\ttitle={(autoplay ? 'Disable' : 'Enable') + ' Autoplay'}\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tOrpheus.autoplayOnStartEnabled = !Orpheus.autoplayOnStartEnabled\n\t\t\t\t\t\tsetAutoplay(Orpheus.autoplayOnStartEnabled)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Set Sleep (10s)'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.setSleepTimer(10000)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Get Sleep Time'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst endTime = await Orpheus.getSleepTimerEndTime()\n\t\t\t\t\t\t\tif (endTime) {\n\t\t\t\t\t\t\t\tAlert.alert('Sleep End', `${endTime / 1000}s`)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tAlert.alert('Sleep End', 'Not Set')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tif (e instanceof Error) {\n\t\t\t\t\t\t\t\tAlert.alert('Error', e.message)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconsole.log(e)\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Cancel Sleep'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.cancelSleepTimer()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</View>\n\n\t\t\t<Text style={[styles.sectionTitle, { marginTop: 15 }]}>Download API</Text>\n\t\t\t<View style={styles.grid}>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Download [0]'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.downloadTrack(TEST_TRACKS[0])\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Download Batch'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.multiDownload(TEST_TRACKS.slice(1))\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Get Downloads'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tconst downloads = await Orpheus.getDownloads()\n\t\t\t\t\t\tconsole.log('All Downloads:', downloads)\n\t\t\t\t\t\tsetLastEventLog(`Downloads: ${downloads.length}`)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Get ID Status'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tconst ids = TEST_TRACKS.map((t) => t.id)\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst statusMap = await Orpheus.getDownloadStatusByIds(ids)\n\t\t\t\t\t\t\tconsole.log('Status Map:', statusMap)\n\t\t\t\t\t\t\tAlert.alert('Status Map', JSON.stringify(statusMap, null, 2))\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tif (e instanceof Error) {\n\t\t\t\t\t\t\t\tAlert.alert('Error', e.message)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconsole.log(e)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Del All DLs'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.removeAllDownloads()\n\t\t\t\t\t}}\n\t\t\t\t\tdanger\n\t\t\t\t/>\n\t\t\t</View>\n\n\t\t\t<Text style={[styles.sectionTitle, { marginTop: 15 }]}>\n\t\t\t\tDesktop Lyrics API\n\t\t\t</Text>\n\t\t\t<View style={{ marginBottom: 10 }}>\n\t\t\t\t<Text style={{ color: '#aaa', fontSize: 12 }}>\n\t\t\t\t\tStatus: {desktopLyricsShown ? 'Shown' : 'Hidden'} /{' '}\n\t\t\t\t\t{desktopLyricsLocked ? 'Locked' : 'Unlocked'}\n\t\t\t\t</Text>\n\t\t\t</View>\n\t\t\t<View style={styles.grid}>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Req Permission'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tawait Orpheus.requestOverlayPermission()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Check Permission'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tconst has = await Orpheus.checkOverlayPermission()\n\t\t\t\t\t\tAlert.alert('Permission', has ? 'Granted' : 'Denied')\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Show Lyrics'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tawait Orpheus.showDesktopLyrics()\n\t\t\t\t\t\tawait syncDesktopLyricsStatus()\n\t\t\t\t\t}}\n\t\t\t\t\tprimary\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Hide Lyrics'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tawait Orpheus.hideDesktopLyrics()\n\t\t\t\t\t\tawait syncDesktopLyricsStatus()\n\t\t\t\t\t}}\n\t\t\t\t\tdanger\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Lock Lyrics'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tOrpheus.isDesktopLyricsLocked = true\n\t\t\t\t\t\tawait syncDesktopLyricsStatus()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Unlock Lyrics'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tOrpheus.isDesktopLyricsLocked = false\n\t\t\t\t\t\tawait syncDesktopLyricsStatus()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Refresh Status'\n\t\t\t\t\tonPress={async () => {\n\t\t\t\t\t\tawait syncDesktopLyricsStatus()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</View>\n\n\t\t\t<Text style={[styles.sectionTitle, { marginTop: 15 }]}>Debug Tools</Text>\n\t\t\t<View style={styles.grid}>\n\t\t\t\t<Button\n\t\t\t\t\ttitle='Trigger Error'\n\t\t\t\t\tonPress={() => {\n\t\t\t\t\t\tvoid Orpheus.debugTriggerError()\n\t\t\t\t\t}}\n\t\t\t\t\tdanger\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tactionsContainer: {\n\t\tbackgroundColor: '#1E1E1E',\n\t\tpadding: 15,\n\t\tborderRadius: 12,\n\t},\n\tsectionTitle: {\n\t\tcolor: '#666',\n\t\tmarginBottom: 15,\n\t\tfontSize: 12,\n\t\tfontWeight: 'bold',\n\t\ttextTransform: 'uppercase',\n\t},\n\tgrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },\n})\n"
  },
  {
    "path": "packages/orpheus/example/src/components/PlayerControls.tsx",
    "content": "import {\n\tOrpheus,\n\ttype Track,\n\tPlaybackState,\n\tRepeatMode,\n} from '@bbplayer/orpheus'\nimport type { FC } from 'react'\nimport { View, Text, Image, StyleSheet, TouchableOpacity } from 'react-native'\n\nimport { ControlButton, Button } from './Buttons'\n\ninterface PlayerControlsProps {\n\tcurrentTrack: Track | null\n\tplaybackState: PlaybackState\n\tisPlaying: boolean\n\tprogress: { position: number; duration: number; buffered: number }\n\trepeatMode: RepeatMode\n\tshuffleMode: boolean\n\tplaybackSpeed: number\n\tlastEventLog: string\n\tonPlayPause: () => void\n\tonToggleRepeat: () => void\n\tonToggleShuffle: () => void\n\tonToggleSpeed: () => void\n}\n\nconst formatTime = (seconds: number) => {\n\tif (!seconds || isNaN(seconds) || seconds < 0) return '0:00'\n\tconst mins = Math.floor(seconds / 60)\n\tconst secs = Math.floor(seconds % 60)\n\treturn `${mins}:${secs < 10 ? '0' : ''}${secs}`\n}\n\nexport const PlayerControls: FC<PlayerControlsProps> = ({\n\tcurrentTrack,\n\tplaybackState,\n\tisPlaying,\n\tprogress,\n\trepeatMode,\n\tshuffleMode,\n\tplaybackSpeed,\n\tlastEventLog,\n\tonPlayPause,\n\tonToggleRepeat,\n\tonToggleShuffle,\n\tonToggleSpeed,\n}) => {\n\tconst progressPercent =\n\t\tprogress.duration > 0 ? (progress.position / progress.duration) * 100 : 0\n\n\treturn (\n\t\t<View>\n\t\t\t{/* 1. Header State */}\n\t\t\t<View style={styles.header}>\n\t\t\t\t<Text style={styles.headerTitle}>Orpheus Debugger</Text>\n\t\t\t\t<Text style={styles.stateTag}>{PlaybackState[playbackState]}</Text>\n\t\t\t</View>\n\n\t\t\t{/* 2. Artwork & Info */}\n\t\t\t<View style={styles.artworkContainer}>\n\t\t\t\t{currentTrack?.artwork ? (\n\t\t\t\t\t<Image\n\t\t\t\t\t\tsource={{ uri: currentTrack.artwork }}\n\t\t\t\t\t\tstyle={styles.artwork}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<View style={[styles.artwork, styles.artworkPlaceholder]}>\n\t\t\t\t\t\t<Text style={{ color: '#666' }}>No Artwork</Text>\n\t\t\t\t\t</View>\n\t\t\t\t)}\n\n\t\t\t\t<Text\n\t\t\t\t\tstyle={styles.title}\n\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t>\n\t\t\t\t\t{currentTrack?.title ?? 'Not Playing'}\n\t\t\t\t</Text>\n\t\t\t\t<Text\n\t\t\t\t\tstyle={styles.artist}\n\t\t\t\t\tnumberOfLines={1}\n\t\t\t\t>\n\t\t\t\t\t{currentTrack?.artist ?? 'Orpheus Player'}\n\t\t\t\t</Text>\n\t\t\t\t<Text style={styles.trackId}>ID: {currentTrack?.id ?? '-'}</Text>\n\t\t\t\t<Text style={styles.debugText}>{lastEventLog}</Text>\n\t\t\t</View>\n\n\t\t\t{/* 3. Progress Bar */}\n\t\t\t<View style={styles.progressContainer}>\n\t\t\t\t<View style={styles.progressBarBg}>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.progressBarBuffered,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\twidth: `${progress.duration > 0 ? Math.min((progress.buffered / progress.duration) * 100, 100) : 0}%`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t\t<View\n\t\t\t\t\t\tstyle={[\n\t\t\t\t\t\t\tstyles.progressBarFill,\n\t\t\t\t\t\t\t{ width: `${Math.min(progressPercent, 100)}%` },\n\t\t\t\t\t\t]}\n\t\t\t\t\t/>\n\t\t\t\t</View>\n\t\t\t\t<View style={styles.timeRow}>\n\t\t\t\t\t<Text style={styles.timeText}>{formatTime(progress.position)}</Text>\n\t\t\t\t\t<Text style={styles.timeText}>{formatTime(progress.duration)}</Text>\n\t\t\t\t</View>\n\t\t\t</View>\n\n\t\t\t{/* 4. Controls */}\n\t\t\t<View style={styles.controlsRow}>\n\t\t\t\t<ControlButton\n\t\t\t\t\tlabel='⏮'\n\t\t\t\t\tonPress={() => Orpheus.skipToPrevious()}\n\t\t\t\t/>\n\n\t\t\t\t<TouchableOpacity\n\t\t\t\t\tstyle={styles.playBtn}\n\t\t\t\t\tonPress={onPlayPause}\n\t\t\t\t>\n\t\t\t\t\t<Text style={styles.playBtnText}>{isPlaying ? '⏸' : '▶️'}</Text>\n\t\t\t\t</TouchableOpacity>\n\n\t\t\t\t<ControlButton\n\t\t\t\t\tlabel='⏭'\n\t\t\t\t\tonPress={() => Orpheus.skipToNext()}\n\t\t\t\t/>\n\t\t\t</View>\n\n\t\t\t{/* 5. Mode Controls */}\n\t\t\t<View style={styles.modeRow}>\n\t\t\t\t<Button\n\t\t\t\t\ttitle={`Repeat: ${RepeatMode[repeatMode]}`}\n\t\t\t\t\tonPress={onToggleRepeat}\n\t\t\t\t\tsmall\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle={`Shuffle: ${shuffleMode ? 'ON' : 'OFF'}`}\n\t\t\t\t\tonPress={onToggleShuffle}\n\t\t\t\t\tsmall\n\t\t\t\t\tactive={shuffleMode}\n\t\t\t\t/>\n\t\t\t\t<Button\n\t\t\t\t\ttitle={`Speed: ${playbackSpeed.toFixed(1)}x`}\n\t\t\t\t\tonPress={onToggleSpeed}\n\t\t\t\t\tsmall\n\t\t\t\t\tactive={playbackSpeed !== 1.0}\n\t\t\t\t/>\n\t\t\t</View>\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\theader: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\talignItems: 'center',\n\t\tmarginBottom: 20,\n\t},\n\theaderTitle: { color: '#fff', fontSize: 18, fontWeight: 'bold' },\n\tstateTag: {\n\t\tcolor: '#1DB954',\n\t\tfontSize: 12,\n\t\tborderWidth: 1,\n\t\tborderColor: '#1DB954',\n\t\tpaddingHorizontal: 6,\n\t\tpaddingVertical: 2,\n\t\tborderRadius: 4,\n\t},\n\n\tartworkContainer: { alignItems: 'center', marginBottom: 25 },\n\tartwork: {\n\t\twidth: 240,\n\t\theight: 240,\n\t\tborderRadius: 12,\n\t\tmarginBottom: 15,\n\t\tbackgroundColor: '#000',\n\t},\n\tartworkPlaceholder: {\n\t\tbackgroundColor: '#222',\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t\tborderWidth: 1,\n\t\tborderColor: '#333',\n\t},\n\ttitle: {\n\t\tcolor: '#fff',\n\t\tfontSize: 22,\n\t\tfontWeight: 'bold',\n\t\ttextAlign: 'center',\n\t\tmarginBottom: 5,\n\t},\n\tartist: { color: '#bbb', fontSize: 16, marginBottom: 5 },\n\ttrackId: {\n\t\tcolor: '#444',\n\t\tfontSize: 10,\n\t\tfontFamily: 'monospace',\n\t\tmarginBottom: 5,\n\t},\n\tdebugText: {\n\t\tcolor: '#e5e5e5',\n\t\tfontSize: 10,\n\t\tfontFamily: 'monospace',\n\t\tbackgroundColor: '#333',\n\t\tpadding: 4,\n\t\tborderRadius: 4,\n\t\tmarginTop: 5,\n\t},\n\n\tprogressContainer: { marginBottom: 30 },\n\tprogressBarBg: {\n\t\theight: 6,\n\t\tbackgroundColor: '#333',\n\t\tborderRadius: 3,\n\t\toverflow: 'hidden',\n\t\tposition: 'relative',\n\t},\n\tprogressBarBuffered: {\n\t\theight: '100%',\n\t\tbackgroundColor: '#555',\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\ttop: 0,\n\t},\n\tprogressBarFill: {\n\t\theight: '100%',\n\t\tbackgroundColor: '#1DB954',\n\t\tposition: 'absolute',\n\t\tleft: 0,\n\t\ttop: 0,\n\t},\n\ttimeRow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'space-between',\n\t\tmarginTop: 8,\n\t},\n\ttimeText: { color: '#888', fontSize: 12, fontVariant: ['tabular-nums'] },\n\n\tcontrolsRow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t\tmarginBottom: 30,\n\t\tgap: 40,\n\t},\n\tplayBtn: {\n\t\twidth: 70,\n\t\theight: 70,\n\t\tborderRadius: 35,\n\t\tbackgroundColor: '#fff',\n\t\tjustifyContent: 'center',\n\t\talignItems: 'center',\n\t},\n\tplayBtnText: { fontSize: 32, color: '#000', marginLeft: 4 },\n\n\tmodeRow: {\n\t\tflexDirection: 'row',\n\t\tjustifyContent: 'center',\n\t\tgap: 10,\n\t\tmarginBottom: 30,\n\t},\n})\n"
  },
  {
    "path": "packages/orpheus/example/src/components/SpectrumVisualizer.tsx",
    "content": "import { Orpheus } from '@bbplayer/orpheus'\nimport { useEffect, useRef } from 'react'\nimport { View, StyleSheet, useWindowDimensions } from 'react-native'\n\nconst BAR_COUNT = 32\nconst FFT_SIZE = 1024 // Buffer size we might pull, but we only show 32 bars\n// Since FFT is 512 bins (Nyquist), we can bin them.\n\nexport const SpectrumVisualizer = ({ isPlaying }: { isPlaying: boolean }) => {\n\tconst barsRef = useRef<(View | null)[]>([])\n\tconst rafRef = useRef<number | null>(null)\n\tconst bufferRef = useRef(new Float32Array(FFT_SIZE / 2))\n\tconst dimensions = useWindowDimensions()\n\n\tuseEffect(() => {\n\t\tconst animate = () => {\n\t\t\tif (!isPlaying) return\n\n\t\t\t// Pull data\n\t\t\tOrpheus.updateSpectrumData(bufferRef.current)\n\t\t\tconst data = bufferRef.current\n\n\t\t\t// Update bars\n\t\t\t// Simple linear sampling for demo\n\t\t\tconst step = Math.floor(data.length / BAR_COUNT)\n\n\t\t\tfor (let i = 0; i < BAR_COUNT; i++) {\n\t\t\t\tconst view = barsRef.current[i]\n\t\t\t\tif (view) {\n\t\t\t\t\t// Average or Max in the bin\n\t\t\t\t\tlet sum = 0\n\t\t\t\t\tconst start = i * step\n\t\t\t\t\tconst end = start + step\n\t\t\t\t\tfor (let j = start; j < end; j++) {\n\t\t\t\t\t\tsum += data[j]\n\t\t\t\t\t}\n\t\t\t\t\tconst avg = sum / step\n\n\t\t\t\t\t// Draw\n\t\t\t\t\t// Height 0-100\n\t\t\t\t\t// Magnitudes are 0-1 usually.\n\t\t\t\t\tconst height = Math.min(Math.max(avg * 200, 2), 150)\n\n\t\t\t\t\tview.setNativeProps({\n\t\t\t\t\t\tstyle: {\n\t\t\t\t\t\t\theight: height,\n\t\t\t\t\t\t\tbackgroundColor: `hsl(${i * 10}, 80%, 60%)`,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\trafRef.current = requestAnimationFrame(animate)\n\t\t}\n\n\t\tif (isPlaying) {\n\t\t\tanimate()\n\t\t} else {\n\t\t\tif (rafRef.current) {\n\t\t\t\tcancelAnimationFrame(rafRef.current)\n\t\t\t\trafRef.current = null\n\t\t\t}\n\t\t\t// Reset bars\n\t\t\tfor (let i = 0; i < BAR_COUNT; i++) {\n\t\t\t\tbarsRef.current[i]?.setNativeProps({ style: { height: 2 } })\n\t\t\t}\n\t\t}\n\n\t\treturn () => {\n\t\t\tif (rafRef.current) {\n\t\t\t\tcancelAnimationFrame(rafRef.current)\n\t\t\t}\n\t\t}\n\t}, [isPlaying])\n\n\treturn (\n\t\t<View style={styles.container}>\n\t\t\t{Array.from({ length: BAR_COUNT }).map((_, i) => (\n\t\t\t\t<View\n\t\t\t\t\t// oxlint-disable-next-line react/no-array-index-key\n\t\t\t\t\tkey={i}\n\t\t\t\t\tref={(ref) => {\n\t\t\t\t\t\tbarsRef.current[i] = ref\n\t\t\t\t\t}}\n\t\t\t\t\tstyle={[\n\t\t\t\t\t\tstyles.bar,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tbackgroundColor: `hsl(${i * 10}, 80%, 50%)`,\n\t\t\t\t\t\t\twidth: (dimensions.width - 80) / BAR_COUNT,\n\t\t\t\t\t\t},\n\t\t\t\t\t]}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</View>\n\t)\n}\n\nconst styles = StyleSheet.create({\n\tcontainer: {\n\t\tflexDirection: 'row',\n\t\talignItems: 'flex-end',\n\t\tjustifyContent: 'space-between',\n\t\theight: 160,\n\t\tbackgroundColor: '#222',\n\t\tpadding: 10,\n\t\tborderRadius: 10,\n\t\tmarginVertical: 10,\n\t},\n\tbar: {\n\t\theight: 2,\n\t\tborderRadius: 2,\n\t},\n})\n"
  },
  {
    "path": "packages/orpheus/example/src/constants.ts",
    "content": "import type { Track } from '@bbplayer/orpheus'\n\nexport const TEST_TRACKS: Track[] = [\n\t{\n\t\tid: 'bilibili--BV1DL4y1V7xH--584235509',\n\t\turl: 'orpheus://bilibili?bvid=BV1DL4y1V7xH&cid=584235509',\n\t\ttitle: 'Superstar (Desktop Lyrics Demo)',\n\t\tartist: 'えびかれー伯爵',\n\t\tartwork:\n\t\t\t'https://i0.hdslb.com/bfs/archive/8f2c8d87a9f7e8e8e8e8e8e8e8e8e8e8e8e8e8e8.jpg',\n\t},\n\t{\n\t\tid: 'test_bili_fake',\n\t\turl: 'orpheus://bilibili?bvid=BV1WPS4BuEEb',\n\t\ttitle: 'Bilibili Test (Fake)',\n\t\tartist: 'Orpheus Repo',\n\t\tartwork:\n\t\t\t'https://i1.hdslb.com/bfs/archive/77894b93c447724ff2d52a8171771c72681cb986.jpg',\n\t},\n\t{\n\t\tid: 'test_bili_new1',\n\t\turl: 'orpheus://bilibili?bvid=BV1DzCABvEAV',\n\t\ttitle: 'lty',\n\t\tartist: 'Orpheus Repo',\n\t\tartwork:\n\t\t\t'https://i2.hdslb.com/bfs/archive/1dc8b91a28f425835178bc5a399dbdbb6788d3ff.jpg',\n\t},\n\t{\n\t\tid: 'test_bili_new2',\n\t\turl: 'orpheus://bilibili?bvid=BV1NSC5BtEem',\n\t\ttitle: '111',\n\t\tartist: 'Orpheus Repo',\n\t\tartwork:\n\t\t\t'https://i1.hdslb.com/bfs/archive/e115f949947eabc57f626a5f4f81eeb3d468c63c.jpg',\n\t},\n\t{\n\t\tid: 'test_bili_new3',\n\t\turl: 'orpheus://bilibili?bvid=BV1mV411X7DZ&dolby=1&hires=1',\n\t\ttitle: '草东《大风吹》【Hi-Res】',\n\t\tartist: 'Orpheus Repo',\n\t\tartwork:\n\t\t\t'https://i1.hdslb.com/bfs/archive/554224b5870aad1353306f2fb8e788e3c22c4bae.jpg',\n\t},\n]\n"
  },
  {
    "path": "packages/orpheus/example/tsconfig.json",
    "content": "{\n\t\"extends\": \"expo/tsconfig.base\",\n\t\"compilerOptions\": {\n\t\t\"strict\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"paths\": {\n\t\t\t\"@bbplayer/orpheus\": [\"../src/index\"],\n\t\t\t\"@bbplayer/orpheus/*\": [\"../src/*\"]\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/example/webpack.config.js",
    "content": "const createConfigAsync = require('@expo/webpack-config')\nconst path = require('path')\n\nmodule.exports = async (env, argv) => {\n\tconst config = await createConfigAsync(\n\t\t{\n\t\t\t...env,\n\t\t\tbabel: {\n\t\t\t\tdangerouslyAddModulePathsToTranspile: ['expo-orpheus'],\n\t\t\t},\n\t\t},\n\t\targv,\n\t)\n\tconfig.resolve.modules = [\n\t\tpath.resolve(__dirname, './node_modules'),\n\t\tpath.resolve(__dirname, '../node_modules'),\n\t]\n\n\treturn config\n}\n"
  },
  {
    "path": "packages/orpheus/expo-module.config.json",
    "content": "{\n\t\"platforms\": [\"android\", \"ios\"],\n\t\"android\": {\n\t\t\"modules\": [\"expo.modules.orpheus.ExpoOrpheusModule\"]\n\t},\n\t\"ios\": {\n\t\t\"modules\": [\"ExpoOrpheusModule\"]\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/ios/AudioSpectrumAnalyzer.swift",
    "content": "import Foundation\nimport AVFoundation\nimport Accelerate\n\nclass AudioSpectrumAnalyzer {\n    static let shared = AudioSpectrumAnalyzer()\n    \n    // Configuration\n    private let fftSize: Int = 1024\n    private lazy var log2n = vDSP_Length(log2(Float(fftSize)))\n    \n    // Buffers and Setup\n    private var fftSetup: vDSP_DFT_Setup?\n    \n    // Safe data storage ensuring thread safety (Tap runs on audio thread)\n    private var frequencyData = [Float](repeating: 0, count: 512) // fftSize / 2\n    private let lock = NSLock()\n    \n    private init() {\n        fftSetup = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(fftSize), vDSP_DFT_Direction.FORWARD)\n    }\n    \n    deinit {\n        if let setup = fftSetup {\n            vDSP_DFT_DestroySetup(setup)\n        }\n    }\n    \n    // MARK: - Tap Creation\n    \n    func createTap() -> MTAudioProcessingTap? {\n        var callbacks = MTAudioProcessingTapCallbacks(\n            version: kMTAudioProcessingTapCallbacksVersion_0,\n            clientInfo: nil,\n            init: { (tap, clientInfo, tapStorageOut) in\n                // Init\n            },\n            finalize: { (tap) in\n                // Finalize\n            },\n            prepare: { (tap, maxFrames, format) in\n                // Prepare\n            },\n            unprepare: { (tap) in\n                // Unprepare\n            },\n            process: { (tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut) in\n                // Process\n                let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut)\n                \n                if status == noErr {\n                    AudioSpectrumAnalyzer.shared.processAudio(bufferList: bufferListInOut, frames: numberFrames)\n                }\n            }\n        )\n        \n        var tap: Unmanaged<MTAudioProcessingTap>?\n        let err = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PreEffects, &tap)\n        \n        if err == noErr {\n            return tap?.takeRetainedValue()\n        }\n        \n        return nil\n    }\n    \n    // MARK: - Processing\n    \n    // Called from Audio Thread - performance critical!\n    private func processAudio(bufferList: UnsafeMutablePointer<AudioBufferList>, frames: CMItemCount) {\n        let buffers = UnsafeMutableAudioBufferListPointer(bufferList)\n        \n        // Assume non-interleaved or take the first channel\n        guard let firstBuffer = buffers.first, let dataPointer = firstBuffer.mData else { return }\n        \n        // If float data (standard for AVPlayer), cast it\n        let floatPointer = dataPointer.assumingMemoryBound(to: Float.self)\n        \n        // We need 'fftSize' samples. processing buffer might be different size.\n        // For simplicity in this \"pull\" model, we just take the first fftSize samples if available,\n        // or zero pad. A real production ring buffer is better but more complex.\n        // Given high fps pull, taking a snapshot of current buffer is usually \"good enough\" for visualization.\n        \n        // Check if we have enough frames\n        let captureSize = min(Int(frames), fftSize)\n        \n        // We must perform FFT here\n        // 1. Convert real input to complex split for vDSP\n        // Actually, vDSP_DFT_Execute takes separate real and imaginary arrays if using complex-split, \n        // OR interleaved complex.\n        // Let's use vDSP_DFT_zop_CreateSetup which allows Real -> Complex?\n        // Wait, regular FFT usually expects Complex input.\n        // We can treat Real input as Complex with Imaginary = 0.\n        \n        var realIn = [Float](repeating: 0, count: fftSize)\n        var imagIn = [Float](repeating: 0, count: fftSize)\n        var realOut = [Float](repeating: 0, count: fftSize)\n        var imagOut = [Float](repeating: 0, count: fftSize)\n        \n        // Auto-scale window (Hamming/Hann) could be applied here for better quality.\n        // Copy audio data\n        for i in 0..<captureSize {\n            realIn[i] = floatPointer[i]\n        }\n        \n        // Execute FFT\n        guard let setup = fftSetup else { return }\n        \n        // vDSP_DFT_Execute expects interleaved complex? No...\n        // vDSP_DFT_Execute(_:_:_:_:_:)\n        // \"Performs an out-of-place disconnect Fourier transform\"\n        // It takes input real, input imag, output real, output imag.\n        \n        vDSP_DFT_Execute(setup, &realIn, &imagIn, &realOut, &imagOut)\n        \n        // Calculate magnitudes\n        // mag = sqrt(r^2 + i^2)\n        var magnitudes = [Float](repeating: 0, count: fftSize)\n        \n        // Using vDSP_zvabs not applicable directly unless we have DSPSplitComplex\n        // Let's just loop or use vDSP_vdist (vector distance)\n        // Magnitudes is essentially distance from (0,0) to (r, i)\n        \n        // vDSP_hvdist(realOnly, 1, imagOnly, 1, &magnitudes, 1, n) triggers \"hypot\" behavior\n        // But wait, vDSP_zvabs takes (real, imag) split complex and returns mangitude.\n        \n        var splitComplex = DSPSplitComplex(realp: &realOut, imagp: &imagOut)\n        vDSP_zvabs(&splitComplex, 1, &magnitudes, 1, vDSP_Length(fftSize))\n        \n        // Normalize\n        // Audio samples are -1..1.\n        // FFT scales by N? Or sqrt(N)?\n        // We usually want 0..1 output. \n        // Applying 1/N scaling.\n        var scale = 1.0 / Float(fftSize)\n        vDSP_vsmul(&magnitudes, 1, &scale, &magnitudes, 1, vDSP_Length(fftSize))\n        \n        // Save to thread-safe storage\n        // We only fail half (Nyquist)\n        let validCount = fftSize / 2\n        \n        if lock.try() {\n            for i in 0..<validCount {\n                frequencyData[i] = magnitudes[i]\n            }\n            lock.unlock()\n        }\n    }\n    \n    // MARK: - Public Accessor\n    \n    func fillSpectrumData(destination: UnsafeMutablePointer<Float32>, count: Int) {\n        lock.lock()\n        defer { lock.unlock() }\n        \n        let copyCount = min(count, frequencyData.count)\n        // Safe copy\n        for i in 0..<copyCount {\n            destination[i] = frequencyData[i]\n        }\n        \n        // Zero pad if destination is larger\n        if count > copyCount {\n            for i in copyCount..<count {\n                destination[i] = 0\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/ios/BilibiliApi.swift",
    "content": "import Foundation\n\nenum BilibiliError: Error {\n    case invalidUrl\n    case requestFailed\n    case decodingFailed\n    case navInfoMissing\n    case noData\n    case apiError(code: Int, message: String)\n}\n\nstruct BilibiliNavResponse: Codable {\n    let code: Int\n    let data: NavData?\n    \n    struct NavData: Codable {\n        let wbi_img: WbiImg?\n        let isLogin: Bool?\n    }\n    \n    struct WbiImg: Codable {\n        let img_url: String\n        let sub_url: String\n    }\n}\n\nstruct BilibiliPlayUrlResponse: Codable {\n    let code: Int\n    let message: String?\n    let data: PlayUrlData?\n    \n    struct PlayUrlData: Codable {\n        let durl: [Durl]?\n        let dash: Dash?\n    }\n    \n    struct Durl: Codable {\n        let url: String\n        let backup_url: [String]?\n    }\n    \n    struct Dash: Codable {\n        let audio: [DashAudio]?\n    }\n    \n    struct DashAudio: Codable {\n        let id: Int\n        let baseUrl: String\n        let backupUrl: [String]?\n    }\n}\n\n\nstruct BilibiliPageListResponse: Codable {\n    let code: Int\n    let message: String?\n    let data: [BilibiliPageListItem]?\n}\n\nstruct BilibiliPageListItem: Codable {\n    let cid: Int\n    let page: Int\n    let part: String\n}\n\nclass BilibiliApi {\n    static let shared = BilibiliApi()\n    \n    private let session = URLSession.shared\n    private var cookie: String?\n    \n    // Constants for API parameters\n    private let BiliQualityHigh = 80 // 1080P\n    private let BiliFnvalDash = 16 \n    private let FnvalMp4 = 1\n    private let FnverDefault = 0\n    private let FourKEnabled = 1\n    private let PlatformHtml5 = \"html5\"\n\n    private var imgKey: String?\n    private var subKey: String?\n    private var wbiKeysUpdatedAt: Date?\n    \n    func setCookie(_ cookie: String) {\n        self.cookie = cookie\n    }\n    \n    func getPageList(bvid: String, completion: @escaping (Result<Int, Error>) -> Void) {\n         guard var components = URLComponents(string: \"https://api.bilibili.com/x/player/pagelist\") else {\n             completion(.failure(BilibiliError.invalidUrl))\n             return\n         }\n         components.queryItems = [URLQueryItem(name: \"bvid\", value: bvid)]\n         \n         guard let url = components.url else {\n             completion(.failure(BilibiliError.invalidUrl))\n             return\n         }\n         \n         var request = URLRequest(url: url)\n         request.httpMethod = \"GET\"\n         if let cookie = cookie {\n             request.setValue(cookie, forHTTPHeaderField: \"Cookie\")\n         }\n         \n\n         \n         session.dataTask(with: request) { data, response, error in\n             if let error = error {\n                 completion(.failure(error))\n                 return\n             }\n             \n             guard let data = data else {\n                 completion(.failure(BilibiliError.noData))\n                 return\n             }\n             \n             do {\n                 let apiResponse = try JSONDecoder().decode(BilibiliPageListResponse.self, from: data)\n                 if apiResponse.code != 0 {\n                     completion(.failure(BilibiliError.apiError(code: apiResponse.code, message: apiResponse.message ?? \"Unknown error\")))\n                     return\n                 }\n                 \n                 if let firstPage = apiResponse.data?.first {\n                     completion(.success(firstPage.cid))\n                 } else {\n                     completion(.failure(BilibiliError.decodingFailed))\n                 }\n             } catch {\n                 completion(.failure(error))\n             }\n         }.resume()\n    }\n    \n    func refreshNavInfo(completion: @escaping (Result<Void, Error>) -> Void) {\n        let urlStr = \"https://api.bilibili.com/x/web-interface/nav\"\n        guard let url = URL(string: urlStr) else {\n            completion(.failure(BilibiliError.invalidUrl))\n            return\n        }\n        \n        var request = URLRequest(url: url)\n        if let cookie = cookie {\n            request.setValue(cookie, forHTTPHeaderField: \"Cookie\")\n        }\n        request.setValue(\"https://www.bilibili.com\", forHTTPHeaderField: \"Referer\")\n        request.setValue(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\", forHTTPHeaderField: \"User-Agent\")\n        \n        session.dataTask(with: request) { [weak self] data, response, error in\n            if let error = error {\n                completion(.failure(error))\n                return\n            }\n            \n            guard let data = data else {\n                completion(.failure(BilibiliError.requestFailed))\n                return\n            }\n            \n            do {\n                let navResponse = try JSONDecoder().decode(BilibiliNavResponse.self, from: data)\n                if let wbiImg = navResponse.data?.wbi_img {\n                    self?.imgKey = WbiUtil.extractKey(url: wbiImg.img_url)\n                    self?.subKey = WbiUtil.extractKey(url: wbiImg.sub_url)\n                    completion(.success(()))\n                } else {\n                    completion(.failure(BilibiliError.navInfoMissing))\n                }\n            } catch {\n                completion(.failure(error))\n            }\n        }.resume()\n    }\n    \n    func getPlayUrl(bvid: String, cid: String, completion: @escaping (Result<String, Error>) -> Void) {\n        guard let imgKey = imgKey, let subKey = subKey else {\n            // Refresh nav info first\n            refreshNavInfo { result in\n                switch result {\n                case .success:\n                    self.getPlayUrl(bvid: bvid, cid: cid, completion: completion)\n                case .failure(let error):\n                    completion(.failure(error))\n                }\n            }\n            return\n        }\n        \n        let params: [String: Any] = [\n            \"bvid\": bvid,\n            \"cid\": cid,\n            \"qn\": BiliQualityHigh,\n            // fnval=1 requests MP4/FLV durl list which is better for AVPlayer\n            \"fnval\": FnvalMp4, \n            \"fnver\": FnverDefault,\n            \"fourk\": FourKEnabled,\n            \"platform\": PlatformHtml5\n        ]\n        \n        let signedParams = WbiUtil.sign(params: params, imgKey: imgKey, subKey: subKey)\n        \n        guard var components = URLComponents(string: \"https://api.bilibili.com/x/player/wbi/playurl\") else {\n             completion(.failure(BilibiliError.invalidUrl))\n             return\n        }\n        components.queryItems = signedParams.map { URLQueryItem(name: $0.key, value: $0.value) }\n        \n        \n        guard let url = components.url else {\n             completion(.failure(BilibiliError.invalidUrl))\n             return\n        }\n        \n        var request = URLRequest(url: url)\n        if let cookie = cookie {\n            request.setValue(cookie, forHTTPHeaderField: \"Cookie\")\n        }\n        request.setValue(\"https://www.bilibili.com\", forHTTPHeaderField: \"Referer\")\n        request.setValue(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\", forHTTPHeaderField: \"User-Agent\")\n        \n        session.dataTask(with: request) { data, response, error in\n            if let error = error {\n                completion(.failure(error))\n                return\n            }\n            \n            guard let data = data else {\n                completion(.failure(BilibiliError.requestFailed))\n                return\n            }\n            \n            \n            do {\n                let playUrlResponse = try JSONDecoder().decode(BilibiliPlayUrlResponse.self, from: data)\n                \n                if playUrlResponse.code != 0 {\n                     completion(.failure(BilibiliError.requestFailed))\n                     return\n                }\n                \n                // Prioritize Dash Audio, then Durl\n                if let audioUrl = playUrlResponse.data?.dash?.audio?.first?.baseUrl {\n                    completion(.success(audioUrl))\n                } else if let mp4Url = playUrlResponse.data?.durl?.first?.url {\n                    completion(.success(mp4Url))\n                } else {\n                    completion(.failure(BilibiliError.decodingFailed))\n                }\n            } catch {\n                completion(.failure(error))\n            }\n        }.resume()\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/ios/ExpoOrpheus.podspec",
    "content": "require 'json'\n\npackage = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))\n\nPod::Spec.new do |s|\n  s.name           = 'ExpoOrpheus'\n  s.version        = package['version']\n  s.summary        = package['description']\n  s.description    = package['description']\n  s.license        = package['license']\n  s.author         = package['author']\n  s.homepage       = package['homepage']\n  s.platforms      = {\n    :ios => '15.1',\n    :tvos => '15.1'\n  }\n  s.swift_version  = '5.9'\n  s.source         = { git: 'https://github.com/bbplayer-app/bbplayer.git' }\n  s.static_framework = true\n\n  s.dependency 'ExpoModulesCore'\n  s.dependency 'MMKV'\n\n  # Swift/Objective-C compatibility\n  s.pod_target_xcconfig = {\n    'DEFINES_MODULE' => 'YES'\n  }\n  \n  s.source_files = \"**/*.{h,m,swift}\"\nend\n"
  },
  {
    "path": "packages/orpheus/ios/ExpoOrpheusModule.swift",
    "content": "import ExpoModulesCore\nimport MMKV\n\npublic class ExpoOrpheusModule: Module {\n\n    private func setupEventListeners() {\n        let manager = OrpheusPlayerManager.shared\n\n        manager.onPlaybackStateChanged = { [weak self] state in\n            self?.sendEvent(\"onPlaybackStateChanged\", [\"state\": state.rawValue])\n        }\n\n        manager.onTrackStarted = { [weak self] trackId, reason in\n            self?.sendEvent(\"onTrackStarted\", [\n                \"trackId\": trackId,\n                \"reason\": reason.rawValue\n            ])\n        }\n\n        manager.onPositionUpdate = { [weak self] position, duration, buffered in\n            self?.sendEvent(\"onPositionUpdate\", [\n                \"position\": position,\n                \"duration\": duration,\n                \"buffered\": buffered\n            ])\n        }\n\n        OrpheusDownloadManager.shared.onDownloadUpdated = { [weak self] task in\n            self?.sendEvent(\"onDownloadUpdated\", [\n                \"id\": task.id,\n                \"state\": task.state.rawValue,\n                \"percentDownloaded\": task.percentDownloaded,\n                \"bytesDownloaded\": task.bytesDownloaded,\n                \"contentLength\": task.contentLength\n            ])\n        }\n\n        manager.onTrackFinished = { [weak self] trackId, finalPosition, duration in\n            self?.sendEvent(\"onTrackFinished\", [\n                \"trackId\": trackId,\n                \"finalPosition\": finalPosition,\n                \"duration\": duration\n            ])\n        }\n\n        manager.onPlayerError = { [weak self] errorMsg in\n            self?.sendEvent(\"onPlayerError\", [\"platform\": \"ios\", \"error\": errorMsg])\n        }\n\n        manager.onIsPlayingChanged = { [weak self] isPlaying in\n            self?.sendEvent(\"onIsPlayingChanged\", [\"status\": isPlaying])\n        }\n    }\n\n    public func definition() -> ModuleDefinition {\n        Name(\"Orpheus\")\n\n\n        Events(\n            \"onPlaybackStateChanged\",\n            \"onPlayerError\",\n            \"onPositionUpdate\",\n            \"onIsPlayingChanged\",\n            \"onDownloadUpdated\",\n            \"onPlaybackSpeedChanged\",\n            \"onHeadlessEvent\",\n            \"onTrackStarted\",\n            \"onTrackFinished\"\n        )\n\n        OnCreate {\n            MMKV.initialize(rootDir: nil)\n            self.setupEventListeners()\n        }\n\n        // MARK: - Preferences\n\n        Property(\"restorePlaybackPositionEnabled\")\n            .get { GeneralStorage.shared.isRestoreEnabled }\n            .set { GeneralStorage.shared.isRestoreEnabled = $0 }\n\n        Property(\"loudnessNormalizationEnabled\")\n            .get { GeneralStorage.shared.isLoudnessNormalizationEnabled }\n            .set { GeneralStorage.shared.isLoudnessNormalizationEnabled = $0 }\n\n        Property(\"autoplayOnStartEnabled\")\n            .get { GeneralStorage.shared.isAutoplayOnStartEnabled }\n            .set { GeneralStorage.shared.isAutoplayOnStartEnabled = $0 }\n\n    // MARK: - Getters\n\n    AsyncFunction(\"getPosition\") { () -> Double in\n        return OrpheusPlayerManager.shared.getPosition()\n    }\n\n    AsyncFunction(\"getDuration\") { () -> Double in\n        return OrpheusPlayerManager.shared.getDuration()\n    }\n\n    AsyncFunction(\"getBuffered\") { () -> Double in\n        return OrpheusPlayerManager.shared.getBufferedPosition()\n    }\n\n    AsyncFunction(\"getIsPlaying\") { () -> Bool in\n        return OrpheusPlayerManager.shared.isPlaying()\n    }\n\n    AsyncFunction(\"getCurrentIndex\") { () -> Int in\n        return OrpheusPlayerManager.shared.getCurrentIndex()\n    }\n\n    AsyncFunction(\"getCurrentTrack\") { () -> Track? in\n        return OrpheusPlayerManager.shared.getCurrentTrack()\n    }\n\n    AsyncFunction(\"getQueue\") { () -> [Track] in\n        return OrpheusPlayerManager.shared.getQueue()\n    }\n\n    AsyncFunction(\"getIndexTrack\") { (index: Int) -> Track? in\n        return OrpheusPlayerManager.shared.getTrack(at: index)\n    }\n\n    AsyncFunction(\"getPlaybackSpeed\") { () -> Double in\n        return Double(OrpheusPlayerManager.shared.getPlaybackSpeed())\n    }\n\n    AsyncFunction(\"getRepeatMode\") { () -> Int in\n        return OrpheusPlayerManager.shared.repeatMode.rawValue\n    }\n\n    AsyncFunction(\"getShuffleMode\") { () -> Bool in\n        return OrpheusPlayerManager.shared.shuffleMode\n    }\n\n    // MARK: - Controls\n\n    AsyncFunction(\"play\") {\n        OrpheusPlayerManager.shared.play()\n    }\n    AsyncFunction(\"pause\") {\n        OrpheusPlayerManager.shared.pause()\n    }\n\n    AsyncFunction(\"skipToNext\") {\n        OrpheusPlayerManager.shared.playNext()\n    }\n\n    AsyncFunction(\"skipToPrevious\") {\n        OrpheusPlayerManager.shared.skipToPrevious()\n    }\n\n    AsyncFunction(\"seekTo\") { (seconds: Double) in\n        OrpheusPlayerManager.shared.seek(to: seconds)\n    }\n\n    AsyncFunction(\"skipTo\") { (index: Int) in\n        OrpheusPlayerManager.shared.skipTo(index: index)\n    }\n\n    AsyncFunction(\"addToEnd\") { (tracks: [Track], startFromId: String?, clearQueue: Bool) in\n        OrpheusPlayerManager.shared.addToEnd(tracks: tracks, startFromId: startFromId, clearQueue: clearQueue)\n    }\n\n    AsyncFunction(\"playNext\") { (track: Track) in\n        OrpheusPlayerManager.shared.addToNext(track: track)\n    }\n\n    AsyncFunction(\"removeTrack\") { (index: Int) in\n        OrpheusPlayerManager.shared.removeTrack(at: index)\n    }\n\n    AsyncFunction(\"clear\") {\n         OrpheusPlayerManager.shared.clearQueue()\n    }\n\n    AsyncFunction(\"setPlaybackSpeed\") { (speed: Double) in\n        OrpheusPlayerManager.shared.setPlaybackSpeed(Float(speed))\n    }\n\n    Function(\"setBilibiliCookie\") { (cookie: String) in\n        BilibiliApi.shared.setCookie(cookie)\n    }\n\n    Function(\"setShuffleMode\") { (enabled: Bool) in\n        OrpheusPlayerManager.shared.setExecuteShuffleMode(enabled)\n    }\n\n    Function(\"setRepeatMode\") { (mode: Int) in\n        if let repeatMode = RepeatMode(rawValue: mode) {\n            OrpheusPlayerManager.shared.setExecuteRepeatMode(repeatMode)\n        }\n    }\n\n    Function(\"setSleepTimer\") { (durationMs: Double) in\n        OrpheusPlayerManager.shared.setSleepTimer(durationMs: durationMs)\n    }\n\n    Function(\"getSleepTimerEndTime\") { () -> Double? in\n        return OrpheusPlayerManager.shared.getSleepTimerEndTime()\n    }\n\n    Function(\"cancelSleepTimer\") {\n        OrpheusPlayerManager.shared.cancelSleepTimer()\n    }\n\n    // MARK: - Downloads\n\n    Function(\"downloadTrack\") { (track: Track) in\n        OrpheusDownloadManager.shared.downloadTrack(track: track)\n    }\n\n    Function(\"multiDownload\") { (tracks: [Track]) in\n        OrpheusDownloadManager.shared.multiDownload(tracks: tracks)\n    }\n\n    Function(\"resumeDownload\") { (id: String) in\n        OrpheusDownloadManager.shared.resumeDownload(id: id)\n    }\n\n    Function(\"retryDownload\") { (track: Track) in\n        OrpheusDownloadManager.shared.downloadTrack(track: track)\n    }\n\n    Function(\"setDownloadMaxParallelTasks\") { (_: Int) in\n        // iOS download concurrency is currently managed by URLSession.\n    }\n\n    Function(\"removeDownload\") { (id: String) in\n        OrpheusDownloadManager.shared.removeDownload(id: id)\n    }\n\n    Function(\"removeDownloads\") { (ids: [String]) in\n        for id in ids {\n            OrpheusDownloadManager.shared.removeDownload(id: id)\n        }\n    }\n\n    Function(\"removeAllDownloads\") {\n        OrpheusDownloadManager.shared.removeAllDownloads()\n    }\n\n    Function(\"getDownloads\") { () -> [DownloadTask] in\n        return OrpheusDownloadManager.shared.getDownloads()\n    }\n\n    Function(\"getDownloadStatusByIds\") { (ids: [String]) -> [String: Int] in\n        return OrpheusDownloadManager.shared.getDownloadStatusByIds(ids: ids)\n    }\n\n    Function(\"clearUncompletedDownloadTasks\") {\n        OrpheusDownloadManager.shared.clearUncompletedTasks()\n    }\n\n    Function(\"getUncompletedDownloadTasks\") { () -> [DownloadTask] in\n         return OrpheusDownloadManager.shared.getUncompletedTasks()\n    }\n\n    AsyncFunction(\"checkOverlayPermission\") { () -> Bool in\n        return false\n    }\n\n    AsyncFunction(\"requestOverlayPermission\") {\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"showDesktopLyrics\") {\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"hideDesktopLyrics\") {\n         throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"clearOverlays\") {\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"setLyricsInternal\") { (_: String, _: [String]) in\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"setDesktopLyricsInternal\") { (lyricsJson: String) in\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"setStatusBarLyricsInternal\") { (lyricsJson: String) in\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    AsyncFunction(\"debugTriggerError\") {\n        throw NSError(domain: \"Orpheus\", code: 1, userInfo: [NSLocalizedDescriptionKey: \"Platform not supported\"])\n    }\n\n    Function(\"updateSpectrumData\") { (destination: TypedArray) in\n        let count = destination.length\n        // Get the unsafe pointer to valid memory\n        let pointer = destination.getUnsafeMutablePointer(Float32.self)\n\n        if let ptr = pointer {\n            AudioSpectrumAnalyzer.shared.fillSpectrumData(destination: ptr, count: count)\n        }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/orpheus/ios/GeneralStorage.swift",
    "content": "import Foundation\nimport MMKV\n\nclass GeneralStorage {\n    static let shared = GeneralStorage()\n    \n    private let mmkv = MMKV.default()\n    \n    private let KEY_SAVED_QUEUE = \"saved_queue_json_list\"\n    private let KEY_SAVED_INDEX = \"saved_index\"\n    private let KEY_SAVED_POSITION = \"saved_position\"\n    private let KEY_SAVED_REPEAT_MODE = \"saved_repeat_mode\"\n    private let KEY_SAVED_SHUFFLE_MODE = \"saved_shuffle_mode\"\n    \n    private let KEY_RESTORE_ENABLED = \"restorePlaybackPositionEnabled\"\n    private let KEY_LOUDNESS_ENABLED = \"loudnessNormalizationEnabled\"\n    private let KEY_AUTOPLAY_ENABLED = \"autoplayOnStartEnabled\"\n    \n    // MARK: - Preferences\n    \n    var isRestoreEnabled: Bool {\n        get { return mmkv?.bool(forKey: KEY_RESTORE_ENABLED, defaultValue: false) ?? false }\n        set { mmkv?.set(newValue, forKey: KEY_RESTORE_ENABLED) }\n    }\n    \n    var isLoudnessNormalizationEnabled: Bool {\n        get { return mmkv?.bool(forKey: KEY_LOUDNESS_ENABLED, defaultValue: true) ?? true }\n        set { mmkv?.set(newValue, forKey: KEY_LOUDNESS_ENABLED) }\n    }\n    \n    var isAutoplayOnStartEnabled: Bool {\n        get { return mmkv?.bool(forKey: KEY_AUTOPLAY_ENABLED, defaultValue: false) ?? false }\n        set { mmkv?.set(newValue, forKey: KEY_AUTOPLAY_ENABLED) }\n    }\n    \n    // MARK: - Playback State\n    \n    func saveQueue(_ queue: [Track]) {\n        let dicts = queue.map { $0.dictionaryRepresentation }\n        do {\n            let data = try JSONSerialization.data(withJSONObject: dicts, options: [])\n            mmkv?.set(data, forKey: KEY_SAVED_QUEUE)\n        } catch {\n             print(\"Failed to save queue: \\(error)\")\n        }\n    }\n    \n    func getSavedQueue() -> [Track] {\n        guard let data = mmkv?.data(forKey: KEY_SAVED_QUEUE),\n              let dicts = try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else {\n            return []\n        }\n        \n        return dicts.compactMap { Track(dictionary: $0) }\n    }\n    \n    func savePosition(index: Int, positionSec: Double) {\n        mmkv?.set(Int32(index), forKey: KEY_SAVED_INDEX)\n        \n        if !positionSec.isNaN && !positionSec.isInfinite {\n             let positionMs = Int64(positionSec * 1000)\n             mmkv?.set(Int64(positionMs), forKey: KEY_SAVED_POSITION)\n        }\n    }\n    \n    func getSavedIndex() -> Int {\n        return Int(mmkv?.int32(forKey: KEY_SAVED_INDEX, defaultValue: -1) ?? -1)\n    }\n    \n    func getSavedPosition() -> Double {\n        return Double(mmkv?.int64(forKey: KEY_SAVED_POSITION, defaultValue: 0) ?? 0) / 1000.0\n    }\n    \n    func saveRepeatMode(_ mode: Int) {\n        mmkv?.set(Int32(mode), forKey: KEY_SAVED_REPEAT_MODE)\n    }\n    \n    func getSavedRepeatMode() -> Int {\n        return Int(mmkv?.int32(forKey: KEY_SAVED_REPEAT_MODE, defaultValue: 0) ?? 0)\n    }\n    \n    func saveShuffleMode(_ enabled: Bool) {\n        mmkv?.set(enabled, forKey: KEY_SAVED_SHUFFLE_MODE)\n    }\n    \n    func getSavedShuffleMode() -> Bool {\n        return mmkv?.bool(forKey: KEY_SAVED_SHUFFLE_MODE, defaultValue: false) ?? false\n    }\n}\n\n"
  },
  {
    "path": "packages/orpheus/ios/OrpheusDownloadManager.swift",
    "content": "import Foundation\nimport ExpoModulesCore\nimport MMKV\n\nclass OrpheusDownloadManager: NSObject, URLSessionDownloadDelegate {\n    static let shared = OrpheusDownloadManager()\n    \n    private let stateQueue = DispatchQueue(label: \"com.orpheus.download.state\")\n    \n    private var urlSession: URLSession!\n    private var downloadTasks: [String: DownloadState] = [:] // Map taskID/url to state\n    private var activeTasks: [String: URLSessionDownloadTask] = [:] // Map ID to task\n    private var trackMap: [String: Track] = [:] // Map ID to Track metadata\n    var onDownloadUpdated: ((DownloadTask) -> Void)?\n    \n    override init() {\n        super.init()\n        let config = URLSessionConfiguration.background(withIdentifier: \"com.orpheus.download\")\n        config.isDiscretionary = false\n        config.sessionSendsLaunchEvents = true\n        urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)\n        \n        restoreTasks()\n    }\n    \n    func downloadTrack(track: Track) {\n        // Resolve URL if needed (Bilibili logic)\n        let urlString = track.url\n        if urlString.starts(with: \"orpheus://bilibili\") {\n            resolveAndDownload(track: track)\n        } else {\n            startDownload(url: urlString, track: track)\n        }\n    }\n\n    func resumeDownload(id: String) {\n        guard let track = stateQueue.sync(execute: { trackMap[id] }) else { return }\n        downloadTrack(track: track)\n    }\n    \n    private func resolveAndDownload(track: Track) {\n        guard let uri = URL(string: track.url),\n              let components = URLComponents(url: uri, resolvingAgainstBaseURL: false) else { return }\n        \n        let bvid = components.queryItems?.first(where: { $0.name == \"bvid\" })?.value\n        let cid = components.queryItems?.first(where: { $0.name == \"cid\" })?.value\n        \n        guard let bvid = bvid, let cid = cid else { return }\n        \n        BilibiliApi.shared.getPlayUrl(bvid: bvid, cid: cid) { [weak self] result in\n            DispatchQueue.main.async {\n                switch result {\n                case .success(let realUrl):\n                    self?.startDownload(url: realUrl, track: track)\n                case .failure(let error):\n                    // No ID available if we failed before starting? Actually track.id is there.\n                    self?.notifyUpdate(id: track.id, state: .failed, track: track)\n                }\n            }\n        }\n    }\n    \n    private func notifyUpdate(id: String, state: DownloadState, track: Track?) {\n        let task = DownloadTask()\n        task.id = id\n        task.state = state\n        task.track = track\n        \n        if state == .completed {\n            task.percentDownloaded = 1.0\n        }\n        \n        onDownloadUpdated?(task)\n    }\n    \n    private func startDownload(url: String, track: Track) {\n        guard let nsUrl = URL(string: url) else { return }\n        \n        stateQueue.sync {\n            var request = URLRequest(url: nsUrl)\n            if url.contains(\"bilivideo.com\") {\n                 request.setValue(\"https://www.bilibili.com/\", forHTTPHeaderField: \"Referer\")\n                 request.setValue(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\", forHTTPHeaderField: \"User-Agent\")\n            }\n            \n            let task = urlSession.downloadTask(with: request)\n            task.taskDescription = track.id\n            \n            trackMap[track.id] = track\n            activeTasks[track.id] = task\n            downloadTasks[track.id] = .downloading\n            \n            task.resume()\n            \n            saveTasksLocked()\n        }\n        \n        notifyUpdate(id: track.id, state: .downloading, track: track)\n    }\n    \n    // Internal version of startDownload used by restoreTasks within lock\n    private func startDownloadLocked(url: String, track: Track) {\n        guard let nsUrl = URL(string: url) else { return }\n        \n        var request = URLRequest(url: nsUrl)\n        if url.contains(\"bilivideo.com\") {\n             request.setValue(\"https://www.bilibili.com/\", forHTTPHeaderField: \"Referer\")\n             request.setValue(\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\", forHTTPHeaderField: \"User-Agent\")\n        }\n        \n        let task = urlSession.downloadTask(with: request)\n        task.taskDescription = track.id\n        \n        trackMap[track.id] = track\n        activeTasks[track.id] = task\n        downloadTasks[track.id] = .downloading\n        \n        task.resume()\n        saveTasksLocked()\n        \n        // Notify outside lock? Or via async\n        DispatchQueue.main.async {\n            self.notifyUpdate(id: track.id, state: .downloading, track: track)\n        }\n    }\n    \n    func removeDownload(id: String) {\n        stateQueue.sync {\n            if let task = activeTasks[id] {\n                task.cancel()\n                activeTasks.removeValue(forKey: id)\n            }\n            \n            let fileManager = FileManager.default\n            let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n            let dest = docs.appendingPathComponent(\"downloads/\\(id).mp4\")\n            try? fileManager.removeItem(at: dest)\n            \n            downloadTasks.removeValue(forKey: id)\n            trackMap.removeValue(forKey: id)\n            \n            saveTasksLocked()\n        }\n        notifyUpdate(id: id, state: .removing, track: nil)\n    }\n    \n    func removeAllDownloads() {\n        stateQueue.sync {\n            for (_, task) in activeTasks {\n                task.cancel()\n            }\n            activeTasks.removeAll()\n            \n            let fileManager = FileManager.default\n            let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n            let downloadsDir = docs.appendingPathComponent(\"downloads\")\n            try? fileManager.removeItem(at: downloadsDir)\n            \n            downloadTasks.removeAll()\n            trackMap.removeAll()\n            \n            saveTasksLocked()\n        }\n    }\n    \n    // MARK: - Persistence\n    \n    private let KEY_SAVED_TASKS = \"saved_download_tasks\"\n    \n    private struct SavedTrack: Codable {\n        let id: String\n        let url: String\n        let title: String?\n        let artist: String?\n        let artwork: String?\n        let duration: Double?\n    }\n    \n    private struct PersistedTask: Codable {\n        let id: String\n        let state: Int\n        let track: SavedTrack\n    }\n    \n    private func toSavedTrack(_ track: Track) -> SavedTrack {\n        return SavedTrack(\n            id: track.id,\n            url: track.url,\n            title: track.title,\n            artist: track.artist,\n            artwork: track.artwork,\n            duration: track.duration\n        )\n    }\n    \n    private func fromSavedTrack(_ saved: SavedTrack) -> Track {\n        let t = Track()\n        t.id = saved.id\n        t.url = saved.url\n        t.title = saved.title\n        t.artist = saved.artist\n        t.artwork = saved.artwork\n        t.duration = saved.duration\n        return t\n    }\n    \n    private func saveTasks() {\n        stateQueue.sync {\n            saveTasksLocked()\n        }\n    }\n    \n    private func saveTasksLocked() {\n        let tasksToSave = trackMap.map { (id, track) -> PersistedTask in\n            let state = downloadTasks[id] ?? .queued\n            return PersistedTask(id: id, state: state.rawValue, track: toSavedTrack(track))\n        }\n        \n        if let data = try? JSONEncoder().encode(tasksToSave) {\n            MMKV.default()?.set(data, forKey: KEY_SAVED_TASKS)\n        }\n    }\n    \n    private func restoreTasks() {\n        guard let data = MMKV.default()?.data(forKey: KEY_SAVED_TASKS),\n              let persisted = try? JSONDecoder().decode([PersistedTask].self, from: data) else {\n            return\n        }\n            \n        // Initial load (called in init)\n        for p in persisted {\n            let track = fromSavedTrack(p.track)\n            trackMap[p.id] = track\n            if let state = DownloadState(rawValue: p.state) {\n                downloadTasks[p.id] = state\n            }\n        }\n        \n        // Check for existing background tasks first\n        urlSession.getAllTasks { [weak self] tasks in\n            guard let self = self else { return }\n            \n            self.stateQueue.sync {\n                var runningTaskIds = Set<String>()\n                \n                for task in tasks {\n                    if let dlTask = task as? URLSessionDownloadTask, let id = dlTask.taskDescription {\n                        self.activeTasks[id] = dlTask\n                        runningTaskIds.insert(id)\n                        \n                        // Update state to downloading if it was running\n                        if dlTask.state == .running {\n                            self.downloadTasks[id] = .downloading\n                        }\n                    }\n                }\n                \n                // Auto-resume downloads that should be running but aren't\n                let fileManager = FileManager.default\n                let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n                let downloadsDir = docs.appendingPathComponent(\"downloads\")\n                \n                let tasksSnapshot = self.downloadTasks\n                for (id, state) in tasksSnapshot {\n                    if state == .downloading && !runningTaskIds.contains(id) {\n                        let dest = downloadsDir.appendingPathComponent(\"\\(id).mp4\")\n                        if fileManager.fileExists(atPath: dest.path) {\n                            // File exists, mark completed\n                            self.downloadTasks[id] = .completed\n                        } else {\n                             // Not running and file missing -> restart\n                             // Ensure we don't have an active task (already checked !runningTaskIds but activeTasks map might differ if logic is buggy, but runningTaskIds comes from session)\n                             if self.activeTasks[id] == nil, let track = self.trackMap[id] {\n                                 // Restart logic:\n                                 // If it's a bilibili link, we need to resolve again which is async.\n                                 // We can't do that synchronously inside restoreTasks if we want to hold the lock.\n                                 // Dispatch to main to start the full download flow (resolve -> download)\n                                 DispatchQueue.main.async {\n                                     self.downloadTrack(track: track)\n                                 }\n                             }\n                        }\n                    }\n                }\n            }\n        }\n    }\n    \n    // MARK: - Delegate\n    \n    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {\n        guard let id = downloadTask.taskDescription else { return }\n        \n        let fileManager = FileManager.default\n        let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n        let downloadsDir = docs.appendingPathComponent(\"downloads\")\n        \n        do {\n            try fileManager.createDirectory(at: downloadsDir, withIntermediateDirectories: true)\n            // Use mp4 for now. Ideally retain extension from url or metadata.\n            let dest = downloadsDir.appendingPathComponent(\"\\(id).mp4\")\n            \n            if fileManager.fileExists(atPath: dest.path) {\n                try fileManager.removeItem(at: dest)\n            }\n            try fileManager.moveItem(at: location, to: dest)\n            \n            var track: Track?\n            stateQueue.sync {\n                downloadTasks[id] = .completed\n                activeTasks.removeValue(forKey: id)\n                saveTasksLocked()\n                track = trackMap[id]\n            }\n            \n            if let t = track {\n                notifyUpdate(id: id, state: .completed, track: t)\n            }\n        } catch {\n            var track: Track?\n            stateQueue.sync {\n                downloadTasks[id] = .failed\n                saveTasksLocked()\n                track = trackMap[id]\n            }\n            notifyUpdate(id: id, state: .failed, track: track)\n        }\n    }\n    \n    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {\n        guard let id = task.taskDescription else { return }\n        \n        if let error = error {\n            var track: Track?\n            var state: DownloadState = .failed\n            \n            stateQueue.sync {\n                if (error as NSError).code == NSURLErrorCancelled {\n                    downloadTasks[id] = .stopped\n                    state = .stopped\n                } else {\n                    downloadTasks[id] = .failed\n                    state = .failed\n                }\n                activeTasks.removeValue(forKey: id)\n                saveTasksLocked()\n                track = trackMap[id]\n            }\n            \n            if state == .failed {\n                notifyUpdate(id: id, state: .failed, track: track)\n            }\n        }\n    }\n    \n    func getDownloads() -> [DownloadTask] {\n        return stateQueue.sync {\n            return trackMap.map { (id, track) -> DownloadTask in\n                let task = DownloadTask()\n                task.id = id\n                task.state = downloadTasks[id] ?? .queued\n                task.track = track\n                \n                if task.state == .completed {\n                    task.percentDownloaded = 1.0\n                    task.contentLength = 0\n                    let fileManager = FileManager.default\n                    let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n                    let dest = docs.appendingPathComponent(\"downloads/\\(id).mp4\")\n                    if let attrs = try? fileManager.attributesOfItem(atPath: dest.path),\n                       let size = attrs[.size] as? Double {\n                        task.contentLength = size\n                        task.bytesDownloaded = size\n                    }\n                } else if let active = activeTasks[id] {\n                    task.bytesDownloaded = Double(active.countOfBytesReceived)\n                    task.contentLength = Double(active.countOfBytesExpectedToReceive)\n                    task.percentDownloaded = task.contentLength > 0 ? task.bytesDownloaded / task.contentLength : 0\n                }\n                \n                return task\n            }\n        }\n    }\n    \n    func multiDownload(tracks: [Track]) {\n        for track in tracks {\n            downloadTrack(track: track)\n        }\n    }\n    \n    func getDownloadStatusByIds(ids: [String]) -> [String: Int] {\n        return stateQueue.sync {\n            var result: [String: Int] = [:]\n            for id in ids {\n                if let state = downloadTasks[id] {\n                    result[id] = state.rawValue\n                }\n            }\n            return result\n        }\n    }\n    \n    func clearUncompletedTasks() {\n        stateQueue.sync {\n            // Need to remove tasks physically too\n            let idsShouldRemove = downloadTasks.filter { $0.value != .completed }.map { $0.key }\n            \n            let fileManager = FileManager.default\n            let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n            \n            for id in idsShouldRemove {\n                if let task = activeTasks[id] {\n                    task.cancel()\n                    activeTasks.removeValue(forKey: id)\n                }\n                \n                let dest = docs.appendingPathComponent(\"downloads/\\(id).mp4\")\n                try? fileManager.removeItem(at: dest)\n                \n                downloadTasks.removeValue(forKey: id)\n                trackMap.removeValue(forKey: id)\n            }\n            \n            saveTasksLocked()\n        }\n    }\n    \n    func getUncompletedTasks() -> [DownloadTask] {\n        // Safe to call getDownloads() (which syncs) then filter\n        return getDownloads().filter { $0.state != .completed }\n    }\n    \n    func getDownloadedFileUrl(id: String) -> URL? {\n        let fileManager = FileManager.default\n        let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!\n        let dest = docs.appendingPathComponent(\"downloads/\\(id).mp4\")\n        \n        if fileManager.fileExists(atPath: dest.path) {\n            return dest\n        }\n        return nil\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/ios/OrpheusModels.swift",
    "content": "import ExpoModulesCore\n\nstruct Track: Record {\n  @Field\n  var id: String = \"\"\n  \n  @Field\n  var url: String = \"\"\n  \n  @Field\n  var title: String?\n  \n  @Field\n  var artist: String?\n  \n  @Field\n  var artwork: String?\n  \n  @Field\n  var duration: Double?\n}\n\nextension Track {\n    var dictionaryRepresentation: [String: Any] {\n        var dict: [String: Any] = [\n            \"id\": id,\n            \"url\": url\n        ]\n        if let title = title { dict[\"title\"] = title }\n        if let artist = artist { dict[\"artist\"] = artist }\n        if let artwork = artwork { dict[\"artwork\"] = artwork }\n        if let duration = duration { dict[\"duration\"] = duration }\n        return dict\n    }\n    \n    init?(dictionary: [String: Any]) {\n        guard let id = dictionary[\"id\"] as? String,\n              let url = dictionary[\"url\"] as? String else { return nil }\n              \n        self.init()\n        self.id = id\n        self.url = url\n        self.title = dictionary[\"title\"] as? String\n        self.artist = dictionary[\"artist\"] as? String\n        self.artwork = dictionary[\"artwork\"] as? String\n        self.duration = dictionary[\"duration\"] as? Double\n    }\n}\n\nenum PlaybackState: Int, Enumerable {\n  case idle = 1\n  case buffering = 2\n  case ready = 3\n  case ended = 4\n}\n\nenum RepeatMode: Int, Enumerable {\n  case off = 0\n  case track = 1\n  case queue = 2\n}\n\nenum TransitionReason: Int, Enumerable {\n  case repeatMode = 0\n  case auto = 1\n  case seek = 2\n  case playlistChanged = 3\n}\n\nenum DownloadState: Int, Enumerable {\n  case queued = 0\n  case stopped = 1\n  case downloading = 2\n  case completed = 3\n  case failed = 4\n  case removing = 5\n  case restarting = 7\n}\n\nstruct DownloadTask: Record {\n  @Field\n  var id: String = \"\"\n  \n  @Field\n  var state: DownloadState = .queued\n  \n  @Field\n  var percentDownloaded: Double = 0.0\n  \n  @Field\n  var bytesDownloaded: Double = 0.0\n  \n  @Field\n  var contentLength: Double = 0.0\n  \n  @Field\n  var track: Track?\n}\n"
  },
  {
    "path": "packages/orpheus/ios/OrpheusPlayerManager.swift",
    "content": "import AVFoundation\nimport ExpoModulesCore\nimport MediaPlayer\n\nclass OrpheusPlayerManager: NSObject {\n    static let shared = OrpheusPlayerManager()\n    \n    private let player: AVPlayer\n    private let queueManager = OrpheusQueueManager()\n\n    \n    var repeatMode: RepeatMode = .off\n    var shuffleMode: Bool = false\n    \n\n    \n    // Image Cache\n    private var imageCache = NSCache<NSString, UIImage>()\n    private var sleepTimer: Timer?\n    private var sleepTimerEndTime: Date?\n    \n\n    \n\n    var onPlaybackStateChanged: ((PlaybackState) -> Void)?\n    var onTrackStarted: ((String, TransitionReason) -> Void)?\n    var onTrackFinished: ((String, Double, Double) -> Void)?\n    var onPositionUpdate: ((Double, Double, Double) -> Void)?\n    var onIsPlayingChanged: ((Bool) -> Void)?\n    var onPlayerError: ((String) -> Void)?\n    \n    override init() {\n        self.player = AVPlayer()\n        super.init()\n        setupPlayerObservers()\n        setupAudioSession()\n        setupRemoteCommands()\n        restoreState()\n    }\n    \n    private var timeObserverToken: Any?\n    \n    private func setupPlayerObservers() {\n        // Status observer\n        player.addObserver(self, forKeyPath: \"timeControlStatus\", options: [.new], context: nil)\n        player.addObserver(self, forKeyPath: \"currentItem.status\", options: [.new], context: nil)\n        \n        // Periodic time observer\n        let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))\n        timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in\n            self?.notifyPositionUpdate()\n        }\n        \n        // End of track observer\n        NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying), name: .AVPlayerItemDidPlayToEndTime, object: nil)\n    }\n    \n    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {\n        if keyPath == \"timeControlStatus\" {\n\n            notifyPlaybackState()\n        } else if keyPath == \"currentItem.status\" {\n            let status = player.currentItem?.status ?? .unknown\n\n            if status == .failed {\n                let errorMsg = player.currentItem?.error?.localizedDescription ?? \"unknown error\"\n\n                onPlayerError?(errorMsg)\n                onPlaybackStateChanged?(.idle)\n                onIsPlayingChanged?(false)\n            }\n        }\n    }\n    \n    @objc private func playerDidFinishPlaying(note: NSNotification) {\n        handleAutoAdvance()\n    }\n    \n    private func handleAutoAdvance() {\n        if let current = queueManager.getCurrentTrack(), let duration = player.currentItem?.duration.seconds {\n             onTrackFinished?(current.id, duration, duration)\n        }\n\n        if repeatMode == .track {\n            player.seek(to: .zero)\n            player.play()\n            return\n        }\n        \n        skipToNext(reason: .auto)\n    }\n    \n    // MARK: - Queue Management\n    \n    func getQueue() -> [Track] {\n        return queueManager.getQueue()\n    }\n    \n    func getCurrentTrack() -> Track? {\n        return queueManager.getCurrentTrack()\n    }\n    \n    func getTrack(at index: Int) -> Track? {\n        let queue = queueManager.getQueue()\n        guard index >= 0 && index < queue.count else { return nil }\n        return queue[index]\n    }\n    \n    func getCurrentIndex() -> Int {\n        return queueManager.getCurrentIndex()\n    }\n    \n    func setQueue(_ tracks: [Track], startIndex: Int) {\n        queueManager.setQueue(tracks, startFromIndex: startIndex, inputShuffleMode: shuffleMode)\n        \n        let currentIndex = queueManager.getCurrentIndex()\n        if currentIndex >= 0 {\n             playTrack(at: currentIndex, reason: .playlistChanged)\n        }\n        \n        saveState()\n    }\n    \n    private func getQueueCount() -> Int {\n        return queueManager.getQueueCount()\n    }\n    \n    func removeTrack(at index: Int) {\n        // index is backing index\n        let wasCurrent = queueManager.removeTrack(at: index)\n        \n        if wasCurrent {\n             if queueManager.getQueueCount() == 0 {\n                  stopPlayback()\n             } else {\n                  // Play new current or stop if none\n                  if let current = queueManager.getCurrentTrack() {\n\n                      playTrack(at: queueManager.getCurrentIndex(), reason: .playlistChanged)\n                  } else {\n                      stopPlayback()\n                  }\n             }\n        }\n        \n        saveState()\n    }\n    \n    func clearQueue() {\n        queueManager.clear()\n        stopPlayback()\n        updateNowPlayingInfo()\n        saveState()\n    }\n    \n    private func stopPlayback() {\n        player.pause()\n        player.replaceCurrentItem(with: nil)\n        onPlaybackStateChanged?(.idle)\n        onIsPlayingChanged?(false)\n        notifyPositionUpdate()\n    }\n    \n    func addToNext(track: Track) {\n        queueManager.insertNext(track: track)\n        saveState()\n    }\n    \n    func addToEnd(tracks: [Track], startFromId: String?, clearQueue: Bool) {\n        if clearQueue {\n            // Logic similar to setQueue\n            var startIndex = 0\n            if let startId = startFromId, let index = tracks.firstIndex(where: { $0.id == startId }) {\n                startIndex = index\n            }\n            setQueue(tracks, startIndex: startIndex)\n        } else {\n            // Append\n            queueManager.append(tracks: tracks)\n            \n            if let startId = startFromId, let index = queueManager.getQueue().firstIndex(where: { $0.id == startId }) {\n                // If startFromId is present, play the specified track\n                playTrack(at: index, reason: .playlistChanged)\n            }\n            saveState()\n        }\n    }\n    \n    func setExecuteShuffleMode(_ enabled: Bool) {\n        guard shuffleMode != enabled else { return }\n        shuffleMode = enabled\n        queueManager.setShuffleMode(enabled)\n        saveState()\n    }\n    \n    func setExecuteRepeatMode(_ mode: RepeatMode) {\n        repeatMode = mode\n        saveState()\n    }\n    \n    // MARK: - Playback Control\n    \n    func play() {\n        let currentIndex = queueManager.getCurrentIndex()\n        if player.currentItem == nil && currentIndex >= 0 {\n\n             playTrack(at: currentIndex, reason: .auto)\n             return\n        }\n        if player.status == .failed || player.currentItem?.status == .failed {\n\n             playTrack(at: currentIndex, reason: .auto)\n             return\n        }\n        player.play()\n    }\n    \n    func pause() {\n        player.pause()\n    }\n    \n    func playNext() {\n        skipToNext(reason: .seek)\n    }\n    \n    func skipToNext(reason: TransitionReason) {\n        let count = queueManager.getQueueCount()\n        if count == 0 { return }\n        \n        guard let nextIndex = queueManager.getNextIndex(repeatMode: repeatMode) else {\n             // End of queue\n             onPlaybackStateChanged?(.ended)\n             return\n        }\n        \n        playTrack(at: nextIndex, reason: reason)\n    }\n    \n    func skipToPrevious() {\n        // 跟随 Media3 逻辑（或许是行业标准？），当播放超过 3s，「上一曲」的语义变成「重新播放」\n        if player.currentTime().seconds > 3.0 {\n            player.seek(to: CMTime.zero)\n            return\n        }\n        \n        guard let prevIndex = queueManager.getPreviousIndex(repeatMode: repeatMode) else {\n             player.seek(to: CMTime.zero)\n             return\n        }\n        \n        playTrack(at: prevIndex, reason: .seek)\n    }\n    \n    func seek(to seconds: Double) {\n        let time = CMTime(seconds: seconds, preferredTimescale: 1000)\n        player.seek(to: time)\n    }\n    \n    // MARK: - Track Loading\n    \n    private func playTrack(at index: Int, reason: TransitionReason, startPosition: Double? = nil) {\n        // Handle previous track finish (for manual skips)\n        if reason != .auto, let oldTrack = queueManager.getCurrentTrack() {\n             let position = player.currentTime().seconds\n             let duration = player.currentItem?.duration.seconds ?? 0\n             // Only emit if we actually have a duration (implying we were playing something)\n             // or just emit whatever state we have.\n             onTrackFinished?(oldTrack.id, position, duration)\n        }\n\n        // Index is BACKING index\n        queueManager.skipTo(backingIndex: index)\n        guard let track = queueManager.getCurrentTrack() else {\n            return\n        }\n        \n        // Optimistic update\n\n        \n        onTrackStarted?(track.id, reason)\n        saveState()\n        \n        let urlString = track.url\n        \n        // Check for local download first\n        if let localUrl = OrpheusDownloadManager.shared.getDownloadedFileUrl(id: track.id) {\n             // Use local file\n             loadAvPlayerItem(url: localUrl.absoluteString, headers: nil, startPosition: startPosition)\n             return\n        }\n        \n        if urlString.starts(with: \"orpheus://bilibili\") {\n            resolveAndPlayBilibili(url: urlString, startPosition: startPosition)\n        } else {\n            loadAvPlayerItem(url: urlString, headers: nil, startPosition: startPosition)\n        }\n    }\n    \n    private func resolveAndPlayBilibili(url: String, startPosition: Double? = nil) {\n        guard let uri = URL(string: url),\n              let components = URLComponents(url: uri, resolvingAgainstBaseURL: false) else {\n\n            onPlayerError?(\"Invalid Bilibili URL\")\n            onPlaybackStateChanged?(.idle)\n            return\n        }\n        \n        let bvid = components.queryItems?.first(where: { $0.name == \"bvid\" })?.value\n        let cid = components.queryItems?.first(where: { $0.name == \"cid\" })?.value\n        \n        guard let bvid = bvid else {\n\n            onPlayerError?(\"Missing bvid in URL\")\n            onPlaybackStateChanged?(.idle)\n            return\n        }\n        \n        if let cid = cid {\n            fetchBilibiliPlayUrl(bvid: bvid, cid: cid, startPosition: startPosition)\n        } else {\n\n            BilibiliApi.shared.getPageList(bvid: bvid) { [weak self] result in\n                DispatchQueue.main.async {\n                    switch result {\n                    case .success(let cidInt):\n\n                        self?.fetchBilibiliPlayUrl(bvid: bvid, cid: String(cidInt), startPosition: startPosition)\n                    case .failure(let error):\n\n                        self?.onPlayerError?(error.localizedDescription)\n                        self?.onPlaybackStateChanged?(.idle)\n                    }\n                }\n            }\n        }\n    }\n    \n    private func fetchBilibiliPlayUrl(bvid: String, cid: String, startPosition: Double? = nil) {\n\n        \n        BilibiliApi.shared.getPlayUrl(bvid: bvid, cid: cid) { [weak self] result in\n            DispatchQueue.main.async {\n                switch result {\n                case .success(let realUrl):\n\n                    // Bilibili requires Referer header\n                    let headers: [String: String] = [\n                        \"Referer\": \"https://www.bilibili.com/\",\n                        \"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n                    ]\n                    self?.loadAvPlayerItem(url: realUrl, headers: headers, startPosition: startPosition)\n                case .failure(let error):\n\n                    self?.onPlayerError?(error.localizedDescription)\n                    // Reset state so UI doesn't stick in loading\n                    self?.onPlaybackStateChanged?(.idle)\n                }\n            }\n        }\n    }\n    \n    private func loadAvPlayerItem(url: String, headers: [String: String]?, startPosition: Double? = nil) {\n        guard let nsUrl = URL(string: url) else {\n\n            return\n        }\n        \n\n        \n        let asset: AVURLAsset\n        if let headers = headers {\n            let options = [\"AVURLAssetHTTPHeaderFieldsKey\": headers]\n            asset = AVURLAsset(url: nsUrl, options: options)\n        } else {\n            asset = AVURLAsset(url: nsUrl)\n        }\n        \n        let item = AVPlayerItem(asset: asset)\n        \n        // Attach Spectrum Tap\n        if let tap = AudioSpectrumAnalyzer.shared.createTap() {\n            let audioMix = AVMutableAudioMix()\n            // Try to find audio track. Note: This assumes tracks are available synchronously or shortly. \n            // For robust implementation with remote assets, we might need to wait for \"tracks\" key.\n            // But doing it synchronously here is the \"simple\" approach.\n            if let track = asset.tracks(withMediaType: .audio).first {\n                let inputParams = AVMutableAudioMixInputParameters(track: track)\n                inputParams.audioTapProcessor = tap\n                audioMix.inputParameters = [inputParams]\n                item.audioMix = audioMix\n            }\n        }\n\n        player.replaceCurrentItem(with: item)\n        if let startPos = startPosition, startPos > 0 {\n             let time = CMTime(seconds: startPos, preferredTimescale: 1000)\n             player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)\n        }\n        player.play()\n        \n\n    }\n    \n    // MARK: - Notification\n    \n    private func notifyPositionUpdate() {\n        let currentTime = player.currentTime().seconds\n        let duration = player.currentItem?.duration.seconds ?? 0\n        let buffered = player.currentItem?.loadedTimeRanges.last?.timeRangeValue.end.seconds ?? 0\n        \n        onPositionUpdate?(currentTime, duration, buffered)\n        \n        // Update lock screen progress occasionally or on state change\n        // MPNowPlayingInfoCenter handles 'elapsedPlaybackTime' automatically so we mainly update on rate/status changes.\n    }\n    \n    private func notifyPlaybackState() {\n        var state: PlaybackState = .idle\n        switch player.timeControlStatus {\n        case .paused:\n            state = .ready\n\n        case .waitingToPlayAtSpecifiedRate:\n            state = .buffering\n\n        case .playing:\n            state = .ready\n\n        @unknown default:\n            state = .idle\n\n        }\n        \n        onPlaybackStateChanged?(state)\n        onIsPlayingChanged?(isPlaying())\n        \n        if state == .ready || state == .idle {\n             savePositionState()\n        }\n        \n        updateNowPlayingInfo()\n    }\n    \n    // MARK: - System Integration\n    \n    private func setupAudioSession() {\n        do {\n            let session = AVAudioSession.sharedInstance()\n            try session.setCategory(.playback, mode: .default, options: [])\n            try session.setActive(true)\n            \n            // Interruption observer\n            NotificationCenter.default.addObserver(self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: session)\n        } catch {\n\n        }\n    }\n    \n    @objc private func handleInterruption(notification: Notification) {\n        guard let userInfo = notification.userInfo,\n              let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,\n              let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }\n        \n        if type == .began {\n            // Audio interrupted (phone call, etc.), pause player\n\n             pause()\n        } else if type == .ended {\n            if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {\n                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)\n                if options.contains(.shouldResume) {\n                    play()\n                }\n            }\n        }\n    }\n    \n    private func setupRemoteCommands() {\n        let commandCenter = MPRemoteCommandCenter.shared()\n        \n        commandCenter.togglePlayPauseCommand.isEnabled = true\n        commandCenter.playCommand.isEnabled = true\n        commandCenter.pauseCommand.isEnabled = true\n        commandCenter.nextTrackCommand.isEnabled = true\n        commandCenter.previousTrackCommand.isEnabled = true\n        commandCenter.changePlaybackPositionCommand.isEnabled = true\n        \n        commandCenter.togglePlayPauseCommand.addTarget { [weak self] event in\n            guard let self = self else { return .commandFailed }\n            if self.isPlaying() {\n                self.pause()\n            } else {\n                self.play()\n            }\n            return .success\n        }\n        \n        commandCenter.playCommand.addTarget { [weak self] event in\n            self?.play()\n            return .success\n        }\n        \n        commandCenter.pauseCommand.addTarget { [weak self] event in\n            self?.pause()\n            return .success\n        }\n        \n        commandCenter.nextTrackCommand.addTarget { [weak self] event in\n            self?.playNext()\n            return .success\n        }\n        \n        commandCenter.previousTrackCommand.addTarget { [weak self] event in\n            self?.skipToPrevious()\n            return .success\n        }\n        \n        commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in\n            if let event = event as? MPChangePlaybackPositionCommandEvent {\n                self?.seek(to: event.positionTime)\n                return .success\n            }\n            return .commandFailed\n        }\n    }\n    \n    private func updateNowPlayingInfo() {\n        guard let track = getCurrentTrack() else {\n            MPNowPlayingInfoCenter.default().nowPlayingInfo = nil\n            return\n        }\n        \n        // Ensure audio session is active for lock screen controls to appear\n        \n        var info: [String: Any] = [\n            MPMediaItemPropertyTitle: track.title ?? \"Unknown Title\",\n            MPMediaItemPropertyArtist: track.artist ?? \"Unknown Artist\",\n            MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds,\n            MPNowPlayingInfoPropertyPlaybackRate: player.rate,\n            MPNowPlayingInfoPropertyPlaybackQueueIndex: queueManager.getCurrentIndex(),\n            MPNowPlayingInfoPropertyPlaybackQueueCount: queueManager.getQueueCount()\n        ]\n        \n        if let duration = track.duration {\n             info[MPMediaItemPropertyPlaybackDuration] = duration\n        } else if let playerDuration = player.currentItem?.duration.seconds, !playerDuration.isNaN {\n             info[MPMediaItemPropertyPlaybackDuration] = playerDuration\n        }\n        \n        // Artwork\n        if let artworkUrlStr = track.artwork, let artworkUrl = URL(string: artworkUrlStr) {\n            downloadImage(url: artworkUrl) { image in\n                guard let image = image else { return }\n                \n                // Verify track hasn't changed before updating artwork\n                if var currentInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {\n                    if currentInfo[MPMediaItemPropertyTitle] as? String == track.title {\n                         let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }\n                         currentInfo[MPMediaItemPropertyArtwork] = artwork\n                         MPNowPlayingInfoCenter.default().nowPlayingInfo = currentInfo\n                    }\n                } else {\n                    // If info was cleared but we just got image, maybe we should set it again?\n                    // Safe to just update info variable and set it?\n                    // We need to carry over the other fields.\n                    info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }\n                    MPNowPlayingInfoCenter.default().nowPlayingInfo = info\n                }\n            }\n        }\n        \n        MPNowPlayingInfoCenter.default().nowPlayingInfo = info\n    }\n    \n\n    private func downloadImage(url: URL, completion: @escaping (UIImage?) -> Void) {\n        URLSession.shared.dataTask(with: url) { data, _, _ in\n            guard let data = data, let image = UIImage(data: data) else {\n                completion(nil)\n                return\n            }\n            DispatchQueue.main.async {\n                completion(image)\n            }\n        }.resume()\n    }\n    \n    func skipTo(index: Int) {\n        // index is backing index from UI\n        playTrack(at: index, reason: .seek)\n    }\n    \n    // MARK: - Playback Attributes\n    \n    func setPlaybackSpeed(_ speed: Float) {\n        player.rate = speed\n    }\n    \n    func getPlaybackSpeed() -> Float {\n        return player.rate\n    }\n    \n    // MARK: - Getters\n    \n    func getPosition() -> Double {\n        return player.currentTime().seconds\n    }\n    \n    func getBufferedPosition() -> Double {\n        return player.currentItem?.loadedTimeRanges.last?.timeRangeValue.end.seconds ?? 0.0\n    }\n    \n    func getDuration() -> Double {\n        return player.currentItem?.duration.seconds ?? 0.0\n    }\n    \n    func isPlaying() -> Bool {\n        return player.rate > 0 && player.error == nil\n    }\n    \n    // MARK: - Sleep Timer\n    \n    func setSleepTimer(durationMs: Double) {\n        cancelSleepTimer()\n        \n        let interval = durationMs / 1000.0\n        sleepTimerEndTime = Date().addingTimeInterval(interval)\n        \n        sleepTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in\n            self?.pause()\n            self?.cancelSleepTimer()\n        }\n    }\n    \n    func cancelSleepTimer() {\n        sleepTimer?.invalidate()\n        sleepTimer = nil\n        sleepTimerEndTime = nil\n    }\n    \n    func getSleepTimerEndTime() -> Double? {\n        guard let endTime = sleepTimerEndTime else { return nil }\n        return endTime.timeIntervalSince1970 * 1000.0\n    }\n    \n    // MARK: - Persistence\n    \n    private func saveState() {\n        GeneralStorage.shared.saveQueue(queueManager.getQueue())\n        \n        GeneralStorage.shared.savePosition(\n            index: queueManager.getCurrentIndex(),\n            positionSec: player.currentTime().seconds\n        )\n        \n        GeneralStorage.shared.saveRepeatMode(repeatMode.rawValue)\n        GeneralStorage.shared.saveShuffleMode(shuffleMode)\n    }\n    \n    private func savePositionState() {\n        let seconds = player.currentTime().seconds\n        GeneralStorage.shared.savePosition(index: queueManager.getCurrentIndex(), positionSec: seconds)\n    }\n\n    private func restoreState() {\n        // Restore Modes\n        if let mode = RepeatMode(rawValue: GeneralStorage.shared.getSavedRepeatMode()) {\n            repeatMode = mode\n        }\n        shuffleMode = GeneralStorage.shared.getSavedShuffleMode()\n        \n        // Restore Queue\n        let restoredQueue = GeneralStorage.shared.getSavedQueue()\n        \n        // Restore Index\n        let savedIndex = GeneralStorage.shared.getSavedIndex()\n        var savedPosition = 0.0\n        if GeneralStorage.shared.isRestoreEnabled {\n            savedPosition = GeneralStorage.shared.getSavedPosition()\n        }\n        \n        if !restoredQueue.isEmpty {\n             // Initialize queue manager\n             queueManager.setQueue(restoredQueue, startFromIndex: savedIndex, inputShuffleMode: shuffleMode)\n             \n             // Now handle playback state restoration\n             if savedIndex >= 0 && savedIndex < restoredQueue.count {\n                 if GeneralStorage.shared.isAutoplayOnStartEnabled {\n                     playTrack(at: savedIndex, reason: .playlistChanged, startPosition: savedPosition > 0 ? savedPosition : nil)\n                 } else {\n                     // Prepare but paused\n                     if let track = queueManager.getCurrentTrack() {\n                         onTrackStarted?(track.id, .playlistChanged)\n                         // Emit position update so UI is consistent\n                         onPositionUpdate?(savedPosition, track.duration ?? 0, 0)\n                         // Preparing UI state without creating AVPlayerItem yet\n                     }\n                 }\n             }\n        }\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/ios/OrpheusQueueManager.swift",
    "content": "import Foundation\n\n// 队列中所有有关 index 的操作都是以 backingQueue 为准的\nclass OrpheusQueueManager {\n    private var backingQueue: [Track] = []\n    private var shuffleIndices: [Int]?\n    private var currentIndex: Int = -1 // This is ALWAYS the index in backingQueue\n    private var pendingShuffleInit: Bool = false\n    \n    // MARK: - Getters\n    \n    func getQueue() -> [Track] {\n        return backingQueue\n    }\n    \n    func getCurrentTrack() -> Track? {\n        guard currentIndex >= 0 && currentIndex < backingQueue.count else { return nil }\n        return backingQueue[currentIndex]\n    }\n    \n    func getCurrentIndex() -> Int {\n        return currentIndex\n    }\n    \n    func getQueueCount() -> Int {\n        return backingQueue.count\n    }\n    \n    func isShuffled() -> Bool {\n        return shuffleIndices != nil\n    }\n    \n    // MARK: - Queue Operations\n    \n    func setQueue(_ tracks: [Track], startFromIndex: Int, inputShuffleMode: Bool) {\n        backingQueue = tracks\n        \n        if inputShuffleMode {\n            shuffleIndices = Array(0..<tracks.count).shuffled()\n            \n            if startFromIndex >= 0 && startFromIndex < tracks.count {\n                currentIndex = startFromIndex\n            } else {\n            currentIndex = shuffleIndices?.first ?? -1 // Start with first in shuffle if no specific start\n                 if currentIndex == -1 && !tracks.isEmpty { currentIndex = tracks.indices.contains(0) ? shuffleIndices?.first ?? 0 : 0 }\n            }\n            \n        } else {\n            shuffleIndices = nil\n            pendingShuffleInit = false\n            if tracks.isEmpty {\n                currentIndex = -1\n            } else if startFromIndex >= 0 && startFromIndex < tracks.count {\n                currentIndex = startFromIndex\n            } else {\n                currentIndex = 0 // Safe default\n            }\n        }\n    }\n    \n    func setShuffleMode(_ enabled: Bool) {\n        if enabled {\n            if backingQueue.isEmpty {\n                pendingShuffleInit = true\n            } else if shuffleIndices == nil {\n                generateShuffleIndices()\n            }\n        } else {\n            shuffleIndices = nil\n            pendingShuffleInit = false\n        }\n    }\n    \n    private func generateShuffleIndices() {\n        guard !backingQueue.isEmpty else { return }\n        var newIndices = Array(0..<backingQueue.count).shuffled()\n\n        if currentIndex >= 0 && currentIndex < backingQueue.count {\n            if let pos = newIndices.firstIndex(of: currentIndex) {\n                newIndices.swapAt(0, pos)\n            }\n        }\n        \n        shuffleIndices = newIndices\n    }\n    \n    func setRepeatMode(_ mode: RepeatMode) {\n        // Queue manager might not need to store this if we pass it in next/prev methods\n    }\n    \n    // MARK: - Navigation\n    \n    // Returns the backing index of the next track\n    func getNextIndex(repeatMode: RepeatMode) -> Int? {\n        guard !backingQueue.isEmpty else { return nil }\n        \n        let playbackIndex = getPlaybackIndex(for: currentIndex)\n        var nextPlaybackIndex = playbackIndex + 1\n        \n        if nextPlaybackIndex >= backingQueue.count {\n            if repeatMode == .queue || repeatMode == .track { \n                 nextPlaybackIndex = 0\n            } else {\n                return nil // End of queue\n            }\n        }\n        \n        return getBackingIndex(from: nextPlaybackIndex)\n    }\n    \n    func getPreviousIndex(repeatMode: RepeatMode) -> Int? {\n        guard !backingQueue.isEmpty else { return nil }\n        \n        let playbackIndex = getPlaybackIndex(for: currentIndex)\n        var prevPlaybackIndex = playbackIndex - 1\n        \n        if prevPlaybackIndex < 0 {\n             if repeatMode == .queue || repeatMode == .track {\n                 prevPlaybackIndex = backingQueue.count - 1\n             } else {\n                 return nil // Start of queue\n             }\n        }\n        \n        return getBackingIndex(from: prevPlaybackIndex)\n    }\n     \n    func skipTo(backingIndex: Int) {\n        if backingIndex >= 0 && backingIndex < backingQueue.count {\n            currentIndex = backingIndex\n        }\n    }\n    \n    // MARK: - Modification\n    \n    func removeTrack(at backingIndex: Int) -> Bool {\n        // Returns true if current track was removed (requiring player stop/next)\n        guard backingIndex >= 0 && backingIndex < backingQueue.count else { return false }\n        \n        let wasCurrent = (backingIndex == currentIndex)\n        var removedShufflePos: Int? = nil\n        \n        if let indices = shuffleIndices, let pos = indices.firstIndex(of: backingIndex) {\n            removedShufflePos = pos\n        }\n        \n        backingQueue.remove(at: backingIndex)\n        \n        // Update shuffle indices\n        if let indices = shuffleIndices {\n            var newIndices = indices.filter { $0 != backingIndex }\n            // Shift indices > removed index down\n            newIndices = newIndices.map { $0 > backingIndex ? $0 - 1 : $0 }\n            shuffleIndices = newIndices\n        }\n        \n        // Update current index\n        if backingIndex < currentIndex {\n            currentIndex -= 1\n        } else if wasCurrent {\n             // Current track removed.\n             if backingQueue.isEmpty {\n                 currentIndex = -1\n             } else {\n                 if currentIndex >= backingQueue.count {\n                     currentIndex = 0 \n                 }\n                 \n                 // If shuffled, pick the next one in shuffle order\n                 if let indices = shuffleIndices, let removedPos = removedShufflePos {\n                     if !indices.isEmpty {\n                         // We want the item that is now at removedPos (or wrap)\n                         let nextPos = removedPos < indices.count ? removedPos : 0\n                         currentIndex = indices[nextPos]\n                     } else {\n                         currentIndex = -1\n                     }\n                 }\n             }\n        }\n        \n        return wasCurrent\n    }\n    \n    func append(tracks: [Track]) {\n        let startIndex = backingQueue.count\n        backingQueue.append(contentsOf: tracks)\n        \n        if pendingShuffleInit {\n            generateShuffleIndices()\n            pendingShuffleInit = false\n        } else if var indices = shuffleIndices {\n            // Add new items to end of shuffle order (standard \"Add to Queue\" behavior)\n             let newIndices = (startIndex..<(startIndex + tracks.count))\n             indices.append(contentsOf: newIndices)\n             shuffleIndices = indices\n        }\n    }\n\n    func insertNext(track: Track) {\n        if backingQueue.isEmpty {\n             backingQueue = [track]\n             currentIndex = 0\n             return\n        }\n        \n        let insertIndex = currentIndex + 1\n        backingQueue.insert(track, at: insertIndex)\n        \n        if var indices = shuffleIndices {\n             // We inserted at `insertIndex`.\n             // Any index >= insertIndex in `indices` needs to be incremented.\n             for i in 0..<indices.count {\n                 if indices[i] >= insertIndex {\n                     indices[i] += 1\n                 }\n             }\n             \n             // Now add our new item (which is at `insertIndex`) to the playback order\n             // It should be after current PLAYBACK position.\n             let currentPlaybackPos = getPlaybackIndex(for: currentIndex)\n             let targetPlaybackPos = currentPlaybackPos + 1\n             indices.insert(insertIndex, at: targetPlaybackPos)\n             \n             shuffleIndices = indices\n        }\n    }\n    \n    func clear() {\n        backingQueue.removeAll()\n        shuffleIndices = nil\n        currentIndex = -1\n    }\n    \n    // MARK: - Helpers\n    \n    private func getPlaybackIndex(for backingIndex: Int) -> Int {\n        if let indices = shuffleIndices {\n            return indices.firstIndex(of: backingIndex) ?? -1\n        }\n        return backingIndex\n    }\n    \n    private func getBackingIndex(from playbackIndex: Int) -> Int? {\n        if let indices = shuffleIndices {\n            guard playbackIndex >= 0 && playbackIndex < indices.count else { return nil }\n            return indices[playbackIndex]\n        }\n        guard playbackIndex >= 0 && playbackIndex < backingQueue.count else { return nil }\n        return playbackIndex\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/ios/WbiUtil.swift",
    "content": "import Foundation\nimport CommonCrypto\n\nclass WbiUtil {\n    private static let mixinKeyEncTab: [Int] = [\n        46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,\n        33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,\n        61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,\n        36, 20, 34, 44, 52\n    ]\n\n    static func getMixinKey(orig: String) -> String {\n        var result = \"\"\n        let origChars = Array(orig)\n        for i in 0..<32 {\n            if i < mixinKeyEncTab.count {\n                let index = mixinKeyEncTab[i]\n                if index < origChars.count {\n                    result.append(origChars[index])\n                }\n            }\n        }\n        return result\n    }\n\n    static func encodeURIComponent(_ string: String) -> String {\n        var allowed = CharacterSet.alphanumerics\n        allowed.insert(charactersIn: \"-_.~\") // RFC 3986 unreserved characters\n        \n        let encoded = string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string\n        // Ensure we follow RFC 3986 for consistency with Bilibili requirements\n        \n        return encoded\n    }\n    \n    static func md5(_ string: String) -> String {\n        let length = Int(CC_MD5_DIGEST_LENGTH)\n        var digest = [UInt8](repeating: 0, count: length)\n        if let data = string.data(using: .utf8) {\n            _ = data.withUnsafeBytes { body -> String in\n                CC_MD5(body.baseAddress, CC_LONG(data.count), &digest)\n                return \"\"\n            }\n        }\n        return (0..<length).reduce(\"\") {\n            $0 + String(format: \"%02x\", digest[$1])\n        }\n    }\n\n    static func sign(params: [String: Any], imgKey: String, subKey: String) -> [String: String] {\n        let mixinKey = getMixinKey(orig: imgKey + subKey)\n        let currTime = Int(Date().timeIntervalSince1970)\n        \n        var sortedParams = params\n        sortedParams[\"wts\"] = currTime\n        \n        // Sort keys\n        let keys = sortedParams.keys.sorted()\n        \n        var queryParts: [String] = []\n        for key in keys {\n            if let value = sortedParams[key] {\n                let strValue = \"\\(value)\"\n\n                \n                queryParts.append(\"\\(encodeURIComponent(key))=\\(encodeURIComponent(strValue))\")\n            }\n        }\n        \n        let queryStr = queryParts.joined(separator: \"&\")\n        let w_rid = md5(queryStr + mixinKey)\n        \n        var finalMap: [String: String] = [:]\n        for (k, v) in sortedParams {\n            finalMap[k] = \"\\(v)\"\n        }\n        finalMap[\"w_rid\"] = w_rid\n        \n        return finalMap\n    }\n    \n    static func extractKey(url: String) -> String {\n\n        guard let lastComponent = url.split(separator: \"/\").last else { return \"\" }\n        let filename = String(lastComponent)\n        if let dotIndex = filename.lastIndex(of: \".\") {\n            return String(filename[..<dotIndex])\n        }\n        return filename\n    }\n}\n"
  },
  {
    "path": "packages/orpheus/mise.toml",
    "content": "[env]\n_.file = { path = \".env\", redact = true }\n"
  },
  {
    "path": "packages/orpheus/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/orpheus\",\n\t\"version\": \"0.11.3\",\n\t\"description\": \"A player for bbplayer\",\n\t\"keywords\": [\n\t\t\"ExpoOrpheus\",\n\t\t\"expo\",\n\t\t\"expo-orpheus\",\n\t\t\"react-native\"\n\t],\n\t\"homepage\": \"https://github.com/bbplayer-app/bbplayer/tree/dev/packages/orpheus\",\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer/issues\"\n\t},\n\t\"license\": \"MIT\",\n\t\"author\": \"Roitium <65794453+roitium@users.noreply.github.com> (https://github.com/roitium)\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"https://github.com/bbplayer-app/bbplayer.git\",\n\t\t\"directory\": \"packages/orpheus\"\n\t},\n\t\"files\": [\n\t\t\"build\",\n\t\t\"src\",\n\t\t\"android\",\n\t\t\"ios\",\n\t\t\"expo-module.config.json\"\n\t],\n\t\"main\": \"src/index.ts\",\n\t\"types\": \"src/index.ts\",\n\t\"scripts\": {\n\t\t\"build\": \"expo-module build\",\n\t\t\"clean\": \"expo-module clean\",\n\t\t\"expo-module\": \"expo-module\",\n\t\t\"lint\": \"expo-module lint\",\n\t\t\"open:android\": \"open -a \\\"Android Studio\\\" example/android\",\n\t\t\"prepublishOnly\": \"expo-module prepublishOnly\",\n\t\t\"test\": \"expo-module test\"\n\t},\n\t\"devDependencies\": {\n\t\t\"expo\": \"55.0.4\",\n\t\t\"expo-module-scripts\": \"^5.0.7\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\",\n\t\t\"react-native-worklets\": \"0.7.4\"\n\t},\n\t\"peerDependencies\": {\n\t\t\"expo\": \"55.0.4\",\n\t\t\"react\": \"19.2.0\",\n\t\t\"react-native\": \"0.83.2\",\n\t\t\"react-native-worklets\": \"0.7.4\"\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/src/ExpoOrpheusModule.ts",
    "content": "import { requireNativeModule, NativeModule } from 'expo-modules-core'\n\nexport enum PlaybackState {\n\tIDLE = 1,\n\tBUFFERING = 2,\n\tREADY = 3,\n\tENDED = 4,\n}\n\nexport enum RepeatMode {\n\tOFF = 0,\n\tTRACK = 1,\n\tQUEUE = 2,\n}\n\nexport enum TransitionReason {\n\tREPEAT = 0,\n\tAUTO = 1,\n\tSEEK = 2,\n\tPLAYLIST_CHANGED = 3,\n}\n\nexport interface Track {\n\tid: string\n\turl: string\n\ttitle?: string\n\tartist?: string\n\tartwork?: string\n\tduration?: number\n}\n\nexport interface LyricSpan {\n\ttext: string\n\tstartTime: number // ms\n\tendTime: number // ms\n\tduration: number // ms\n}\n\nexport interface LyricLine {\n\ttimestamp: number // seconds\n\tendTime?: number // seconds\n\ttext: string\n\ttranslation?: string\n\tromaji?: string\n\tspans?: LyricSpan[]\n}\n\nexport interface LyricsData {\n\tlyrics: LyricLine[]\n\toffset: number\n}\n\nexport type LyricConsumer = 'desktop' | 'statusBar' | 'car'\n\nexport interface AndroidPlaybackErrorEvent {\n\tplatform: 'android'\n\terrorCode: number\n\terrorCodeName: string | null\n\ttimestamp: string\n\tmessage: string | null\n\tstackTrace: string\n\trootCauseClass: string\n\trootCauseMessage: string\n}\n\nexport interface IosPlaybackErrorEvent {\n\tplatform: 'ios'\n\terror: string\n}\n\nexport type PlaybackErrorEvent =\n\t| AndroidPlaybackErrorEvent\n\t| IosPlaybackErrorEvent\n\nexport type OrpheusEvents = {\n\tonPlaybackStateChanged(event: { state: PlaybackState }): void\n\tonTrackStarted(event: { trackId: string; reason: number }): void\n\tonTrackFinished(event: {\n\t\ttrackId: string\n\t\tfinalPosition: number\n\t\tduration: number\n\t}): void\n\tonHeadlessEvent(event: OrpheusHeadlessEvent): void\n\tonPlayerError(event: PlaybackErrorEvent): void\n\tonPositionUpdate(event: {\n\t\tposition: number\n\t\tduration: number\n\t\tbuffered: number\n\t}): void\n\tonIsPlayingChanged(event: { status: boolean }): void\n\tonDownloadUpdated(event: DownloadTask): void\n\tonCoverDownloadProgress(event: {\n\t\tcurrent: number\n\t\ttotal: number\n\t\ttrackId: string\n\t\tstatus: 'success' | 'failed'\n\t}): void\n\tonPlaybackSpeedChanged(event: { speed: number }): void\n\tonExportProgress(event: {\n\t\tprogress?: number\n\t\tcurrentId: string\n\t\tindex?: number\n\t\ttotal?: number\n\t\tstatus: 'success' | 'error'\n\t\tmessage?: string\n\t}): void\n\tonStatusBarLyricsStatusChanged(): void\n}\nexport interface OrpheusHeadlessTrackStartedEvent {\n\teventName: 'onTrackStarted'\n\ttrackId: string\n\treason: number\n}\n\nexport interface OrpheusHeadlessTrackFinishedEvent {\n\teventName: 'onTrackFinished'\n\ttrackId: string\n\tfinalPosition: number\n\tduration: number\n}\n\nexport interface OrpheusHeadlessTrackPausedEvent {\n\teventName: 'onTrackPaused'\n}\n\nexport interface OrpheusHeadlessTrackResumedEvent {\n\teventName: 'onTrackResumed'\n}\n\nexport interface OrpheusHeadlessRequestClearLyricsEvent {\n\teventName: 'onRequestClearLyrics'\n\ttrackId: string\n}\n\nexport type OrpheusHeadlessEvent =\n\t| OrpheusHeadlessTrackStartedEvent\n\t| OrpheusHeadlessTrackFinishedEvent\n\t| OrpheusHeadlessTrackPausedEvent\n\t| OrpheusHeadlessTrackResumedEvent\n\t| OrpheusHeadlessRequestClearLyricsEvent\n\n/** 内部使用的原生接口定义 */\ndeclare class NativeOrpheusModule extends NativeModule<OrpheusEvents> {\n\trestorePlaybackPositionEnabled: boolean\n\tloudnessNormalizationEnabled: boolean\n\tautoplayOnStartEnabled: boolean\n\tisDesktopLyricsShown: boolean\n\tisDesktopLyricsLocked: boolean\n\tisStatusBarLyricsEnabled: boolean\n\tisCarLyricsEnabled: boolean\n\tstatusBarLyricsProvider: string\n\treadonly isSuperLyricApiEnabled: boolean\n\treadonly isLyriconApiEnabled: boolean\n\n\tgetPosition(): Promise<number>\n\tgetDuration(): Promise<number>\n\tgetBuffered(): Promise<number>\n\tgetIsPlaying(): Promise<boolean>\n\tgetCurrentIndex(): Promise<number>\n\tgetCurrentTrack(): Promise<Track | null>\n\tgetShuffleMode(): Promise<boolean>\n\tgetIndexTrack(index: number): Promise<Track | null>\n\tgetRepeatMode(): Promise<RepeatMode>\n\tsetBilibiliCookie(cookie: string): void\n\tplay(): Promise<void>\n\tpause(): Promise<void>\n\tclear(): Promise<void>\n\tskipTo(index: number): Promise<void>\n\tskipToNext(): Promise<void>\n\tskipToPrevious(): Promise<void>\n\tseekTo(seconds: number): Promise<void>\n\tsetRepeatMode(mode: RepeatMode): Promise<void>\n\tsetShuffleMode(enabled: boolean): Promise<void>\n\tgetQueue(): Promise<Track[]>\n\taddToEnd(\n\t\ttracks: Track[],\n\t\tstartFromId?: string,\n\t\tclearQueue?: boolean,\n\t): Promise<void>\n\tplayNext(track: Track): Promise<void>\n\tremoveTrack(index: number): Promise<void>\n\tsetSleepTimer(durationMs: number): Promise<void>\n\tgetSleepTimerEndTime(): Promise<number | null>\n\tcancelSleepTimer(): Promise<void>\n\tdownloadTrack(track: Track): Promise<void>\n\tremoveDownload(id: string): Promise<void>\n\tremoveDownloads(ids: string[]): Promise<void>\n\tmultiDownload(tracks: Track[]): Promise<void>\n\tresumeDownload(id: string): Promise<void>\n\tretryDownload(track: Track): Promise<void>\n\tsetDownloadMaxParallelTasks(maxParallelTasks: number): Promise<void>\n\tremoveAllDownloads(): Promise<void>\n\tgetDownloads(): Promise<DownloadTask[]>\n\tgetDownloadStatusByIds(ids: string[]): Promise<Record<string, DownloadState>>\n\tclearUncompletedDownloadTasks(): Promise<void>\n\tgetUncompletedDownloadTasks(): Promise<DownloadTask[]>\n\tdownloadMissingCovers(): Promise<number>\n\tgetDownloadedCoverUri(trackId: string): string | null\n\texportDownloads(\n\t\tids: string[],\n\t\tdestinationUri: string,\n\t\tfilenamePattern: string | null,\n\t\tembedLyrics: boolean,\n\t\tconvertToLrc: boolean,\n\t\tcropCoverArt: boolean,\n\t): Promise<void>\n\tselectDirectory(): Promise<string | null>\n\tisDirectoryPickerAvailable(): Promise<boolean>\n\tcheckOverlayPermission(): Promise<boolean>\n\trequestOverlayPermission(): Promise<void>\n\tshowDesktopLyrics(): Promise<void>\n\thideDesktopLyrics(): Promise<void>\n\tsetLyricsInternal(\n\t\tlyricsJson: string,\n\t\tconsumers: LyricConsumer[],\n\t): Promise<void>\n\tclearOverlays(): Promise<void>\n\tsetPlaybackSpeed(speed: number): Promise<void>\n\tgetPlaybackSpeed(): Promise<number>\n\tdebugTriggerError(): Promise<void>\n\tupdateSpectrumData(destination: Float32Array): void\n\tgetLruCachedUris(uris: string[]): string[]\n}\n\nconst NativeModuleInstance = requireNativeModule<NativeOrpheusModule>('Orpheus')\n\ntype PublicOrpheusModule = Omit<NativeOrpheusModule, 'setLyricsInternal'> & {\n\tsetLyrics(data: LyricsData, consumers?: LyricConsumer[]): Promise<void>\n}\n\n/**\n * Orpheus 模块的包装对象，提供更好的类型支持和便捷方法。\n */\nexport const Orpheus = NativeModuleInstance as unknown as PublicOrpheusModule\n\nOrpheus.setLyrics = async (\n\tdata: LyricsData,\n\tconsumers: LyricConsumer[] = ['desktop', 'statusBar', 'car'],\n) => {\n\treturn await NativeModuleInstance.setLyricsInternal(\n\t\tJSON.stringify(data),\n\t\tconsumers,\n\t)\n}\n\nexport const SPECTRUM_SIZE = 512\n\nexport enum DownloadState {\n\tQUEUED = 0,\n\tSTOPPED = 1,\n\tDOWNLOADING = 2,\n\tCOMPLETED = 3,\n\tFAILED = 4,\n\tREMOVING = 5,\n\tRESTARTING = 7,\n}\n\nexport interface DownloadTask {\n\tid: string\n\tstate: DownloadState\n\tpercentDownloaded: number\n\tbytesDownloaded: number\n\tcontentLength: number\n\ttrack?: Track\n}\n"
  },
  {
    "path": "packages/orpheus/src/headless.ts",
    "content": "import { AppRegistry, Platform } from 'react-native'\n\nimport { Orpheus, type OrpheusHeadlessEvent } from './ExpoOrpheusModule'\n\nconst ORPHEUS_HEADLESS_TASK = 'OrpheusHeadlessTask'\n\nexport function registerOrpheusHeadlessTask(\n\ttask: (event: OrpheusHeadlessEvent) => Promise<void>,\n) {\n\t// On iOS, we bridge events from the Native Module to the headless task logic.\n\tif (Platform.OS === 'ios') {\n\t\tOrpheus.addListener('onTrackStarted', (event) => {\n\t\t\ttask({\n\t\t\t\teventName: 'onTrackStarted',\n\t\t\t\t...event,\n\t\t\t}).catch((e) => console.error('[Orpheus] Headless task error:', e))\n\t\t})\n\n\t\tOrpheus.addListener('onTrackFinished', (event) => {\n\t\t\ttask({\n\t\t\t\teventName: 'onTrackFinished',\n\t\t\t\t...event,\n\t\t\t}).catch((e) => console.error('[Orpheus] Headless task error:', e))\n\t\t})\n\n\t\tOrpheus.addListener('onIsPlayingChanged', (event: { status: boolean }) => {\n\t\t\ttask({\n\t\t\t\teventName: event.status ? 'onTrackResumed' : 'onTrackPaused',\n\t\t\t}).catch((e) => console.error('[Orpheus] Headless task error:', e))\n\t\t})\n\n\t\t// 懒得管 ios 了\n\t\t// Orpheus.addListener(\n\t\t// \t'onRequestClearLyrics',\n\t\t// \t(event: { trackId: string }) => {\n\t\t// \t\ttask({\n\t\t// \t\t\teventName: 'onRequestClearLyrics',\n\t\t// \t\t\t...event,\n\t\t// \t\t}).catch((e) => console.error('[Orpheus] Headless task error:', e))\n\t\t// \t},\n\t\t// )\n\t}\n\n\t// On Android, the Headless Task Service handles this natively.\n\tif (Platform.OS === 'android') {\n\t\tAppRegistry.registerHeadlessTask(ORPHEUS_HEADLESS_TASK, () => task)\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/src/hooks/index.ts",
    "content": "export * from './useProgress'\nexport * from './usePlaybackState'\nexport * from './useIsPlaying'\nexport * from './useCurrentTrack'\nexport * from './useOrpheus'\n"
  },
  {
    "path": "packages/orpheus/src/hooks/useCurrentTrack.ts",
    "content": "import { useState, useEffect } from 'react'\n\nimport { type Track, Orpheus } from '../ExpoOrpheusModule'\n\nexport function useCurrentTrack() {\n\tconst [track, setTrack] = useState<Track | null>(null)\n\tconst [index, setIndex] = useState<number>(-1)\n\n\tconst fetchTrack = async () => {\n\t\ttry {\n\t\t\tconst [currentTrack, currentIndex] = await Promise.all([\n\t\t\t\tOrpheus.getCurrentTrack(),\n\t\t\t\tOrpheus.getCurrentIndex(),\n\t\t\t])\n\t\t\tconsole.log(currentTrack)\n\t\t\treturn { currentTrack, currentIndex }\n\t\t} catch (e) {\n\t\t\tconsole.warn('Failed to fetch current track', e)\n\t\t\treturn { currentTrack: null, currentIndex: -1 }\n\t\t}\n\t}\n\n\tuseEffect(() => {\n\t\tlet isMounted = true\n\n\t\tvoid fetchTrack().then(({ currentTrack, currentIndex }) => {\n\t\t\tif (isMounted) {\n\t\t\t\tsetTrack(currentTrack)\n\t\t\t\tsetIndex(currentIndex)\n\t\t\t}\n\t\t})\n\n\t\tconst sub = Orpheus.addListener('onTrackStarted', async () => {\n\t\t\tconsole.log('Track Started')\n\t\t\tconst { currentTrack, currentIndex } = await fetchTrack()\n\t\t\tif (isMounted) {\n\t\t\t\tsetTrack(currentTrack)\n\t\t\t\tsetIndex(currentIndex)\n\t\t\t}\n\t\t})\n\n\t\treturn () => {\n\t\t\tisMounted = false\n\t\t\tsub.remove()\n\t\t}\n\t}, [])\n\n\treturn { track, index }\n}\n"
  },
  {
    "path": "packages/orpheus/src/hooks/useIsPlaying.ts",
    "content": "import { useState, useEffect } from 'react'\n\nimport { Orpheus } from '../ExpoOrpheusModule'\n\nexport function useIsPlaying() {\n\tconst [isPlaying, setIsPlaying] = useState(false)\n\n\tuseEffect(() => {\n\t\tlet isMounted = true\n\n\t\tvoid Orpheus.getIsPlaying().then((val) => {\n\t\t\tif (isMounted) setIsPlaying(val)\n\t\t})\n\n\t\tconst sub = Orpheus.addListener('onIsPlayingChanged', (event) => {\n\t\t\tif (isMounted) setIsPlaying(event.status)\n\t\t})\n\n\t\treturn () => {\n\t\t\tisMounted = false\n\t\t\tsub.remove()\n\t\t}\n\t}, [])\n\n\treturn isPlaying\n}\n"
  },
  {
    "path": "packages/orpheus/src/hooks/useOrpheus.ts",
    "content": "import { useCurrentTrack } from './useCurrentTrack'\nimport { useIsPlaying } from './useIsPlaying'\nimport { usePlaybackState } from './usePlaybackState'\nimport { useProgress } from './useProgress'\n\nexport function useOrpheus() {\n\tconst state = usePlaybackState()\n\tconst isPlaying = useIsPlaying()\n\tconst progress = useProgress()\n\tconst { track, index } = useCurrentTrack()\n\n\treturn {\n\t\tstate,\n\t\tisPlaying,\n\t\tposition: progress.position,\n\t\tduration: progress.duration,\n\t\tbuffered: progress.buffered,\n\t\tcurrentTrack: track,\n\t\tcurrentIndex: index,\n\t}\n}\n"
  },
  {
    "path": "packages/orpheus/src/hooks/usePlaybackState.ts",
    "content": "import { useEffect, useState } from 'react'\n\nimport { Orpheus, PlaybackState } from '../ExpoOrpheusModule'\n\nexport function usePlaybackState() {\n\tconst [state, setState] = useState<PlaybackState>(PlaybackState.IDLE)\n\n\tuseEffect(() => {\n\t\tlet isMounted = true\n\n\t\tconst sub = Orpheus.addListener('onPlaybackStateChanged', (event) => {\n\t\t\tif (isMounted) setState(event.state)\n\t\t})\n\n\t\treturn () => {\n\t\t\tisMounted = false\n\t\t\tsub.remove()\n\t\t}\n\t}, [])\n\n\treturn state\n}\n"
  },
  {
    "path": "packages/orpheus/src/hooks/useProgress.ts",
    "content": "import { useEffect, useState, useRef } from 'react'\nimport { AppState, type AppStateStatus } from 'react-native'\n\nimport { Orpheus } from '../ExpoOrpheusModule'\n\ntype OrpheusSubscription = ReturnType<typeof Orpheus.addListener>\n\nexport function useProgress() {\n\tconst [progress, setProgress] = useState({\n\t\tposition: 0,\n\t\tduration: 0,\n\t\tbuffered: 0,\n\t})\n\n\tconst listenerRef = useRef<null | OrpheusSubscription>(null)\n\n\tconst startListening = () => {\n\t\tif (listenerRef.current) return\n\n\t\tlistenerRef.current = Orpheus.addListener('onPositionUpdate', (event) => {\n\t\t\tsetProgress({\n\t\t\t\tposition: event.position,\n\t\t\t\tduration: event.duration,\n\t\t\t\tbuffered: event.buffered,\n\t\t\t})\n\t\t})\n\t}\n\n\tconst stopListening = () => {\n\t\tif (listenerRef.current) {\n\t\t\tlistenerRef.current.remove()\n\t\t\tlistenerRef.current = null\n\t\t}\n\t}\n\n\tconst manualSync = () => {\n\t\tPromise.all([\n\t\t\tOrpheus.getPosition(),\n\t\t\tOrpheus.getDuration(),\n\t\t\tOrpheus.getBuffered(),\n\t\t])\n\t\t\t.then(([pos, dur, buf]) => {\n\t\t\t\tsetProgress(() => ({\n\t\t\t\t\tposition: pos,\n\t\t\t\t\tduration: dur,\n\t\t\t\t\tbuffered: buf,\n\t\t\t\t}))\n\t\t\t})\n\t\t\t.catch((e) => console.warn('同步最新进度失败', e))\n\t}\n\n\tuseEffect(() => {\n\t\tmanualSync()\n\t\tstartListening()\n\n\t\tconst subscription = AppState.addEventListener(\n\t\t\t'change',\n\t\t\t(nextAppState: AppStateStatus) => {\n\t\t\t\tif (nextAppState === 'active') {\n\t\t\t\t\tmanualSync()\n\t\t\t\t\tstartListening()\n\t\t\t\t} else {\n\t\t\t\t\tstopListening()\n\t\t\t\t}\n\t\t\t},\n\t\t)\n\n\t\treturn () => {\n\t\t\tstopListening()\n\t\t\tsubscription.remove()\n\t\t}\n\t}, [])\n\n\treturn progress\n}\n"
  },
  {
    "path": "packages/orpheus/src/index.ts",
    "content": "export * from './ExpoOrpheusModule'\nexport * from './hooks'\nexport * from './headless'\n"
  },
  {
    "path": "packages/orpheus/tsconfig.json",
    "content": "// @generated by expo-module-scripts\n{\n\t\"extends\": \"expo-module-scripts/tsconfig.base\",\n\t\"compilerOptions\": {\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"outDir\": \"./build\"\n\t},\n\t\"include\": [\"./src\"],\n\t\"exclude\": [\"**/__mocks__/*\", \"**/__tests__/*\", \"**/__rsc_tests__/*\"]\n}\n"
  },
  {
    "path": "packages/splash/README.md",
    "content": "# @bbplayer/splash\n\nBBPlayer 歌词解析与转换核心工具库。\n\n## 简介\n\n格式基于 [SPL (Salt Player Lyric)](https://bbplayer.roitium.com/SPL)，它是 LRC 格式的高级扩展，旨在支持更丰富的歌词呈现效果。\n\n## 功能特性\n\n- **解析能力**：支持 LRC、SPL 等多种歌词格式的精准解析。\n- **转换引擎**：支持将网易云音乐等平台的 YRC/LRC 格式转换为支持逐字进度的 SPL 格式。\n- **类型安全**：提供统一、严谨的歌词数据结构定义。\n- **高性能**：针对移动端环境优化的解析算法。\n\n## 安装\n\n```bash\npnpm add @bbplayer/splash\n```\n\n## 快速上手\n\n```typescript\nimport { parseLRC } from '@bbplayer/splash'\n\nconst lyrics = parseLRC(lrcString)\n```\n"
  },
  {
    "path": "packages/splash/jest.config.js",
    "content": "/** @type {import('ts-jest').JestConfigWithTsJest} */\nmodule.exports = {\n\tpreset: 'ts-jest',\n\ttestEnvironment: 'node',\n\ttransform: {\n\t\t'^.+\\\\.tsx?$': ['ts-jest', { isolatedModules: true }],\n\t},\n}\n"
  },
  {
    "path": "packages/splash/package.json",
    "content": "{\n\t\"name\": \"@bbplayer/splash\",\n\t\"version\": \"1.0.0\",\n\t\"main\": \"src/index.ts\",\n\t\"types\": \"src/index.ts\",\n\t\"scripts\": {\n\t\t\"test\": \"jest\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/crypto-js\": \"^4.2.2\",\n\t\t\"@types/jest\": \"^29.5.14\",\n\t\t\"@types/node-forge\": \"^1.3.14\",\n\t\t\"crypto-js\": \"^4.2.0\",\n\t\t\"jest\": \"^30.2.0\",\n\t\t\"neverthrow\": \"^8.2.0\",\n\t\t\"node-forge\": \"1.3.2\",\n\t\t\"ts-jest\": \"^29.4.1\"\n\t}\n}\n"
  },
  {
    "path": "packages/splash/src/__tests__/fixtures/687506.json",
    "content": "{\n\t\"sgc\": false,\n\t\"sfy\": false,\n\t\"qfy\": false,\n\t\"transUser\": {\n\t\t\"id\": 979226,\n\t\t\"status\": 99,\n\t\t\"demand\": 1,\n\t\t\"userid\": 45379341,\n\t\t\"nickname\": \"穰静葉\",\n\t\t\"uptime\": 1496388268527\n\t},\n\t\"lrc\": {\n\t\t\"version\": 21,\n\t\t\"lyric\": \"{\\\"t\\\":0,\\\"c\\\":[{\\\"tx\\\":\\\"制作人: \\\"},{\\\"tx\\\":\\\"ZUN\\\"}]}\\n{\\\"t\\\":1000,\\\"c\\\":[{\\\"tx\\\":\\\"作词: \\\"},{\\\"tx\\\":\\\"Haruka\\\"}]}\\n{\\\"t\\\":2000,\\\"c\\\":[{\\\"tx\\\":\\\"作曲: \\\"},{\\\"tx\\\":\\\"ZUN\\\"}]}\\n{\\\"t\\\":3000,\\\"c\\\":[{\\\"tx\\\":\\\"编曲: \\\"},{\\\"tx\\\":\\\"Masayoshi Minoshima\\\"}]}\\n[00:57.020]流れてく 時の中ででも\\n[01:00.690]気だるさが ほらグルグル廻って\\n[01:04.130]私から 離れる心も\\n[01:07.550]見えないわ そう知らない\\n[01:10.740]\\n[01:11.100]自分から 動くこともなく\\n[01:14.550]時の隙間に 流され続けて\\n[01:17.980]知らないわ 周りの事など\\n[01:21.520]私は私 それだけ\\n[01:24.880]\\n[01:25.060]夢見てる 何も見てない\\n[01:28.090]語るも無駄な 自分の言葉\\n[01:31.520]悲しむなんて 疲れるだけよ\\n[01:34.990]何も感じず 過ごせばいいの\\n[01:38.460]\\n[01:38.860]戸惑う言葉 與えられても\\n[01:42.310]自分の心 ただ上の空\\n[01:45.800]もし私から 動くのならば\\n[01:49.310]すべて変えるのなら 黒にする\\n[01:52.510]\\n[01:53.190]こんな自分に 未來はあるの\\n[01:56.280]こんな世界に 私はいるの\\n[01:59.730]今切ないの 今悲しいの\\n[02:03.240]自分の事も わからないまま\\n[02:06.530]\\n[02:06.780]歩むことさえ 疲れるだけよ\\n[02:10.180]人のことなど 知りもしないわ\\n[02:13.600]こんな私も 変われるのなら\\n[02:17.130]もし変われるのなら 白になる\\n[02:22.810]\\n[02:41.070]\\n[02:48.940]流れてく 時の中ででも\\n[02:52.330]気だるさが ほらグルグル廻って\\n[02:55.840]私から 離れる心も\\n[02:59.260]見えないわそう 知らない\\n[03:02.590]\\n[03:02.970]自分から 動くこともなく\\n[03:06.460]時の隙間に 流され続けて\\n[03:09.890]知らないわ 周りのことなく\\n[03:13.360]私は私 それだけ\\n[03:16.740]\\n[03:16.930]夢見てる 何も見てない\\n[03:19.930]語るも無駄な 自分の言葉\\n[03:23.330]悲しむなんて 疲れるだけよ\\n[03:26.850]何も感じず 過ごせばいいの\\n[03:30.660]\\n[03:30.980]戸惑う言葉 與えられても\\n[03:34.120]自分の心 ただ上の空\\n[03:37.570]もし私から 動くのならば\\n[03:41.090]すべて変えるのなら 黒にする\\n[03:44.200]無駄な時間に 未來はあるの\\n[03:47.180]こんな所に 私はいるの\\n[03:50.610]私のことを 言えたいならば\\n[03:54.100]言葉にするの なら『ろくでなし』\\n[03:57.340]\\n[03:57.890]こんな所に 私はいるの\\n[04:01.070]こんな時間に 私はいるの\\n[04:04.530]こんな私も 変われるのなら\\n[04:08.020]もし変われるのなら 白になる\\n[04:11.360]\\n[04:11.640]今夢見てる なにも見てない\\n[04:14.950]語るも無駄な 自分の言葉\\n[04:18.480]悲しむなんて 疲れるだけよ\\n[04:21.930]何も感じず 過ごせばいいのど\\n[04:25.350]\\n[04:25.660]戸惑う言葉 與えられても\\n[04:28.900]自分の心 ただ上の空\\n[04:32.370]もし私から 動くのならば\\n[04:35.860]すべて変えるのなら 黒にする\\n[04:38.960]\\n[04:39.380]動くのならば 動くのならば\\n[04:42.810]すべて壊すわ すべて壊すわ\\n[04:46.280]悲しむならば 悲しむならば\\n[04:49.770]私の心 白く変われる\\n[04:53.130]\\n[04:53.320]貴方の事も 私のことも\\n[04:56.710]すべての事も まだ知らないの\\n[05:00.190]重い目蓋を 開けたのならば\\n[05:03.750]すべて壊すのなら 黒になれ\\n\"\n\t},\n\t\"klyric\": {\n\t\t\"version\": 0,\n\t\t\"lyric\": \"\"\n\t},\n\t\"tlyric\": {\n\t\t\"version\": 8,\n\t\t\"lyric\": \"[by:穣静葉]\\n[00:57.020]就算身处 流逝的时光里\\n[01:00.690]也只有倦怠 在原地打转不停\\n[01:04.130]从我身边 渐行渐远的心\\n[01:07.550]再也模糊不清 你明白吗\\n[01:11.100]我的身体 已经动弹不得\\n[01:14.550]在时间的狭缝里 随波逐流\\n[01:17.980]周围的一切 都与我无关\\n[01:21.520]我就是我 仅·此·而·已\\n[01:25.060]做着梦的我 什么都没看见\\n[01:28.090]出口也是枉然 自怜自艾的废话\\n[01:31.520]悲伤什么的 只会徒增疲倦啊\\n[01:34.990]干脆就这样 在麻木中度日吧\\n[01:38.860]就算被灌以 喧嚣的闲言碎语\\n[01:42.310]我的心也已经 不再起一丝涟漪\\n[01:45.800]如果我能够 驱使自己的话\\n[01:49.310]就让这一切 被黑暗所吞没吧\\n[01:53.190]这样的我 还有未来可言吗\\n[01:56.280]这种世界 允许我的存在吗\\n[01:59.730]此刻感到窒息吗？此刻觉得悲伤吗\\n[02:03.240]就连自己的事 也根本搞不懂啊\\n[02:06.780]就算走下去 也只是徒增疲倦\\n[02:10.180]对他人的一切 完全无法理解\\n[02:13.600]这样的我 如果还能改变\\n[02:17.130]还能改变的话 可以化为空白吗\\n[02:48.940]就算身处 流逝的时光里\\n[02:52.330]也只有倦怠 在原地打转不停\\n[02:55.840]从我身边 渐行渐远的心\\n[02:59.260]再也模糊不清 你明白吗\\n[03:02.970]我的身体 已经动弹不得\\n[03:06.460]在时间的狭缝里 随波逐流\\n[03:09.890]周围的一切 都与我无关\\n[03:13.360]我就是我 仅·此·而·已\\n[03:16.930]我在做梦吗？什么都没在看\\n[03:19.930]出口也是枉然 自怜自艾的废话\\n[03:23.330]悲伤什么的 只会徒增疲倦啊\\n[03:26.850]干脆就这样 在麻木中度日吧\\n[03:30.980]就算被灌以 喧嚣的闲言碎语\\n[03:34.120]我的心也已经 不再起一丝涟漪\\n[03:37.570]如果我能够 驱使自己的话\\n[03:41.090]就让这一切 被黑暗所吞没吧\\n[03:44.200]空虚的时光 会通往未来吗\\n[03:47.180]这种地方 允许我的存在吗\\n[03:50.610]如果要描述我 将我的一切\\n[03:54.100]付诸言语的话 那就是「废物」\\n[03:57.890]这种地方 允许我的存在吗\\n[04:01.070]这种时光 允许我的存在吗\\n[04:04.530]如果这样的我 还能改变的话\\n[04:08.020]还能改变的话 可以化为空白吗\\n[04:11.640]我还在做梦吗？什么都没在看\\n[04:14.950]出口也是枉然 自怜自艾的废话\\n[04:18.480]悲伤什么的 只会徒增疲倦啊\\n[04:21.930]什么都不管 在麻木中度日吧\\n[04:25.660]就算被灌以 喧嚣的闲言碎语\\n[04:28.900]我的心也已经 不再起一丝涟漪\\n[04:32.370]如果我能够 驱使自己的话\\n[04:35.860]就让这一切 被黑暗所吞没吧\\n[04:39.380]如果任我驱使 驱使自己的话\\n[04:42.810]一切都会毁灭 一切都会毁灭啊\\n[04:46.280]被悲伤笼罩 被悲伤笼罩的话\\n[04:49.770]我的心还能够 化为空白吗\\n[04:53.320]不论你的存在 还是我的存在\\n[04:56.710]这一切的真实 我都一无所知\\n[05:00.190]如果在此睁开 这沉重的双眼\\n[05:03.750]一切都会毁灭 被黑暗所吞没\"\n\t},\n\t\"romalrc\": {\n\t\t\"version\": 4,\n\t\t\"lyric\": \"[00:57.020]na ga re te ku to ki no na ka de de mo\\n[01:00.690]ke da ru sa ga ho ra gu ru gu ru ma wa tte\\n[01:04.130]wa ta shi ka ra ha na re ru ko ko ro mo\\n[01:07.550]mi e na i wa so u shi ra na i\\n[01:10.740]\\n[01:11.100]ji bu n ka ra u go ku ko to mo na ku\\n[01:14.550]to ki no su ki ma ni na ga sa re tsu zu ke te\\n[01:17.980]shi ra na i wa ma wa ri no ko to na do\\n[01:21.520]wa ta shi wa wa ta shi so re da ke\\n[01:24.880]\\n[01:25.060]yu me mi te ru na ni mo mi te na i\\n[01:28.090]ka ta ru mo mu da na ji bu n no ko to ba\\n[01:31.520]ka na shi mu na n te tsu ka re ru da ke yo\\n[01:34.990]na ni mo ka n ji zu su go se ba i i no\\n[01:38.460]\\n[01:38.860]to ma do u ko to ba a ta e ra re te mo\\n[01:42.310]ji bu n no ko ko ro ta da u wa no so ra\\n[01:45.800]mo shi wa ta shi ka ra u go ku no na ra ba\\n[01:49.310]su be te ka e ru no na ra ku ro ni su ru\\n[01:52.510]\\n[01:53.190]ko n na ji bu n ni mi ra i wa a ru no\\n[01:56.280]ko n na se ka i ni wa ta shi wa i ru no\\n[01:59.730]i ma gi re na i no i ma ka na shi i no\\n[02:03.240]ji bu n no ko to mo wa ka ra na i ma ma\\n[02:06.530]\\n[02:06.780]a yu mu ko to sa e tsu ka re ru da ke yo\\n[02:10.180]hi to no ko to na do shi ri mo shi na i wa\\n[02:13.600]ko n na wa ta shi mo ka wa re ru no na ra\\n[02:17.130]mo shi ka wa re ru no na ra shi ro ni na ru\\n[02:22.810]\\n[02:41.070]\\n[02:48.940]na ga re te ku to ki no na ka de de mo\\n[02:52.330]ke da ru sa ga ho ra gu ru gu ru ma wa tte\\n[02:55.840]wa ta shi ka ra ha na re ru ko ko ro mo\\n[02:59.260]mi e na i wa so u shi ra na i\\n[03:02.590]\\n[03:02.970]ji bu n ka ra u go ku ko to mo na ku\\n[03:06.460]to ki no su ki ma ni na ga sa re tsu zu ke te\\n[03:09.890]shi ra na i wa ma wa ri no ko to na ku\\n[03:13.360]wa ta shi wa wa ta shi so re da ke\\n[03:16.740]\\n[03:16.930]yu me mi te ru na ni mo mi te na i\\n[03:19.930]ka ta ru mo mu da na ji bu n no ko to ba\\n[03:23.330]ka na shi mu na n te tsu ka re ru da ke yo\\n[03:26.850]na ni mo ka n ji zu su go se ba i i no\\n[03:30.660]\\n[03:30.980]to ma do u ko to ba a ta e ra re te mo\\n[03:34.120]ji bu n no ko ko ro ta da u wa no so ra\\n[03:37.570]mo shi wa ta shi ka ra u go ku no na ra ba\\n[03:41.090]su be te ka e ru no na ra ku ro ni su ru\\n[03:44.200]mu da na ji ka n ni mi ra i wa a ru no\\n[03:47.180]ko n na to ko ro ni wa ta shi wa i ru no\\n[03:50.610]wa ta shi no ko to wo i e ta i na ra ba\\n[03:54.100]ko to ba ni su ru no na ra『ro ku de na shi』\\n[03:57.340]\\n[03:57.890]ko n na to ko ro ni wa ta shi wa i ru no\\n[04:01.070]ko n na ji ka n ni wa ta shi wa i ru no\\n[04:04.530]ko n na wa ta shi mo ka wa re ru no na ra\\n[04:08.020]mo shi ka wa re ru no na ra shi ro ni na ru\\n[04:11.360]\\n[04:11.640]i ma yu me mi te ru na ni mo mi te na i\\n[04:14.950]ka ta ru mo mu da na ji bu n no ko to ba\\n[04:18.480]ka na shi mu na n te tsu ka re ru da ke yo\\n[04:21.930]na ni mo ka n ji zu su go se ba i i no do\\n[04:25.350]\\n[04:25.660]to ma do u ko to ba a ta e ra re te mo\\n[04:28.900]ji bu n no ko ko ro ta da u wa no so ra\\n[04:32.370]mo shi wa ta shi ka ra u go ku no na ra ba\\n[04:35.860]su be te ka e ru no na ra ku ro ni su ru\\n[04:38.960]\\n[04:39.380]u go ku no na ra ba u go ku no na ra ba\\n[04:42.810]su be te ko wa su wa su be te ko wa su wa\\n[04:46.280]ka na shi mu na ra ba ka na shi mu na ra ba\\n[04:49.770]wa ta shi no ko ko ro shi ro ku ka wa re ru\\n[04:53.130]\\n[04:53.320]a na ta no ko to mo wa ta shi no ko to mo\\n[04:56.710]su be te no ko to mo ma da shi ra na i no\\n[05:00.190]o mo i ma bu ta wo a ke ta no na ra ba\\n[05:03.750]su be te ko wa su no na ra ku ro ni na re\"\n\t},\n\t\"yrc\": {\n\t\t\"version\": 14,\n\t\t\"lyric\": \"{\\\"t\\\":0,\\\"c\\\":[{\\\"tx\\\":\\\"制作人: \\\"},{\\\"tx\\\":\\\"ZUN\\\"}]}\\n{\\\"t\\\":1000,\\\"c\\\":[{\\\"tx\\\":\\\"作词: \\\"},{\\\"tx\\\":\\\"Haruka\\\"}]}\\n{\\\"t\\\":2000,\\\"c\\\":[{\\\"tx\\\":\\\"作曲: \\\"},{\\\"tx\\\":\\\"ZUN\\\"}]}\\n{\\\"t\\\":3000,\\\"c\\\":[{\\\"tx\\\":\\\"编曲: \\\"},{\\\"tx\\\":\\\"Masayoshi Minoshima\\\"}]}\\n[57500,3500](57500,520,0)流(58020,180,0)れ(58200,240,0)て(58440,440,0)く (58880,450,0)時(59330,350,0)の(59680,720,0)中(60400,160,0)で(60560,240,0)で(60800,200,0)も\\n[61000,3470](61000,270,0)気(61270,230,0)だ(61500,160,0)る(61660,270,0)さ(61930,360,0)が (62290,270,0)ほ(62560,170,0)ら(62730,280,0)グ(63010,160,0)ル(63170,270,0)グ(63440,160,0)ル(63600,690,0)廻(64290,30,0)っ(64320,150,0)て\\n[64470,3510](64470,690,0)私(65160,240,0)か(65400,330,0)ら (65730,530,0)離(66260,450,0)れ(66710,250,0)る(66960,830,0)心(67790,190,0)も\\n[67980,3350](67980,260,0)見(68240,200,0)え(68440,230,0)な(68670,200,0)い(68870,400,0)わ (69270,250,0)そ(69520,170,0)う(69690,460,0)知(70150,400,0)ら(70550,470,0)な(71020,310,0)い\\n[71390,3560](71390,310,0)自(71700,450,0)分(72150,210,0)か(72360,380,0)ら (72740,460,0)動(73200,460,0)く(73660,390,0)こ(74050,250,0)と(74300,210,0)も(74510,230,0)な(74740,210,0)く\\n[74950,3410](74950,470,0)時(75420,150,0)の(75570,470,0)隙(76040,450,0)間(76490,210,0)に (76700,360,0)流(77060,290,0)さ(77350,190,0)れ(77540,430,0)続(77970,220,0)け(78190,170,0)て\\n[78360,3530](78360,300,0)知(78660,210,0)ら(78870,220,0)な(79090,210,0)い(79300,350,0)わ (79650,500,0)周(80150,350,0)り(80500,480,0)の(80980,480,0)事(81460,240,0)な(81700,190,0)ど\\n[81890,3340](81890,640,0)私(82530,200,0)は(82730,900,0)私 (83630,160,0)そ(83790,690,0)れ(84480,480,0)だ(84960,270,0)け\\n[85420,2900](85420,430,0)夢(85850,70,0)見(85920,220,0)て(86140,500,0)る (86640,480,0)何(87120,250,0)も(87370,210,0)見(87580,210,0)て(87790,210,0)な(88000,320,0)い\\n[88360,3380](88360,520,0)語(88880,220,0)る(89100,230,0)も(89330,210,0)無(89540,220,0)駄(89760,310,0)な (90070,330,0)自(90400,410,0)分(90810,210,0)の(91020,450,0)言(91470,270,0)葉\\n[91920,3340](91920,370,0)悲(92290,220,0)し(92510,270,0)む(92780,260,0)な(93040,230,0)ん(93270,370,0)て (93640,450,0)疲(94090,260,0)れ(94350,180,0)る(94530,210,0)だ(94740,180,0)け(94920,340,0)よ\\n[95410,3290](95410,410,0)何(95820,240,0)も(96060,420,0)感(96480,200,0)じ(96680,390,0)ず (97070,180,0)過(97250,270,0)ご(97520,260,0)せ(97780,200,0)ば(97980,270,0)い(98250,190,0)い(98440,260,0)の\\n[98880,3310](98880,180,0)戸(99060,660,0)惑(99720,30,0)う(99750,440,0)言(100190,350,0)葉 (100540,700,0)與(101240,30,0)え(101270,250,0)ら(101520,160,0)れ(101680,220,0)て(101900,290,0)も\\n[102240,3290](102240,340,0)自(102580,420,0)分(103000,180,0)の(103180,920,0)心 (104100,210,0)た(104310,200,0)だ(104510,450,0)上(104960,170,0)の(105130,400,0)空\\n[105800,3340](105800,200,0)も(106000,270,0)し(106270,640,0)私(106910,240,0)か(107150,390,0)ら (107540,450,0)動(107990,240,0)く(108230,200,0)の(108430,240,0)な(108670,200,0)ら(108870,270,0)ば\\n[109140,3610](109140,170,0)す(109310,590,0)べ(109900,60,0)て(109960,210,0)変(110170,230,0)え(110400,210,0)る(110610,410,0)の(111020,250,0)な(111270,180,0)ら (111450,450,0)黒(111900,210,0)に(112110,130,0)す(112240,510,0)る\\n[112800,3330](112800,220,0)こ(113020,200,0)ん(113220,250,0)な(113470,200,0)自(113670,260,0)分(113930,620,0)に (114550,250,0)未(114800,370,0)來(115170,400,0)は(115570,70,0)あ(115640,220,0)る(115860,270,0)の\\n[116260,3310](116260,230,0)こ(116490,220,0)ん(116710,170,0)な(116880,220,0)世(117100,440,0)界(117540,450,0)に (117990,670,0)私(118660,220,0)は(118880,230,0)い(119110,200,0)る(119310,260,0)の\\n[119700,3350](119700,440,0)今(120140,490,0)切(120630,120,0)な(120750,370,0)い(121120,380,0)の (121500,390,0)今(121890,420,0)悲(122310,290,0)し(122600,190,0)い(122790,260,0)の\\n[123180,3330](123180,280,0)自(123460,430,0)分(123890,180,0)の(124070,450,0)事(124520,440,0)も (124960,210,0)わ(125170,220,0)か(125390,220,0)ら(125610,200,0)な(125810,230,0)い(126040,220,0)ま(126260,250,0)ま\\n[126710,3300](126710,460,0)歩(127170,330,0)む(127500,60,0)こ(127560,200,0)と(127760,200,0)さ(127960,350,0)え (128310,520,0)疲(128830,470,0)れ(129300,90,0)る(129390,130,0)だ(129520,170,0)け(129690,320,0)よ\\n[130010,3340](130010,610,0)人(130620,220,0)の(130840,200,0)こ(131040,210,0)と(131250,260,0)な(131510,280,0)ど (131790,300,0)知(132090,230,0)り(132320,200,0)も(132520,280,0)し(132800,230,0)な(133030,240,0)い(133270,80,0)わ\\n[133620,3360](133620,270,0)こ(133890,200,0)ん(134090,200,0)な(134290,660,0)私(134950,460,0)も (135410,180,0)変(135590,250,0)わ(135840,360,0)れ(136200,90,0)る(136290,190,0)の(136480,220,0)な(136700,280,0)ら\\n[137170,3470](137170,130,0)も(137300,280,0)し(137580,180,0)変(137760,250,0)わ(138010,230,0)れ(138240,210,0)る(138450,400,0)の(138850,230,0)な(139080,160,0)ら (139240,470,0)白(139710,240,0)に(139950,210,0)な(140160,480,0)る\\n[168860,3440](168860,470,0)流(169330,190,0)れ(169520,220,0)て(169740,450,0)く (170190,440,0)時(170630,340,0)の(170970,730,0)中(171700,170,0)で(171870,240,0)で(172110,190,0)も\\n[172300,3470](172300,270,0)気(172570,240,0)だ(172810,180,0)る(172990,240,0)さ(173230,370,0)が (173600,260,0)ほ(173860,180,0)ら(174040,280,0)グ(174320,160,0)ル(174480,270,0)グ(174750,160,0)ル(174910,660,0)廻(175570,30,0)っ(175600,170,0)て\\n[175770,3520](175770,690,0)私(176460,240,0)か(176700,410,0)ら (177110,460,0)離(177570,280,0)れ(177850,570,0)る(178420,680,0)心(179100,190,0)も\\n[179290,3320](179290,250,0)見(179540,190,0)え(179730,240,0)な(179970,170,0)い(180140,330,0)わ(180470,200,0)そ(180670,290,0)う (180960,490,0)知(181450,230,0)ら(181680,140,0)な(181820,790,0)い\\n[182640,3600](182640,370,0)自(183010,450,0)分(183460,80,0)か(183540,500,0)ら (184040,460,0)動(184500,640,0)く(185140,220,0)こ(185360,240,0)と(185600,210,0)も(185810,230,0)な(186040,200,0)く\\n[186240,3430](186240,480,0)時(186720,160,0)の(186880,470,0)隙(187350,450,0)間(187800,210,0)に (188010,360,0)流(188370,290,0)さ(188660,180,0)れ(188840,440,0)続(189280,210,0)け(189490,180,0)て\\n[189670,3400](189670,290,0)知(189960,210,0)ら(190170,230,0)な(190400,190,0)い(190590,360,0)わ (190950,510,0)周(191460,390,0)り(191850,380,0)の(192230,320,0)こ(192550,220,0)と(192770,240,0)な(193010,60,0)く\\n[193070,3460](193070,770,0)私(193840,190,0)は(194030,930,0)私 (194960,130,0)そ(195090,710,0)れ(195800,460,0)だ(196260,270,0)け\\n[196690,2910](196690,550,0)夢(197240,110,0)見(197350,90,0)て(197440,500,0)る (197940,490,0)何(198430,230,0)も(198660,220,0)見(198880,210,0)て(199090,230,0)な(199320,280,0)い\\n[199670,3370](199670,500,0)語(200170,230,0)る(200400,190,0)も(200590,270,0)無(200860,200,0)駄(201060,270,0)な (201330,370,0)自(201700,410,0)分(202110,200,0)の(202310,460,0)言(202770,270,0)葉\\n[203210,3350](203210,390,0)悲(203600,210,0)し(203810,270,0)む(204080,260,0)な(204340,370,0)ん(204710,240,0)て (204950,440,0)疲(205390,250,0)れ(205640,200,0)る(205840,210,0)だ(206050,190,0)け(206240,320,0)よ\\n[206710,3260](206710,420,0)何(207130,230,0)も(207360,430,0)感(207790,200,0)じ(207990,320,0)ず (208310,240,0)過(208550,270,0)ご(208820,270,0)せ(209090,180,0)ば(209270,210,0)い(209480,260,0)い(209740,230,0)の\\n[210160,3360](210160,220,0)戸(210380,580,0)惑(210960,30,0)う(210990,510,0)言(211500,330,0)葉 (211830,970,0)與(212800,30,0)え(212830,90,0)ら(212920,60,0)れ(212980,230,0)て(213210,310,0)も\\n[213550,3250](213550,340,0)自(213890,420,0)分(214310,180,0)の(214490,910,0)心 (215400,210,0)た(215610,240,0)だ(215850,420,0)上(216270,150,0)の(216420,380,0)空\\n[216800,3770](216800,500,0)も(217300,250,0)し(217550,670,0)私(218220,230,0)か(218450,400,0)ら (218850,450,0)動(219300,230,0)く(219530,210,0)の(219740,230,0)な(219970,540,0)ら(220510,60,0)ば\\n[220600,3330](220600,230,0)す(220830,210,0)べ(221040,240,0)て(221280,250,0)変(221530,180,0)え(221710,210,0)る(221920,410,0)の(222330,250,0)な(222580,180,0)ら (222760,440,0)黒(223200,190,0)に(223390,270,0)す(223660,270,0)る\\n[224080,3370](224080,240,0)無(224320,210,0)駄(224530,220,0)な(224750,200,0)時(224950,400,0)間(225350,490,0)に (225840,220,0)未(226060,410,0)來(226470,400,0)は(226870,50,0)あ(226920,230,0)る(227150,300,0)の\\n[227580,3330](227580,240,0)こ(227820,180,0)ん(228000,210,0)な(228210,660,0)所(228870,520,0)に (229390,530,0)私(229920,240,0)は(230160,220,0)い(230380,360,0)る(230740,170,0)の\\n[230990,3400](230990,710,0)私(231700,200,0)の(231900,240,0)こ(232140,210,0)と(232350,300,0)を (232650,410,0)言(233060,150,0)え(233210,210,0)た(233420,190,0)い(233610,260,0)な(233870,230,0)ら(234100,290,0)ば\\n[234520,3460](234520,450,0)言(234970,210,0)葉(235180,190,0)に(235370,130,0)す(235500,350,0)る(235850,390,0)の (236240,270,0)な(236510,190,0)ら(236700,0,0)『(236700,220,0)ろ(236920,210,0)く(237130,220,0)で(237350,220,0)な(237570,230,0)し(237800,180,0)』\\n[237980,3360](237980,260,0)こ(238240,220,0)ん(238460,200,0)な(238660,650,0)所(239310,440,0)に (239750,630,0)私(240380,260,0)は(240640,180,0)い(240820,230,0)る(241050,290,0)の\\n[241440,3380](241440,260,0)こ(241700,190,0)ん(241890,230,0)な(242120,250,0)時(242370,330,0)間(242700,480,0)に (243180,680,0)私(243860,250,0)は(244110,230,0)い(244340,190,0)る(244530,290,0)の\\n[244920,3440](244920,300,0)こ(245220,190,0)ん(245410,360,0)な(245770,490,0)私(246260,380,0)も (246640,260,0)変(246900,200,0)わ(247100,280,0)れ(247380,180,0)る(247560,220,0)の(247780,230,0)な(248010,350,0)ら\\n[248450,3320](248450,160,0)も(248610,260,0)し(248870,200,0)変(249070,230,0)わ(249300,230,0)れ(249530,200,0)る(249730,430,0)の(250160,250,0)な(250410,160,0)ら (250570,650,0)白(251220,60,0)に(251280,200,0)な(251480,290,0)る\\n[251900,3310](251900,460,0)今(252360,440,0)夢(252800,200,0)見(253000,220,0)て(253220,370,0)る (253590,280,0)な(253870,200,0)に(254070,240,0)も(254310,220,0)見(254530,220,0)て(254750,220,0)な(254970,240,0)い\\n[255400,3310](255400,430,0)語(255830,210,0)る(256040,210,0)も(256250,230,0)無(256480,210,0)駄(256690,350,0)な (257040,320,0)自(257360,420,0)分(257780,200,0)の(257980,470,0)言(258450,260,0)葉\\n[258820,3390](258820,440,0)悲(259260,220,0)し(259480,270,0)む(259750,260,0)な(260010,210,0)ん(260220,320,0)て (260540,500,0)疲(261040,240,0)れ(261280,210,0)る(261490,210,0)だ(261700,190,0)け(261890,320,0)よ\\n[262290,3660](262290,490,0)何(262780,230,0)も(263010,440,0)感(263450,190,0)じ(263640,370,0)ず (264010,260,0)過(264270,230,0)ご(264500,250,0)せ(264750,190,0)ば(264940,210,0)い(265150,400,0)い(265550,220,0)の(265770,180,0)ど\\n[265980,3200](265980,60,0)戸(266040,780,0)惑(266820,30,0)う(266850,300,0)言(267150,390,0)葉 (267540,550,0)與(268090,150,0)え(268240,220,0)ら(268460,200,0)れ(268660,210,0)て(268870,310,0)も\\n[269240,3370](269240,290,0)自(269530,390,0)分(269920,210,0)の(270130,930,0)心 (271060,210,0)た(271270,200,0)だ(271470,460,0)上(271930,190,0)の(272120,490,0)空\\n[272730,3400](272730,230,0)も(272960,250,0)し(273210,660,0)私(273870,100,0)か(273970,500,0)ら (274470,470,0)動(274940,100,0)く(275040,60,0)の(275100,60,0)な(275160,650,0)ら(275810,320,0)ば\\n[276130,3810](276130,130,0)す(276260,590,0)べ(276850,60,0)て(276910,370,0)変(277280,90,0)え(277370,280,0)る(277650,350,0)の(278000,240,0)な(278240,180,0)ら (278420,650,0)黒(279070,520,0)に(279590,70,0)す(279660,280,0)る\\n[279940,3140](279940,230,0)動(280170,230,0)く(280400,220,0)の(280620,230,0)な(280850,210,0)ら(281060,380,0)ば (281440,470,0)動(281910,160,0)く(282070,280,0)の(282350,230,0)な(282580,210,0)ら(282790,290,0)ば\\n[283190,3360](283190,250,0)す(283440,210,0)べ(283650,200,0)て(283850,430,0)壊(284280,240,0)す(284520,410,0)わ (284930,250,0)す(285180,220,0)べ(285400,200,0)て(285600,440,0)壊(286040,250,0)す(286290,260,0)わ\\n[286680,3390](286680,430,0)悲(287110,240,0)し(287350,250,0)む(287600,180,0)な(287780,230,0)ら(288010,410,0)ば (288420,410,0)悲(288830,230,0)し(289060,270,0)む(289330,210,0)な(289540,220,0)ら(289760,310,0)ば\\n[290170,3470](290170,670,0)私(290840,180,0)の(291020,770,0)心 (291790,560,0)白(292350,180,0)く(292530,230,0)変(292760,250,0)わ(293010,200,0)れ(293210,430,0)る\\n[293680,3290](293680,190,0)貴(293870,470,0)方(294340,200,0)の(294540,430,0)事(294970,410,0)も (295380,670,0)私(296050,210,0)の(296260,220,0)こ(296480,220,0)と(296700,270,0)も\\n[297080,3390](297080,280,0)す(297360,220,0)べ(297580,210,0)て(297790,200,0)の(297990,420,0)事(298410,420,0)も (298830,260,0)ま(299090,170,0)だ(299260,290,0)知(299550,180,0)ら(299730,250,0)な(299980,200,0)い(300180,290,0)の\\n[300560,3390](300560,490,0)重(301050,220,0)い(301270,230,0)目(301500,410,0)蓋(301910,450,0)を (302360,170,0)開(302530,250,0)け(302780,230,0)た(303010,220,0)の(303230,220,0)な(303450,210,0)ら(303660,290,0)ば\\n[304070,3330](304070,250,0)す(304320,210,0)べ(304530,200,0)て(304730,420,0)壊(305150,240,0)す(305390,440,0)の(305830,240,0)な(306070,200,0)ら (306270,420,0)黒(306690,220,0)に(306910,210,0)な(307120,280,0)れ\\n\"\n\t},\n\t\"ytlrc\": {\n\t\t\"version\": 3,\n\t\t\"lyric\": \"[00:57.500]就算身处 流逝的时光里\\n[01:01.000]也只有倦怠 在原地打转不停\\n[01:04.470]从我身边 渐行渐远的心\\n[01:07.980]再也模糊不清 你明白吗\\n[01:11.390]我的身体 已经动弹不得\\n[01:14.950]在时间的狭缝里 随波逐流\\n[01:18.360]周围的一切 都与我无关\\n[01:21.890]我就是我 仅·此·而·已\\n[01:25.420]做着梦的我 什么都没看见\\n[01:28.360]出口也是枉然 自怜自艾的废话\\n[01:31.920]悲伤什么的 只会徒增疲倦啊\\n[01:35.410]干脆就这样 在麻木中度日吧\\n[01:38.880]就算被灌以 喧嚣的闲言碎语\\n[01:42.240]我的心也已经 不再起一丝涟漪\\n[01:45.800]如果我能够 驱使自己的话\\n[01:49.140]就让这一切 被黑暗所吞没吧\\n[01:52.800]这样的我 还有未来可言吗\\n[01:56.260]这种世界 允许我的存在吗\\n[01:59.700]此刻感到窒息吗？此刻觉得悲伤吗\\n[02:03.180]就连自己的事 也根本搞不懂啊\\n[02:06.710]就算走下去 也只是徒增疲倦\\n[02:10.010]对他人的一切 完全无法理解\\n[02:13.620]这样的我 如果还能改变\\n[02:17.170]还能改变的话 可以化为空白吗\\n[02:48.860]就算身处 流逝的时光里\\n[02:52.300]也只有倦怠 在原地打转不停\\n[02:55.770]从我身边 渐行渐远的心\\n[02:59.290]再也模糊不清 你明白吗\\n[03:02.640]我的身体 已经动弹不得\\n[03:06.240]在时间的狭缝里 随波逐流\\n[03:09.670]周围的一切 都与我无关\\n[03:13.070]我就是我 仅·此·而·已\\n[03:16.690]我在做梦吗？什么都没在看\\n[03:19.670]出口也是枉然 自怜自艾的废话\\n[03:23.210]悲伤什么的 只会徒增疲倦啊\\n[03:26.710]干脆就这样 在麻木中度日吧\\n[03:30.160]就算被灌以 喧嚣的闲言碎语\\n[03:33.550]我的心也已经 不再起一丝涟漪\\n[03:36.800]如果我能够 驱使自己的话\\n[03:40.600]就让这一切 被黑暗所吞没吧\\n[03:44.080]空虚的时光 会通往未来吗\\n[03:47.580]这种地方 允许我的存在吗\\n[03:50.990]如果要描述我 将我的一切\\n[03:54.520]付诸言语的话 那就是「废物」\\n[03:57.980]这种地方 允许我的存在吗\\n[04:01.440]这种时光 允许我的存在吗\\n[04:04.920]如果这样的我 还能改变的话\\n[04:08.450]还能改变的话 可以化为空白吗\\n[04:11.900]我还在做梦吗？什么都没在看\\n[04:15.400]出口也是枉然 自怜自艾的废话\\n[04:18.820]悲伤什么的 只会徒增疲倦啊\\n[04:22.290]什么都不管 在麻木中度日吧\\n[04:25.980]就算被灌以 喧嚣的闲言碎语\\n[04:29.240]我的心也已经 不再起一丝涟漪\\n[04:32.730]如果我能够 驱使自己的话\\n[04:36.130]就让这一切 被黑暗所吞没吧\\n[04:39.940]如果任我驱使 驱使自己的话\\n[04:43.190]一切都会毁灭 一切都会毁灭啊\\n[04:46.680]被悲伤笼罩 被悲伤笼罩的话\\n[04:50.170]我的心还能够 化为空白吗\\n[04:53.680]不论你的存在 还是我的存在\\n[04:57.080]这一切的真实 我都一无所知\\n[05:00.560]如果在此睁开 这沉重的双眼\\n[05:04.070]一切都会毁灭 被黑暗所吞没\"\n\t},\n\t\"yromalrc\": {\n\t\t\"version\": 4,\n\t\t\"lyric\": \"[00:57.500]na ga re te ku ji no na ka de de mo \\n[01:01.000]ke da ru sa ga ho ra gu ru gu ru ma wa tte \\n[01:04.470]wa ta shi ka ra ha na re ru ko ko ro mo \\n[01:07.980]mi e na i wa so u shi ra na i \\n[01:11.390]ji bu n ka ra u go ku ko to mo na ku \\n[01:14.950]to ki no su ki ma ni na ga sa re tsu zu ke te \\n[01:18.360]shi ra na i wa ma wa ri no ko to na do \\n[01:21.890]wa ta shi wa wa ta shi so re da ke \\n[01:25.420]yu me mi te ru na ni mo mi te na i \\n[01:28.360]ka ta ru mo mu da na ji bu n no ko to ba \\n[01:31.920]ka na shi mu na n te tsu ka re ru da ke yo \\n[01:35.410]na ni mo ka n ji zu su go se ba i i no \\n[01:38.880]to ma do u ko to ba a ta e ra re te mo \\n[01:42.240]ji bu n no ko ko ro ta da u wa no so ra \\n[01:45.800]mo shi wa ta shi ka ra u go ku no na ra ba \\n[01:49.140]su be te ka e ru no na ra ku ro ni su ru \\n[01:52.800]ko n na ji bu n ni mi ra i wa a ru no \\n[01:56.260]ko n na se ka i ni wa ta shi wa i ru no \\n[01:59.700]i ma gi re na i no i ma ka na shi i no \\n[02:03.180]ji bu n no ko to mo wa ka ra na i ma ma \\n[02:06.710]a yu mu ko to sa e tsu ka re ru da ke yo \\n[02:10.010]hi to no ko to na do shi ri mo shi na i wa \\n[02:13.620]ko n na wa ta shi mo ka wa re ru no na ra \\n[02:17.170]mo shi ka wa re ru no na ra shi ro ni na ru \\n[02:48.860]na ga re te ku ji no na ka de de mo \\n[02:52.300]ke da ru sa ga ho ra gu ru gu ru ma wa tte \\n[02:55.770]wa ta shi ka ra ha na re ru ko ko ro mo \\n[02:59.290]mi e na i wa so u shi ra na i \\n[03:02.640]ji bu n ka ra u go ku ko to mo na ku \\n[03:06.240]to ki no su ki ma ni na ga sa re tsu zu ke te \\n[03:09.670]shi ra na i wa ma wa ri no ko to na ku \\n[03:13.070]wa ta shi wa wa ta shi so re da ke \\n[03:16.690]yu me mi te ru na ni mo mi te na i \\n[03:19.670]ka ta ru mo mu da na ji bu n no ko to ba \\n[03:23.210]ka na shi mu na n te tsu ka re ru da ke yo \\n[03:26.710]na ni mo ka n ji zu su go se ba i i no \\n[03:30.160]to ma do u ko to ba a ta e ra re te mo \\n[03:33.550]ji bu n no ko ko ro ta da u wa no so ra \\n[03:36.800]mo shi wa ta shi ka ra u go ku no na ra ba \\n[03:40.600]su be te ka e ru no na ra ku ro ni su ru \\n[03:44.080]mu da na ji ka n ni mi ra i wa a ru no \\n[03:47.580]ko n na to ko ro ni wa ta shi wa i ru no \\n[03:50.990]wa ta shi no ko to wo i e ta i na ra ba \\n[03:54.520]ko to ba ni su ru no na ra 『 ro ku de na shi 』 \\n[03:57.980]ko n na to ko ro ni wa ta shi wa i ru no \\n[04:01.440]ko n na ji ka n ni wa ta shi wa i ru no \\n[04:04.920]ko n na wa ta shi mo ka wa re ru no na ra \\n[04:08.450]mo shi ka wa re ru no na ra shi ro ni na ru \\n[04:11.900]i ma yu me mi te ru na ni mo mi te na i \\n[04:15.400]ka ta ru mo mu da na ji bu n no ko to ba \\n[04:18.820]ka na shi mu na n te tsu ka re ru da ke yo \\n[04:22.290]na ni mo ka n ji zu su go se ba i i no do \\n[04:25.980]to ma do u ko to ba a ta e ra re te mo \\n[04:29.240]ji bu n no ko ko ro ta da u wa no so ra \\n[04:32.730]mo shi wa ta shi ka ra u go ku no na ra ba \\n[04:36.130]su be te ka e ru no na ra ku ro ni su ru \\n[04:39.940]u go ku no na ra ba u go ku no na ra ba \\n[04:43.190]su be te ko wa su wa su be te ko wa su wa \\n[04:46.680]ka na shi mu na ra ba ka na shi mu na ra ba \\n[04:50.170]wa ta shi no ko ko ro shi ro ku ka wa re ru \\n[04:53.680]a na ta no ko to mo wa ta shi no ko to mo \\n[04:57.080]su be te no ko to mo ma da shi ra na i no \\n[05:00.560]o mo i ma bu ta wo a ke ta no na ra ba \\n[05:04.070]su be te ko wa su no na ra ku ro ni na re \"\n\t},\n\t\"code\": 200\n}\n"
  },
  {
    "path": "packages/splash/src/__tests__/fixtures/bilibili--BV1Zu411x7mc.json",
    "content": "{\n\t\"lrc\": \"[00:00.000]作词: ヒグチアイ\\n[00:01.000]作曲: 南田健吾\\n[00:21.710]8[00:22.280]月[00:22.760]の[00:23.450] [00:23.640]青[00:24.080]空[00:25.520]\\n[00:25.520]か[00:25.720]き[00:25.960]混[00:26.170]ぜ[00:26.460]る[00:27.180] [00:27.270]み[00:27.570]た[00:27.820]い[00:28.290]に[00:29.140]\\n[00:29.140]飛[00:29.440]ぶ[00:29.640]鳥[00:30.140]の[00:30.800] [00:30.980]鳴[00:31.240]き[00:31.480]声[00:32.180]聞[00:32.400]こ[00:32.960]え[00:33.120]て[00:33.320]た[00:36.490]\\n[00:36.490]汗[00:37.040]ば[00:37.320]ん[00:37.530]だ [00:38.370]T [00:38.790]シ[00:38.960]ャ[00:39.320]ツ[00:40.200]\\n[00:40.200]真[00:40.590]ん[00:40.740]中[00:41.770]を[00:41.910] [00:42.010]つ[00:42.320]ま[00:42.550]ん[00:43.060]で[00:43.910]\\n[00:43.910]風[00:44.460]起[00:44.620]こ[00:44.860]す [00:45.570] [00:45.770]電[00:46.200]車[00:46.730]に[00:46.970]揺[00:47.400]ら[00:47.870]れ[00:48.090]て[00:50.580]\\n[00:50.580]フ[00:51.130]ラ[00:51.350]ミ[00:51.630]ン[00:51.830]ゴ[00:52.070]色[00:52.740]に[00:53.110]染[00:53.530]ま[00:54.130]る[00:54.780][00:54.970]\\n[00:54.970]西[00:55.500]の[00:55.680]空[00:56.400]と[00:56.810]わ[00:57.120]た[00:57.260]し[00:57.920]\\n[00:57.920]宙[00:58.750]舞[00:59.080]う[00:59.210]埃[01:00.380]が[01:00.740]キ[01:01.070]ラ[01:01.440]キ[01:01.990]ラ[01:02.260] [01:02.380]反[01:02.840]射[01:03.030]し[01:03.790]て[01:04.080]る[01:09.800]\\n[01:09.800]当[01:10.040]た[01:10.290]り[01:10.470]前[01:10.980]み[01:11.200]た[01:11.530]い[01:11.670]な[01:11.850]顔[01:12.470]し[01:13.010]て[01:13.350][01:13.450]\\n[01:13.450]青[01:14.000]い[01:14.190]春[01:14.690]を[01:14.880]食[01:15.130]ら[01:15.530]っ[01:15.580]て[01:15.770]み[01:16.280]た[01:16.600]ん[01:16.740]だ[01:17.030][01:17.170]\\n[01:17.170]甘[01:17.590]す[01:17.800]ぎ[01:18.230]て[01:18.540]と[01:18.820]ろ[01:19.020]け[01:19.200]そ[01:19.520]う[01:20.820]\\n[01:20.820]で[01:21.110]も[01:21.350]毒[01:21.890]に[01:22.220]も[01:22.450]な[01:22.760]る[01:22.960]か[01:23.210]も[01:24.210][01:24.540]\\n[01:24.540]伸[01:24.820]び[01:25.040]て[01:25.280]い[01:25.410]く[01:25.710]影[01:26.260]を[01:26.420]踏[01:26.660]み[01:26.840]し[01:27.190]め[01:27.790]て[01:28.130][01:28.220]\\n[01:28.220]早[01:28.740]く[01:28.980]う[01:29.180]ち[01:29.430]に[01:29.660]帰[01:30.290]ろ[01:31.600]う[01:31.690][01:31.780]\\n[01:31.780]世[01:32.190]界[01:32.660]は[01:33.060]狭[01:33.600]い[01:33.700]、[01:33.740]な[01:34.080]ん[01:34.330]て[01:34.590] [01:34.690]大[01:35.100]き[01:35.430]な[01:35.780]嘘[01:36.130]だ[01:53.980]\\n[01:53.980]写[01:54.320]真[01:54.790]に[01:55.060]は[01:55.720] [01:55.930]写[01:56.440]ら[01:56.910]な[01:57.470]い[01:57.700]\\n[01:57.700]音[01:58.250]や[01:58.470]声[01:59.420]、[01:59.600]匂[02:00.100]い[02:00.600]が[02:01.280][02:01.480]\\n[02:01.480]異[02:01.720]常[02:02.160]事[02:02.440]態 [02:03.180] [02:03.290]ず[02:03.690]っ[02:03.820]と[02:04.260]と[02:04.520]れ[02:04.690]な[02:05.260]い[02:05.420]ん[02:05.670]だ[02:07.770]\\n[02:07.770]背[02:08.660]伸[02:08.900]び[02:09.110]し[02:09.340]続[02:10.040]け[02:10.240]て[02:10.710]た[02:11.140]か[02:11.660]ら[02:12.500]\\n[02:12.500]痛[02:13.050]く[02:13.280]な[02:13.620]っ[02:13.740]た[02:13.900]つ[02:14.250]ま[02:14.630]先[02:15.280][02:15.510]\\n[02:15.510]裸[02:16.220]足[02:16.520]で[02:16.730]寝[02:16.920]転[02:17.890]べ[02:18.330]ば[02:18.550]天[02:19.020]井[02:19.490]に[02:19.870] [02:19.900]浮[02:20.190]か[02:20.430]ぶ[02:20.610]メ[02:21.320]ロ[02:21.580]デ[02:21.680]ィ[02:23.610]\\n[02:23.610]当[02:23.900]た[02:24.140]り[02:24.320]前[02:24.810]み[02:25.030]た[02:25.380]い[02:25.520]な[02:25.720]顔[02:26.330]し[02:26.880]て[02:27.200][02:27.320]\\n[02:27.320]青[02:27.860]い[02:28.040]春[02:28.580]を[02:28.740]食[02:28.970]ら[02:29.380]っ[02:29.440]て[02:29.560]し[02:29.690]ま[02:30.060]っ[02:30.130]た[02:30.430]ん[02:30.590]だ[02:30.940]\\n[02:30.940]白[02:31.460]旗[02:32.280]を[02:32.400]掲[02:32.910]げ[02:33.100]て[02:33.390]る[02:34.530][02:34.680]\\n[02:34.680]熱[02:35.180]く[02:35.410]て[02:35.890] [02:35.920]火[02:36.110]傷[02:36.530]し[02:36.740]そ[02:38.060]う[02:38.400]\\n[02:38.400]薄[02:38.860]く[02:39.090]な[02:39.380]る[02:39.570]影[02:40.110]を[02:40.280]見[02:40.490]つ[02:40.740]め[02:41.120] [02:41.160]て[02:41.630]た[02:42.100]\\n[02:42.100]太[02:42.590]陽[02:43.280]が[02:43.520]出[02:43.720]な[02:44.090]い[02:44.220]と[02:44.390]さ[02:45.740]\\n[02:45.740]誰[02:46.280]だ[02:46.930]っ[02:46.970]て[02:47.230]色[02:47.860]濃[02:48.090]く[02:48.430] [02:48.540]生[02:48.790]き[02:49.050]れ[02:49.260]な[02:49.680]い[02:49.760]よ[02:49.970]な[03:07.930]\\n[03:07.930]当[03:08.200]た[03:08.450]り[03:08.640]前[03:09.130]み[03:09.350]た[03:09.680]い[03:09.820]な[03:10.010]顔[03:10.660]し[03:11.140]て[03:11.520][03:11.650]\\n[03:11.650]青[03:12.180]い[03:12.360]春[03:12.900]を[03:13.030]食[03:13.310]ら[03:13.690]っ[03:13.760]て[03:13.950]ゆ[03:14.410]く[03:14.680]ん[03:14.910]だ[03:15.200][03:15.330]\\n[03:15.330]甘[03:15.800]く[03:16.020]て[03:16.390]も[03:16.740]痛[03:17.170]く[03:17.400]て[03:17.640]も[03:18.840][03:18.980]\\n[03:18.980]燃[03:19.320]え[03:19.480]尽[03:19.670]き[03:20.100]る[03:20.370]そ[03:20.660]の[03:20.810]日[03:21.030]ま[03:21.370]で[03:21.660][03:22.690]\\n[03:22.690]消[03:23.040]え[03:23.180]て[03:23.410]い[03:23.560]く[03:23.870]影[03:24.360]に[03:24.590]手[03:24.830]を[03:25.030]振[03:25.430]れ[03:25.920]ば[03:26.260][03:26.320]\\n[03:26.320]頭[03:26.650]上[03:27.110]に[03:27.340]星[03:27.810]の[03:28.000]ヒ[03:28.470]カ[03:28.770]リ[03:28.920][03:29.980]\\n[03:29.980]世[03:30.350]界[03:30.830]は[03:31.230]広[03:31.790]い[03:31.820]、[03:31.930]な[03:32.230]ん[03:32.510]て[03:32.760]信[03:33.360]じ[03:33.600]て[03:33.820]も[03:34.050]い[03:35.140]い[03:36.230]？[04:13.010]\",\n\t\"tlyric\": \"[00:21.710]8月的蓝天\\n[00:25.520]就像被谁混在了一起般\\n[00:29.140]我的耳边也传来了飞鸟的啼鸣\\n[00:36.490]被汗水侵染的T恤\\n[00:40.200]与人群挤在电车中央\\n[00:43.910]电车也随着那阵阵徐风摇晃\\n[00:50.580]被染为烈火鸟颜色的\\n[00:54.970]西空与我\\n[00:57.920]宛若在空中飞舞般 反射着光芒\\n[01:09.800]带着一副理所当然的表情\\n[01:13.450]试着吃下那蔚蓝春日\\n[01:17.170]那味道无比甜蜜 令人陶醉\\n[01:20.820]但也许会化作毒素\\n[01:24.540]踩着不断延伸的影子\\n[01:28.220]快快回到家里吧\\n[01:31.780]世界是如此小、这句话不过是巨大的谎言罢了\\n[01:53.980]无法映于照片之上的\\n[01:57.700]声音与气息\\n[02:01.480]出现了异常情况 无法被拍下啊\\n[02:07.770]也许是我一直在踮起脚尖\\n[02:12.500]所以脚尖微微泛痛\\n[02:15.510]当我赤足躺下 那天花板上也浮现了心中旋律\\n[02:23.610]我带着一副理所当然的表情\\n[02:27.320]吃下了那蔚蓝春日\\n[02:30.940]向太阳高举白旗\\n[02:34.680]那份炽热快要将我灼伤\\n[02:38.400]注视着逐渐变淡的影子\\n[02:42.100]倘若太阳再不出现的话\\n[02:45.740]无论谁都活不出浓烈的色彩\\n[03:07.930]我带着一副理所当然的表情\\n[03:11.650]渐渐吃下那蔚蓝春日\\n[03:15.330]即便那无比甜蜜 也带着痛楚\\n[03:18.980]我也愿让其在口中燃尽\\n[03:22.690]倘若用手去触摸那渐渐消失的影子\\n[03:26.320]便会发现头上亮起的星光\\n[03:29.980]世界无比广阔 这句话我可以去坚信吗？\",\n\t\"romalrc\": \"[00:21.710]ha chi ga tsu no  a o zo ra\\n[00:25.520]ka ki ma ze ru  mi ta i ni\\n[00:29.140]to bu to ri no  na ki go e ki ko e te ta\\n[00:36.490]a se ba n da T sha tsu\\n[00:40.200]ma n na ka wo  tsu ma n de\\n[00:43.910]ka ze o ko su  de n sha ni yu ra re te\\n[00:50.580]fu ra mi n go i ro ni so ma ru\\n[00:54.970]ni shi no so ra to wa ta shi\\n[00:57.920]chu u ma u ho ko ri ga ki ra ki ra  ha n sha shi te ru\\n[01:09.800]a ta ri ma e mi ta i na ka o shi te\\n[01:13.450]a o i ha ru wo ku ra tte mi ta n da\\n[01:17.170]a ma su gi te to ro ke so u\\n[01:20.820]de mo do ku ni mo na ru ka mo\\n[01:24.540]no bi te i ku ka ge wo fu mi shi me te\\n[01:28.220]ha ya ku u chi ni ka e ro u\\n[01:31.780]se ka i wa se ma i 、 na n te  o o ki na u so da\\n[01:53.980]sha shi n ni wa  u tsu ra na i\\n[01:57.700]o to ya ko e 、 ni o i ga\\n[02:01.480]i jo u ji ta i  zu tto to re na i n da\\n[02:07.770]se no bi shi tsu zu ke te ta ka ra\\n[02:12.500]i ta ku na tta tsu ma sa ki\\n[02:15.510]ha da shi de ne ko ro be ba te n jo u ni  u ka bu me ro di\\n[02:23.610]a ta ri ma e mi ta i na ka o shi te\\n[02:27.320]a o i ha ru wo ku ra tte shi ma tta n da\\n[02:30.940]shi ra ha ta wo ka ka ge te ru\\n[02:34.680]a tsu ku te  ya ke do shi so u\\n[02:38.400]u su ku na ru ka ge wo mi tsu me  te ta\\n[02:42.100]ta i yo u ga de na i to sa\\n[02:45.740]da re da tte i ro ko ku  i ki re na i yo na\\n[03:07.930]a ta ri ma e mi ta i na ka o shi te\\n[03:11.650]a o i ha ru wo ku ra tte yu ku n da\\n[03:15.330]a ma ku te mo i ta ku te mo\\n[03:18.980]mo e tsu ki ru so no hi ma de\\n[03:22.690]ki e te i ku ka ge ni te wo fu re ba\\n[03:26.320]zu jo u ni ho shi no hi ka ri\\n[03:29.980]se ka i wa hi ro i 、 na n te shi n ji te mo i i ?\",\n\t\"id\": \"bilibili::BV1Zu411x7mc\",\n\t\"updateTime\": 1770339268242\n}\n"
  },
  {
    "path": "packages/splash/src/converter/netease.test.ts",
    "content": "import * as fs from 'fs'\nimport * as path from 'path'\n\nimport { parseYrc, formatSplTime } from './netease'\n\ndescribe('网易云 YRC 转换器', () => {\n\tit('应该正确格式化时间', () => {\n\t\texpect(formatSplTime(0)).toBe('00:00.000')\n\t\texpect(formatSplTime(1000)).toBe('00:01.000')\n\t\texpect(formatSplTime(60000)).toBe('01:00.000')\n\t\texpect(formatSplTime(61234)).toBe('01:01.234')\n\t})\n\n\tit('应该解析 JSON 格式的元数据行', () => {\n\t\tconst input = `{\"t\":0,\"c\":[{\"tx\":\"制作人: \"},{\"tx\":\"ZUN\"}]}\\n{\"t\":1000,\"c\":[{\"tx\":\"作词: \"},{\"tx\":\"Haruka\"}]}`\n\t\tconst output = parseYrc(input)\n\t\texpect(output).toBe('[00:00.000]制作人: ZUN\\n[00:01.000]作词: Haruka')\n\t})\n\n\tit('应该解析简单的 YRC 行', () => {\n\t\t// [57500,3500](57500,520,0)流(58020,180,0)れ...\n\t\tconst input = `[57500,3500](57500,520,0)流(58020,180,0)れ`\n\t\tconst output = parseYrc(input)\n\t\t// 57500 = 57.500\n\t\t// 58020 = 58.020\n\t\t// Word 2 End: 58020 + 180 = 58200 = 00:58.200\n\t\texpect(output).toBe('[00:57.500]流<00:58.020>れ<00:58.200>')\n\t})\n\n\tit('应该处理延迟开始的情况', () => {\n\t\t// Line starts at 1000. Word 1 starts at 1500.\n\t\t// Word 1: 1500, 500 -> End 2000\n\t\t// Word 2: 2000, 500 -> End 2500\n\t\tconst input = `[1000,2000](1500,500,0)Hello(2000,500,0)World`\n\t\tconst output = parseYrc(input)\n\t\t// [00:01.000] Line start\n\t\t// <00:01.000> First gap start (implicit from line start)\n\t\t// <00:01.500> Word 1 start\n\t\t// <00:02.000> Word 1 end / Word 2 start (contiguous)\n\t\t// <00:02.500> Word 2 end\n\t\texpect(output).toBe(\n\t\t\t'[00:01.000]<00:01.000><00:01.500>Hello<00:02.000>World<00:02.500>',\n\t\t)\n\t})\n\n\tit('应该能解析真实文件数据', () => {\n\t\tconst filePath = path.join(__dirname, '../__tests__/fixtures/687506.json')\n\t\tif (fs.existsSync(filePath)) {\n\t\t\tconst data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {\n\t\t\t\tyrc: {\n\t\t\t\t\tlyric: string\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (data.yrc?.lyric) {\n\t\t\t\tconst result = parseYrc(data.yrc.lyric)\n\t\t\t\t// 期望包含间奏的时间戳 <00:58.020>\n\t\t\t\texpect(result).toContain('[00:57.500]流<00:58.020>れ')\n\t\t\t\tconsole.log('Converted sample:\\n', result.substring(0, 200))\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn('Test data file not found, skipping real file test')\n\t\t}\n\t})\n\tit('应该解析混合了 JSON 和标准 LRC 的行', () => {\n\t\tconst input = `{\"t\":0,\"c\":[{\"tx\":\"作词: \"},{\"tx\":\"DECO*27\"}]}\n{\"t\":1000,\"c\":[{\"tx\":\"作曲: \"},{\"tx\":\"DECO*27\"}]}\n[00:20.848]特別な君と 特別な日を\n[00:25.915]笑い合って バカもしたいな`\n\n\t\tconst output = parseYrc(input)\n\t\tconst lines = output.split('\\n')\n\n\t\texpect(lines).toContain('[00:00.000]作词: DECO*27')\n\t\texpect(lines).toContain('[00:01.000]作曲: DECO*27')\n\t\texpect(lines).toContain('[00:20.848]特別な君と 特別な日を')\n\t\texpect(lines).toContain('[00:25.915]笑い合って バカもしたいな')\n\t})\n\n\tit('应该正确处理过长的间奏（遵循词的持续时间）', () => {\n\t\t// [57920,11880](57920,830,0)宙... (64080,2630,0)る\n\t\t// Line end: 57920 + 11880 = 69800 = 01:09.800\n\t\t// Last word end: 64080 + 2630 = 66710 = 01:06.710\n\t\tconst input = `[57920,11880](57920,830,0)宙(64080,2630,0)る`\n\t\tconst output = parseYrc(input)\n\t\t// Should end at 01:06.710, not 01:09.800\n\t\t// Should include gap: <00:58.750> (end of 宙) -> <01:04.080> (start of る)\n\t\texpect(output).toBe('[00:57.920]宙<00:58.750><01:04.080>る<01:06.710>')\n\t})\n\n\tit('应该优雅地处理负时间戳', () => {\n\t\tconst input = `{\"t\":-1,\"c\":[{\"tx\":\"Invalid Time\"}]}`\n\t\tconst output = parseYrc(input)\n\t\t// Should clamp to 00:00.000\n\t\texpect(output).toBe('[00:00.000]Invalid Time')\n\t})\n})\n"
  },
  {
    "path": "packages/splash/src/converter/netease.ts",
    "content": "export interface YrcLine {\n\tt: number\n\tc: { tx: string }[]\n}\n\nexport function formatSplTime(ms: number): string {\n\t// 将负时间戳统一处理为 0\n\tif (ms < 0) ms = 0\n\n\tconst totalSeconds = Math.floor(ms / 1000)\n\tconst minutes = Math.floor(totalSeconds / 60)\n\tconst seconds = totalSeconds % 60\n\tconst milliseconds = Math.floor(ms % 1000)\n\n\tconst mm = minutes.toString().padStart(2, '0')\n\tconst ss = seconds.toString().padStart(2, '0')\n\tconst SSS = milliseconds.toString().padStart(3, '0')\n\n\treturn `${mm}:${ss}.${SSS}`\n}\n\nexport function parseYrc(yrcContent: string): string {\n\tconst lines = yrcContent.split('\\n')\n\tconst splLines: string[] = []\n\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim()\n\t\tif (!trimmed) continue\n\n\t\ttry {\n\t\t\tif (trimmed.startsWith('{') && trimmed.endsWith('}')) {\n\t\t\t\tconst json = JSON.parse(trimmed) as YrcLine\n\t\t\t\tif (json.c && Array.isArray(json.c)) {\n\t\t\t\t\tconst text = json.c.map((item) => item.tx).join('')\n\t\t\t\t\tconst time = formatSplTime(json.t || 0)\n\t\t\t\t\tsplLines.push(`[${time}]${text}`)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t} catch {\n\t\t\t// 若非 JSON，则继续正则解析\n\t\t}\n\n\t\t// 标准 LRC 行: [mm:ss.xx] 或 [mm:ss.xxx]\n\t\t// SPL 兼容此类格式，透传即可\n\t\t// 同时需支持元数据标签，如 [ar:Author]\n\t\tif (\n\t\t\t/^\\[\\d{1,2}:\\d{1,2}(?:\\.\\d{1,3})?\\]/.test(trimmed) ||\n\t\t\t/^\\[[a-zA-Z]+:/.test(trimmed)\n\t\t) {\n\t\t\tsplLines.push(trimmed)\n\t\t\tcontinue\n\t\t}\n\n\t\tconst lineMatch = /^\\[(\\d+),(\\d+)\\](.*)/.exec(trimmed)\n\t\tif (lineMatch) {\n\t\t\t// 匹配 YRC 行格式: [开始时间,持续时间]内容\n\t\t\tconst lineStartTime = parseInt(lineMatch[1], 10)\n\t\t\tconst content = lineMatch[3]\n\n\t\t\tconst splLineWords: string[] = []\n\n\t\t\tconst wordRegex = /\\((\\d+),(\\d+),(\\d+)\\)([^(]*)/g\n\t\t\tlet match\n\t\t\tlet lastWordEndTime = lineStartTime\n\n\t\t\twhile ((match = wordRegex.exec(content)) !== null) {\n\t\t\t\tconst wordStartTime = parseInt(match[1], 10)\n\t\t\t\tconst wordDuration = parseInt(match[2], 10)\n\t\t\t\tconst wordEndTime = wordStartTime + wordDuration\n\t\t\t\tconst wordText = match[4]\n\n\t\t\t\tif (wordStartTime > lastWordEndTime) {\n\t\t\t\t\t// 检测到间隔，为当前词插入时间戳\n\t\t\t\t\t// 必须先显式结束上一个词，否则上一个词会被拉长填满间隔\n\t\t\t\t\tsplLineWords.push(`<${formatSplTime(lastWordEndTime)}>`)\n\t\t\t\t\tsplLineWords.push(`<${formatSplTime(wordStartTime)}>${wordText}`)\n\t\t\t\t} else if (splLineWords.length === 0) {\n\t\t\t\t\t// 首个词，仅添加文本（起始点即为行起始点）\n\t\t\t\t\tsplLineWords.push(wordText)\n\t\t\t\t} else {\n\t\t\t\t\t// 连续词\n\t\t\t\t\tsplLineWords.push(`<${formatSplTime(wordStartTime)}>${wordText}`)\n\t\t\t\t}\n\n\t\t\t\t// 记录当前词的末尾作为上一个词的末尾，供后续间隔判断或行尾使用\n\t\t\t\tlastWordEndTime = wordEndTime\n\t\t\t}\n\n\t\t\t// 构造最终行数据\n\t\t\tlet splLine = `[${formatSplTime(lineStartTime)}]` + splLineWords.join('')\n\n\t\t\t// 附加最后一个词的结束时间偏移量\n\t\t\tsplLine += `<${formatSplTime(lastWordEndTime)}>`\n\n\t\t\tsplLines.push(splLine)\n\t\t}\n\t}\n\n\treturn splLines.join('\\n')\n}\n"
  },
  {
    "path": "packages/splash/src/index.ts",
    "content": "export * from './types'\nexport * from './parser'\nexport * from './parser/merge'\nexport * from './utils/time'\nexport * from './converter/netease'\n"
  },
  {
    "path": "packages/splash/src/parser/index.test.ts",
    "content": "import { parseSpl, verify } from './index'\n\ndescribe('SPL Parser Integration (整体集成测试)', () => {\n\ttest('应该解析基础 LRC', () => {\n\t\tconst lrc = `\n[ti:Title]\n[00:01.00]Line 1\n[00:02.00]Line 2\n`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.meta.ti).toBe('Title')\n\t\texpect(result.lines).toHaveLength(2)\n\t\texpect(result.lines[0].content).toBe('Line 1')\n\t\texpect(result.lines[0].startTime).toBe(1000)\n\t\texpect(result.lines[0].endTime).toBe(2000) // Inferred from next line\n\t\texpect(result.lines[1].endTime).toBe(12000) // 2000 + 10s default\n\t})\n\n\ttest('应该处理重复行', () => {\n\t\tconst lrc = `[00:01.00][00:03.00]Repeated`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines).toHaveLength(2)\n\t\texpect(result.lines[0].startTime).toBe(1000)\n\t\texpect(result.lines[0].content).toBe('Repeated')\n\t\texpect(result.lines[1].startTime).toBe(3000)\n\t\texpect(result.lines[1].content).toBe('Repeated')\n\t})\n\n\ttest('应该处理显式翻译', () => {\n\t\tconst lrc = `\n[00:01.00]Main\n[00:01.00]Trans 1\n[00:01.00]Trans 2\n`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines).toHaveLength(1)\n\t\texpect(result.lines[0].content).toBe('Main')\n\t\texpect(result.lines[0].translations).toEqual(['Trans 1', 'Trans 2'])\n\t})\n\n\ttest('应该处理隐式翻译', () => {\n\t\t// \"只要都有时间戳，翻译和主歌词可以不挨着\" - Test explicit timestamps not adjacent\n\t\tconst lrc = `\n[00:01.00]Main 1\n[00:02.00]Main 2\n[00:01.00]Trans 1\n`\n\t\t// Should merge Trans 1 into Main 1\n\t\tconst result = parseSpl(lrc)\n\t\t// Result sorted by time.\n\t\t// Line 1: 1s. Line 2: 2s.\n\t\t// But map grouping logic handles this before creating lines array.\n\t\texpect(result.lines[0].content).toBe('Main 1')\n\t\texpect(result.lines[0].translations).toContain('Trans 1')\n\t\texpect(result.lines[1].content).toBe('Main 2')\n\t})\n\n\ttest('应该处理纯隐式/无时间戳翻译', () => {\n\t\t// Translation follows main lines without timestamp\n\t\tconst lrc = `\n[00:01.00]Main\nImplicit Trans\n`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines[0].content).toBe('Main')\n\t\texpect(result.lines[0].translations).toContain('Implicit Trans')\n\t})\n\n\ttest('应该处理带有隐式翻译的重复行', () => {\n\t\t// Complex case:\n\t\t// [1s][3s]Main\n\t\t// Trans\n\t\t// Should attach Trans to both 1s and 3s lines.\n\t\tconst lrc = `\n[00:01.00][00:03.00]Main\nTrans\n`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines).toHaveLength(2)\n\t\texpect(result.lines[0].startTime).toBe(1000)\n\t\texpect(result.lines[0].translations).toContain('Trans')\n\t\texpect(result.lines[1].startTime).toBe(3000)\n\t\texpect(result.lines[1].translations).toContain('Trans')\n\t})\n\n\ttest('对于未匹配到时间戳的文本应报错', () => {\n\t\tconst lrc = `Orphaned Text`\n\t\texpect(() => parseSpl(lrc)).toThrow(/未找到时间戳/)\n\t})\n\n\ttest('应该使用 spans 修正结束时间', () => {\n\t\t// Line with explicit end tag in spans\n\t\tconst lrc = `[00:01.00]Text[00:02.00]`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines[0].endTime).toBe(2000)\n\t})\n\n\ttest('如果最后一行没有结束标识，则默认为 10 秒', () => {\n\t\tconst lrc = `[00:01.00]Text`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines[0].endTime).toBe(11000)\n\t})\n\n\ttest('应该忽略空行和空白字符', () => {\n\t\tconst lrc = `\n      \n      [00:01.00]Text\n      \n      `\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines).toHaveLength(1)\n\t})\n\ttest('应该处理文件中间的元数据', () => {\n\t\tconst lrc = `\n[00:01.00]Line 1\n[by:Artist]\n[00:02.00]Line 2\n`\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.meta.by).toBe('Artist')\n\t\texpect(result.lines).toHaveLength(2)\n\t\texpect(result.lines[0].content).toBe('Line 1')\n\t\texpect(result.lines[1].content).toBe('Line 2')\n\t})\n\n\ttest('应该优雅地处理负数时间戳', () => {\n\t\tconst lrc = `[-1:-1.000]Negative Time`\n\t\t// Should clamp to 0 or at least parse without throwing \"orphaned text\" (if logic allows)\n\t\t// Since regex doesn't match \"-\", it will likely be treated as text \"[-1:-1.000]Negative Time\"\n\t\t// And if no prior timestamp, it throws \"orphaned text\"\n\t\texpect(() => parseSpl(lrc)).not.toThrow()\n\t\tconst result = parseSpl(lrc)\n\t\texpect(result.lines[0].startTime).toBe(0)\n\t})\n})\n\ndescribe('verify', () => {\n\ttest('应该返回 isValid: true 对于有效的歌词', () => {\n\t\tconst lrc = `[00:01.00]Valid`\n\t\tconst result = verify(lrc)\n\t\texpect(result.isValid).toBe(true)\n\t})\n\n\ttest('应该返回 isValid: false 和 error 对于无效的歌词', () => {\n\t\tconst lrc = `Invalid Without Timestamp`\n\t\tconst result = verify(lrc)\n\t\tif (result.isValid) {\n\t\t\tthrow new Error('Should be invalid')\n\t\t}\n\t\texpect(result.isValid).toBe(false)\n\t\texpect(result.error.line).toBe(1)\n\t\texpect(result.error.message).toContain('未找到时间戳')\n\t})\n})\n"
  },
  {
    "path": "packages/splash/src/parser/index.ts",
    "content": "import type { LyricLine, RawLine, SplLyricData } from '../types'\nimport { SplParseError } from '../types'\nimport { parseTimeTag } from '../utils/time'\n\nimport { parseSpans } from './spans'\n\n/**\n * 解析 SPL (Salt Player Lyrics) 格式歌词\n *\n * @param lrcContent SPL/LRC 格式的歌词字符串\n * @returns 解析后的歌词数据对象 {@link SplLyricData}\n * @throws {SplParseError} 当遇到无法解析的行时抛出错误\n */\nexport function parseSpl(lrcContent: string): SplLyricData {\n\tconst lines = lrcContent.split(/\\r?\\n/)\n\tconst meta: Record<string, string> = {}\n\tconst rawLinesMap = new Map<number, RawLine[]>()\n\n\tlet lastTimestamps: number[] | null = null\n\n\tfor (let i = 0; i < lines.length; i++) {\n\t\tconst originalLine = lines[i].trim()\n\t\tif (!originalLine) continue\n\n\t\tconst metaMatch = /^\\[([a-zA-Z]+):(.*)\\]$/.exec(originalLine)\n\t\tif (metaMatch) {\n\t\t\tmeta[metaMatch[1].trim()] = metaMatch[2].trim()\n\t\t\tcontinue\n\t\t}\n\n\t\t// 支持可选的负号时间戳解析\n\t\tconst leadingTimeRegex = /^(\\[(-?\\d{1,3}):(-?\\d{1,2})\\.(\\d{1,6})\\])+/\n\t\tconst match = leadingTimeRegex.exec(originalLine)\n\n\t\tif (!match) {\n\t\t\tif (lastTimestamps) {\n\t\t\t\tlastTimestamps.forEach((time) => {\n\t\t\t\t\trawLinesMap.get(time)!.push({\n\t\t\t\t\t\tlineNumber: i + 1,\n\t\t\t\t\t\ttimestamps: lastTimestamps!,\n\t\t\t\t\t\tcontent: originalLine,\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\t// 若既无当前时间戳也关联不到上一行，则视作非法数据\n\t\t\t\tthrow new SplParseError(\n\t\t\t\t\ti + 1,\n\t\t\t\t\t`未找到时间戳，且无法关联到上一行: \"${originalLine}\"`,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\tconst fullTimePart = match[0]\n\t\tconst content = originalLine.substring(fullTimePart.length)\n\n\t\tconst singleTimeRegex = /\\[(-?\\d{1,3}):(-?\\d{1,2})\\.(\\d{1,6})\\]/g\n\t\tlet tMatch\n\n\t\tconst extractedTimes: number[] = []\n\t\twhile ((tMatch = singleTimeRegex.exec(fullTimePart)) !== null) {\n\t\t\textractedTimes.push(parseTimeTag(tMatch[0]))\n\t\t}\n\n\t\textractedTimes.sort((a, b) => a - b)\n\t\tlastTimestamps = extractedTimes\n\n\t\textractedTimes.forEach((time) => {\n\t\t\tif (!rawLinesMap.has(time)) {\n\t\t\t\trawLinesMap.set(time, [])\n\t\t\t}\n\t\t\trawLinesMap.get(time)!.push({\n\t\t\t\tlineNumber: i + 1,\n\t\t\t\ttimestamps: extractedTimes,\n\t\t\t\tcontent: content,\n\t\t\t})\n\t\t})\n\t}\n\n\tconst sortedTimes = Array.from(rawLinesMap.keys()).sort((a, b) => a - b)\n\n\tconst finalLines: LyricLine[] = []\n\n\tfor (let i = 0; i < sortedTimes.length; i++) {\n\t\tconst startTime = sortedTimes[i]\n\t\tconst candidates = rawLinesMap.get(startTime)!\n\n\t\tconst mainRaw = candidates[0]\n\t\tconst translationsRaw = candidates.slice(1)\n\n\t\tconst translations = translationsRaw.map((c) => c.content)\n\n\t\tconst {\n\t\t\tcontent: mainContent,\n\t\t\tspans,\n\t\t\tisDynamic,\n\t\t\texplicitEnd,\n\t\t} = parseSpans(mainRaw.content, startTime, mainRaw.lineNumber)\n\n\t\tlet endTime = explicitEnd\n\t\tif (endTime === undefined) {\n\t\t\t// 若没显式结束标签，则取下一行起始时间或默认为 10s\n\t\t\tif (i < sortedTimes.length - 1) {\n\t\t\t\tendTime = sortedTimes[i + 1]\n\t\t\t} else {\n\t\t\t\tendTime = startTime + 10000\n\t\t\t}\n\t\t}\n\n\t\tconst fixedSpans = spans.map((s) => {\n\t\t\tif (s.endTime === 0 || isNaN(s.endTime)) {\n\t\t\t\tconst validEndTime = endTime\n\t\t\t\treturn Object.assign(s, {endTime:validEndTime,duration:validEndTime-s.startTime})\n\t\t\t}\n\t\t\treturn s\n\t\t})\n\n\t\tfinalLines.push({\n\t\t\tstartTime,\n\t\t\tendTime: endTime,\n\t\t\tcontent: mainContent,\n\t\t\ttranslations,\n\t\t\tisDynamic,\n\t\t\tspans: fixedSpans,\n\t\t})\n\t}\n\n\treturn {\n\t\tmeta,\n\t\tlines: finalLines,\n\t}\n}\n\n/**\n * 验证 SPL/LRC 歌词格式是否正确\n *\n * @param lrcContent 待验证的歌词内容\n * @returns 验证结果对象\n */\nexport function verify(\n\tlrcContent: string,\n): { isValid: true } | { isValid: false; error: SplParseError } {\n\ttry {\n\t\tparseSpl(lrcContent)\n\t\treturn { isValid: true }\n\t} catch (e) {\n\t\tif (e instanceof SplParseError) {\n\t\t\treturn { isValid: false, error: e }\n\t\t}\n\t\tthrow e\n\t}\n}\n"
  },
  {
    "path": "packages/splash/src/parser/merge.ts",
    "content": "import type { LyricLine } from '../types'\n\nimport { parseSpl } from './index'\n\nexport interface MultiLyricsInput {\n\tlrc: string\n\ttlyric?: string\n\tromalrc?: string\n}\n\n/**\n * 验证次要歌词与主歌词的时间轴匹配度\n */\nfunction isMatch(mainLines: LyricLine[], secondaryLines: LyricLine[]): boolean {\n\tif (secondaryLines.length === 0) return false\n\tconst mainTimestamps = new Set(mainLines.map((l) => l.startTime))\n\tlet matchCount = 0\n\tfor (const line of secondaryLines) {\n\t\tif (mainTimestamps.has(line.startTime)) matchCount++\n\t}\n\treturn matchCount / secondaryLines.length >= 0.2\n}\n\n/**\n * 解析并合并主歌词、翻译、罗马音。\n * 核心逻辑：以主歌词为基准，通过时间戳对齐翻译和罗马音。\n */\nexport function parseAndMergeLyrics(input: MultiLyricsInput): LyricLine[] {\n\tif (!input.lrc) return []\n\n\tconst mainLines = parseSpl(input.lrc).lines\n\n\tconst getMappedLines = (raw?: string) => {\n\t\tif (!raw) return null\n\t\ttry {\n\t\t\tconst parsed = parseSpl(raw).lines\n\t\t\tif (!isMatch(mainLines, parsed)) return null\n\t\t\treturn new Map(parsed.map((l) => [l.startTime, l.content]))\n\t\t} catch {\n\t\t\treturn null\n\t\t}\n\t}\n\n\tconst translationMap = getMappedLines(input.tlyric)\n\tconst romajiMap = getMappedLines(input.romalrc)\n\n\tif (!translationMap && !romajiMap) return mainLines\n\n\treturn mainLines.map((line) => ({\n\t\t...line,\n\t\ttranslation: translationMap?.get(line.startTime),\n\t\tromaji: romajiMap?.get(line.startTime),\n\t\t// 为旧版逻辑填充 translations 数组\n\t\ttranslations: [\n\t\t\ttranslationMap?.get(line.startTime),\n\t\t\tromajiMap?.get(line.startTime),\n\t\t].filter((v): v is string => !!v),\n\t}))\n}\n"
  },
  {
    "path": "packages/splash/src/parser/spans.test.ts",
    "content": "import { parseSpans } from './spans'\n\ndescribe('Span Parser (逐字解析)', () => {\n\tconst LINE_START = 1000\n\n\ttest('应该解析纯文本', () => {\n\t\tconst result = parseSpans('Hello World', LINE_START, 1)\n\t\texpect(result.content).toBe('Hello World')\n\t\texpect(result.isDynamic).toBe(false)\n\t\texpect(result.spans).toHaveLength(1)\n\t\texpect(result.spans[0].text).toBe('Hello World')\n\t\texpect(result.spans[0].startTime).toBe(LINE_START)\n\t\texpect(result.spans[0].endTime).toBe(0) // Placeholder\n\t})\n\n\ttest('应该解析中括号形式的逐字歌词', () => {\n\t\t// [1s]Hello[2s]World[3s]\n\t\tconst input = 'Hello[00:02.00]World[00:03.00]'\n\t\t// Start at 1s (1000ms)\n\t\tconst result = parseSpans(input, 1000, 1)\n\n\t\texpect(result.content).toBe('HelloWorld')\n\t\texpect(result.isDynamic).toBe(true)\n\t\texpect(result.spans).toHaveLength(2)\n\n\t\texpect(result.spans[0].text).toBe('Hello')\n\t\texpect(result.spans[0].startTime).toBe(1000)\n\t\texpect(result.spans[0].endTime).toBe(2000)\n\n\t\texpect(result.spans[1].text).toBe('World')\n\t\texpect(result.spans[1].startTime).toBe(2000)\n\t\texpect(result.spans[1].endTime).toBe(3000) // Explicit end from last tag if treated as tag?\n\t\t// Wait, parser logic: if tag is loop item, it sets PREV valid span endTime.\n\t\t// Last tag [00:03.00] updates \"World\" span end time.\n\t\t// And explicitEnd should be 3000.\n\n\t\texpect(result.explicitEnd).toBe(3000)\n\t})\n\n\ttest('应该解析尖括号形式的逐字歌词（兼容模式）', () => {\n\t\t// [1s]Hello<00:02.00>World[00:03.00]\n\t\tconst input = 'Hello<00:02.00>World[00:03.00]'\n\t\tconst result = parseSpans(input, 1000, 1)\n\n\t\texpect(result.content).toBe('HelloWorld')\n\t\texpect(result.spans[0].endTime).toBe(2000)\n\t\texpect(result.spans[1].startTime).toBe(2000)\n\t\texpect(result.spans[1].endTime).toBe(3000)\n\t})\n\n\ttest('应该处理延迟开始的情况', () => {\n\t\t// [1s]<1.5s>Text\n\t\tconst input = '<00:01.50>Text'\n\t\t// Line start 1000\n\t\tconst result = parseSpans(input, 1000, 1)\n\n\t\t// First part is empty string (before <...>), ignored?\n\t\t// Split: [\"\", \"<00:01.50>\", \"Text\"]\n\t\t// Loop 0: \"\" -> ignored.\n\t\t// Loop 1: Tag 1500. currentTime = 1500.\n\t\t// Loop 2: \"Text\". Span start 1500.\n\n\t\texpect(result.content).toBe('Text')\n\t\texpect(result.spans).toHaveLength(1)\n\t\texpect(result.spans[0].text).toBe('Text')\n\t\texpect(result.spans[0].startTime).toBe(1500)\n\t})\n\n\ttest('应该警告并忽略时间倒流的戳', () => {\n\t\tconst consoleSpy = jest.spyOn(console, 'warn').mockImplementation()\n\t\t// Start 5s. Tag 4s.\n\t\tconst input = 'Hello[00:04.00]World'\n\t\tconst result = parseSpans(input, 5000, 1)\n\n\t\texpect(consoleSpy).toHaveBeenCalled()\n\t\texpect(result.spans[0].endTime).toBe(0) // Not updated by invalid tag\n\t\t// Second span \"World\" starts at 5000 (ignored tag didn't update currentTime)?\n\t\t// Wait, let's check logic:\n\t\t// Parsing \"Hello\": spans check.\n\t\t// Tag matches: time < currentTime? continue.\n\t\t// So currentTime remains 5000.\n\t\t// Next text \"World\": spans.push(startTime: 5000).\n\n\t\texpect(result.spans[1].startTime).toBe(5000)\n\t\tconsoleSpy.mockRestore()\n\t})\n\n\ttest('应该处理冗余的时间戳', () => {\n\t\t// Text[1s][2s]Suffix\n\t\t// Text ends at 1s. [2s] updates CurrentTime but doesn't extend Text (since it's already closed).\n\t\tconst input = 'Text[00:01.00][00:02.00]Suffix'\n\t\tconst result = parseSpans(input, 0, 1)\n\n\t\texpect(result.spans[0].text).toBe('Text')\n\t\texpect(result.spans[0].endTime).toBe(1000) // Closed by first tag\n\n\t\texpect(result.spans[1].text).toBe('Suffix')\n\t\texpect(result.spans[1].startTime).toBe(2000) // Starts at second tag\n\t})\n})\n"
  },
  {
    "path": "packages/splash/src/parser/spans.ts",
    "content": "import type { LyricSpan } from '../types'\nimport { parseTimeTag } from '../utils/time'\n\n/**\n * 解析单个歌词行中的逐字 Spans\n *\n * @param rawContent 原始歌词行内容 (去除行首时间戳后)，例如 \"Hello<00:01.50>World\"\n * @param lineStartTime 该行歌词的开始时间 (ms)\n * @param lineNumber 当前行号 (用于报错/警告)\n * @returns 解析结果，包含纯文本内容、Spans 数组、是否动态以及显式结束时间(如果有)\n */\nexport function parseSpans(\n\trawContent: string,\n\tlineStartTime: number,\n\tlineNumber: number,\n): {\n\t/** 纯文本内容 (移除标签后) */\n\tcontent: string\n\t/** 逐字片段列表 */\n\tspans: LyricSpan[]\n\t/** 是否包含逐字标签 */\n\tisDynamic: boolean\n\t/** 如果行末有显式时间标签，则返回该时间 (ms) */\n\texplicitEnd?: number\n} {\n\t// 按标签切割，如 <mm:ss.SS> 或 [mm:ss.SS]\n\tconst parts = rawContent.split(/([<[]\\d{1,3}:\\d{1,2}\\.\\d{1,6}[>\\]])/)\n\n\tconst spans: LyricSpan[] = []\n\tlet currentTime = lineStartTime\n\tlet explicitLineEnd: number | undefined\n\tlet fullText = ''\n\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i]\n\t\tif (i % 2 === 0) {\n\t\t\tif (part === '') continue\n\t\t\tspans.push({\n\t\t\t\ttext: part,\n\t\t\t\tstartTime: currentTime,\n\t\t\t\tendTime: 0, // 占位，待由下一个标签修正\n\t\t\t\tduration: 0,\n\t\t\t})\n\t\t\tfullText += part\n\t\t} else {\n\t\t\tconst time = parseTimeTag(part)\n\n\t\t\tif (time < currentTime) {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`第 ${lineNumber} 行警告: 时间戳 ${part} (${time}) 小于当前时间 ${currentTime}，已忽略。`,\n\t\t\t\t)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif (spans.length > 0) {\n\t\t\t\tconst lastSpan = spans[spans.length - 1]\n\t\t\t\tif (lastSpan.endTime === 0) {\n\t\t\t\t\tlastSpan.endTime = time\n\t\t\t\t\tlastSpan.duration = time - lastSpan.startTime\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcurrentTime = time\n\t\t\texplicitLineEnd = time\n\t\t}\n\t}\n\n\tconst lastPart = parts[parts.length - 1]\n\tif (lastPart && lastPart.trim() !== '') {\n\t\texplicitLineEnd = undefined\n\t}\n\n\treturn {\n\t\tcontent: fullText,\n\t\tspans,\n\t\tisDynamic: parts.length > 1,\n\t\texplicitEnd: explicitLineEnd,\n\t}\n}\n"
  },
  {
    "path": "packages/splash/src/types.ts",
    "content": "/**\n * 最小的“逐字”单元\n */\nexport interface LyricSpan {\n\t/** 这一小段的文字 */\n\ttext: string\n\t/** 绝对开始时间 (毫秒 ms) */\n\tstartTime: number\n\t/** 绝对结束时间 (毫秒 ms) */\n\tendTime: number\n\t/** 预计算持续时间 (毫秒 ms) */\n\tduration: number\n}\n\n/**\n * 每一行歌词\n */\nexport interface LyricLine {\n\t/** 该行歌词的开始时间 (毫秒 ms) */\n\tstartTime: number\n\t/** 该行歌词的结束时间 (毫秒 ms) */\n\tendTime: number\n\t/** 主歌词内容（第一次出现的） */\n\tcontent: string\n\t/** 翻译内容 (可选) */\n\ttranslation?: string\n\t/** 罗马音内容 (可选) */\n\tromaji?: string\n\t/** 翻译歌词列表，支持多行翻译 (旧版兼容) */\n\ttranslations: string[]\n\t/** 是否为动态歌词（包含逐字 spans） */\n\tisDynamic: boolean\n\t/** 逐字歌词片段列表 */\n\tspans: LyricSpan[]\n}\n\n/**\n * 最终输出的 SPL 歌词大对象\n */\nexport interface SplLyricData {\n\t/** 元数据，如标题、作者等 (Key-Value) */\n\tmeta: Record<string, string>\n\t/** 排好序的、展开了重复行的扁平化歌词行数组 */\n\tlines: LyricLine[]\n}\n\n/**\n * 内部使用的原始行结构\n */\nexport interface RawLine {\n\tlineNumber: number\n\ttimestamps: number[]\n\tcontent: string\n}\n\n/**\n * SPL 解析错误类\n */\nexport class SplParseError extends Error {\n\tconstructor(\n\t\tpublic line: number,\n\t\tmessage: string,\n\t) {\n\t\tsuper(`第 ${line} 行解析错误: ${message}`)\n\t\tthis.name = 'SplParseError'\n\t}\n}\n"
  },
  {
    "path": "packages/splash/src/utils/time.test.ts",
    "content": "import { parseTimeTag } from './time'\n\ndescribe('Time Utils (时间工具)', () => {\n\ttest('应该解析标准格式 [mm:ss.SS]', () => {\n\t\t// 05:20.22 -> 5*60*1000 + 20.22*1000 = 300000 + 20220 = 320220\n\t\texpect(parseTimeTag('[05:20.22]')).toBe(320220)\n\t})\n\n\ttest('应该能解析尖括号或无括号的格式', () => {\n\t\texpect(parseTimeTag('<05:20.22>')).toBe(320220)\n\t\texpect(parseTimeTag('05:20.22')).toBe(320220)\n\t})\n\n\ttest('应该解析短位数字', () => {\n\t\t// [1:02.1] -> 1m 2s 100ms\n\t\t// 60000 + 2000 + 100 = 62100\n\t\t// \"1\" digit in ms -> 100ms per spec logic (padEnd 3)\n\t\texpect(parseTimeTag('[1:02.1]')).toBe(62100)\n\n\t\t// [1:02.02] -> 1m 2s 20ms\n\t\t// 60000 + 2000 + 20 = 62020\n\t\texpect(parseTimeTag('[1:02.02]')).toBe(62020)\n\t})\n\n\ttest('应该解析长位数字/微秒', () => {\n\t\t// [00:00.123456] -> 0m 0s 123ms (round)\n\t\texpect(parseTimeTag('[00:00.123456]')).toBe(123)\n\t})\n\n\ttest('应该解析超过两位数的分钟', () => {\n\t\t// [100:00.00] -> 100m = 6000000ms\n\t\texpect(parseTimeTag('[100:00.00]')).toBe(6000000)\n\t})\n\n\ttest('应当能解析不带毫秒的时间', () => {\n\t\t// Usually standardized as mm:ss.SS, but basic parseFloat handles \"ss\"\n\t\t// \"05:20\" -> 5m 20s\n\t\texpect(parseTimeTag('[05:20]')).toBe(320000)\n\t})\n\n\ttest('应该按照规范示例处理填充补全', () => {\n\t\t// \"130\" -> 130ms (already 3 digits)\n\t\texpect(parseTimeTag('[00:00.130]')).toBe(130)\n\t\t// \"1\" -> 100ms\n\t\texpect(parseTimeTag('[00:00.1]')).toBe(100)\n\t\t// \"02\" -> 20ms\n\t\texpect(parseTimeTag('[00:00.02]')).toBe(20)\n\t\t// \"103\" -> 103ms (checking ambiguous minute definition in spec vs ms)\n\t\t// Spec says min limit 1-3 digits.\n\t\t// In ms position: .millis\n\t\texpect(parseTimeTag('[00:00.103]')).toBe(103)\n\t})\n})\n"
  },
  {
    "path": "packages/splash/src/utils/time.ts",
    "content": "/**\n * 解析 SPL/LRC 时间标签\n *\n * 支持格式:\n * - `[mm:ss.SS]` (标准 LRC)\n * - `<mm:ss.SS>` (SPL 兼容格式)\n * - `mm:ss.SS` (无括号)\n * - 短位/长位毫秒: `[00:00.1]` (100ms), `[00:00.02]` (20ms), `[00:00.123456]`\n *\n * @param timeStr 时间字符串，例如 \"[05:20.22]\" 或 \"<01:00.00>\"\n * @returns 解析后的绝对时间，单位：毫秒 (ms)\n */\nexport function parseTimeTag(timeStr: string): number {\n\tconst clean = timeStr.replace(/[[\\]<>]/g, '')\n\tconst [minStr, rest] = clean.split(':')\n\tconst [secStr, msStr] = rest.split('.')\n\n\tconst min = parseInt(minStr, 10)\n\tconst seconds = parseFloat(secStr + '.' + (msStr || '0'))\n\tconst result = min * 60 * 1000 + Math.round(seconds * 1000)\n\n\treturn result < 0 ? 0 : result\n}\n"
  },
  {
    "path": "packages/splash/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"target\": \"ESNext\",\n\t\t\"module\": \"CommonJS\",\n\t\t\"strict\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"outDir\": \"./dist\"\n\t},\n\t\"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "patches/react-native-mmkv.patch",
    "content": "diff --git a/src/hooks/createMMKVHook.ts b/src/hooks/createMMKVHook.ts\nindex cc1b0de9e8cc2033b3c1d8094f8434f5a82a7cd5..9058728d1cdb7e138f5a64a43f299b1fd0641ebe 100644\n--- a/src/hooks/createMMKVHook.ts\n+++ b/src/hooks/createMMKVHook.ts\n@@ -58,7 +58,14 @@ export function createMMKVHook<\n           setBump((b) => b + 1)\n         }\n       })\n-      return () => listener.remove()\n+      return () => {\n+        if (typeof listener === 'function') {\n+          // @ts-expect-error - the return type of addOnValueChangedListener can be either a function or an object with a remove method, depending on the MMKV implementation. We check for both cases here.\n+          listener()\n+          return\n+        }\n+        listener?.remove?.()\n+      }\n     }, [key, mmkv])\n \n     return [value, set]\n"
  },
  {
    "path": "patches/sonner-native@0.23.0.patch",
    "content": "diff --git a/src/index.tsx b/src/index.tsx\nindex 620c341842c332a89f227fb2e0b697aeb8806611..32392eeabda6d18df3866eef3b772be66a24fb80 100644\n--- a/src/index.tsx\n+++ b/src/index.tsx\n@@ -1,3 +1,3 @@\n export type * from './types';\n export { Toaster } from './toaster';\n-export { toast } from './toast-fns';\n+export { toast, flushToastQueue } from './toast-fns';\ndiff --git a/src/toast-fns.ts b/src/toast-fns.ts\nindex eec6ea0e9e8294da8035743a541482c844ff2642..5b878287614443e61863291c63c352a06ec50d03 100644\n--- a/src/toast-fns.ts\n+++ b/src/toast-fns.ts\n@@ -1,8 +1,14 @@\n-import { getToastContext } from './toaster';\n+import {\n+  tryAddToast,\n+  tryDismissToast,\n+  tryWiggleToast,\n+} from './toast-queue';\n import { type toast as toastType } from './types';\n\n+export { flushToastQueue } from './toast-queue';\n+\n export const toast: typeof toastType = (title, options) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     title,\n     variant: 'info',\n     ...options,\n@@ -10,7 +16,7 @@ export const toast: typeof toastType = (title, options) => {\n };\n\n toast.success = (title, options = {}) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     ...options,\n     title,\n     variant: 'success',\n@@ -18,11 +24,11 @@ toast.success = (title, options = {}) => {\n };\n\n toast.wiggle = (id) => {\n-  return getToastContext().wiggleToast(id);\n+  return tryWiggleToast(id);\n };\n\n toast.error = (title: string, options = {}) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     ...options,\n     title,\n     variant: 'error',\n@@ -30,7 +36,7 @@ toast.error = (title: string, options = {}) => {\n };\n\n toast.warning = (title: string, options = {}) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     ...options,\n     title,\n     variant: 'warning',\n@@ -38,7 +44,7 @@ toast.warning = (title: string, options = {}) => {\n };\n\n toast.info = (title: string, options = {}) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     title,\n     ...options,\n     variant: 'info',\n@@ -46,7 +52,7 @@ toast.info = (title: string, options = {}) => {\n };\n\n toast.promise = (promise, options) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     ...options,\n     title: options.loading,\n     variant: 'info',\n@@ -59,7 +65,7 @@ toast.promise = (promise, options) => {\n };\n\n toast.custom = (jsx, options) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     title: '',\n     variant: 'info',\n     jsx,\n@@ -68,7 +74,7 @@ toast.custom = (jsx, options) => {\n };\n\n toast.loading = (title, options = {}) => {\n-  return getToastContext().addToast({\n+  return tryAddToast({\n     title,\n     variant: 'loading',\n     ...options,\n@@ -76,5 +82,5 @@ toast.loading = (title, options = {}) => {\n };\n\n toast.dismiss = (id) => {\n-  return getToastContext().dismissToast(id);\n+  return tryDismissToast(id);\n };\ndiff --git a/src/toast-queue.ts b/src/toast-queue.ts\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..efaee5c1dac9c75614e9b2fbc0da09b3694738b3\n--- /dev/null\n+++ b/src/toast-queue.ts\n@@ -0,0 +1,107 @@\n+import { type ToastProps } from './types';\n+\n+type QueuedToast =\n+  | {\n+      type: 'add';\n+      props: ToastProps;\n+    }\n+  | {\n+      type: 'dismiss';\n+      id: string | number | undefined;\n+    }\n+  | {\n+      type: 'wiggle';\n+      id: string | number;\n+    };\n+\n+type ToastContextType = {\n+  addToast: (props: ToastProps) => string | number;\n+  dismissToast: (id: string | number | undefined) => string | number | undefined;\n+  wiggleToast: (id: string | number) => void;\n+};\n+\n+const toastQueue: QueuedToast[] = [];\n+let isToasterReady = false;\n+let idCounter = 1;\n+\n+const generateId = (): string => {\n+  return `${Date.now()}-${idCounter++}`;\n+};\n+\n+let contextRef: ToastContextType | null = null;\n+\n+export const setToastContext = (ctx: ToastContextType | null): void => {\n+  contextRef = ctx;\n+  if (!ctx) {\n+    isToasterReady = false;\n+  }\n+};\n+\n+export const getToastContext = (): ToastContextType => {\n+  if (!contextRef) {\n+    throw new Error('ToastContext is not initialized');\n+  }\n+  return contextRef;\n+};\n+\n+export const tryAddToast = (\n+  props: Omit<ToastProps, 'id'> & { id?: string | number }\n+): string | number => {\n+  const id = props.id ?? generateId();\n+  const fullProps = { ...props, id } as ToastProps;\n+\n+  if (isToasterReady && contextRef) {\n+    return contextRef.addToast(fullProps);\n+  }\n+\n+  toastQueue.push({ type: 'add', props: fullProps });\n+  return id;\n+};\n+\n+export const tryDismissToast = (\n+  id: string | number | undefined\n+): string | number | undefined => {\n+  if (isToasterReady && contextRef) {\n+    return contextRef.dismissToast(id);\n+  }\n+\n+  toastQueue.push({ type: 'dismiss', id });\n+  return id;\n+};\n+\n+export const tryWiggleToast = (id: string | number): void => {\n+  if (isToasterReady && contextRef) {\n+    contextRef.wiggleToast(id);\n+    return;\n+  }\n+\n+  toastQueue.push({ type: 'wiggle', id });\n+};\n+\n+export const flushToastQueue = (): void => {\n+  if (isToasterReady || !contextRef) return;\n+  isToasterReady = true;\n+\n+  while (toastQueue.length > 0) {\n+    const item = toastQueue.shift();\n+    if (!item) continue;\n+\n+    try {\n+      switch (item.type) {\n+        case 'add':\n+          contextRef.addToast(item.props);\n+          break;\n+        case 'dismiss':\n+          contextRef.dismissToast(item.id);\n+          break;\n+        case 'wiggle':\n+          contextRef.wiggleToast(item.id);\n+          break;\n+      }\n+    } catch {\n+      isToasterReady = false;\n+      if (item) toastQueue.unshift(item);\n+      break;\n+    }\n+  }\n+};\ndiff --git a/src/toaster.tsx b/src/toaster.tsx\nindex bc417793bdb5bc6a57d6b1dd195e0ee7477ad5a7..30012c0196258e447dafbb8cc36229fa28ee454f 100644\n--- a/src/toaster.tsx\n+++ b/src/toaster.tsx\n@@ -16,11 +16,15 @@ import {\n } from './types';\n import { areToastsEqual } from './toast-comparator';\n import { ANIMATION_DURATION } from './animations';\n+import { setToastContext, flushToastQueue } from './toast-queue';\n\n let addToastHandler: AddToastContextHandler;\n let dismissToastHandler: typeof toast.dismiss;\n let wiggleHandler: typeof toast.wiggle;\n\n+export { flushToastQueue };\n+export { getToastContext } from './toast-queue';\n+\n export const Toaster: React.FC<ToasterProps> = ({\n   ToasterOverlayWrapper,\n   ...toasterProps\n@@ -231,6 +235,19 @@ export const ToasterUI: React.FC<\n     [toastRefs]\n   );\n\n+  React.useEffect(() => {\n+    setToastContext({\n+      addToast: addToastHandler,\n+      dismissToast: dismissToastHandler,\n+      wiggleToast: wiggleHandler,\n+    });\n+    flushToastQueue();\n+\n+    return () => {\n+      setToastContext(null);\n+    };\n+  }, []);\n+\n   const { unstyled } = toastOptions;\n\n   const value = React.useMemo<ToasterContextType>(\n@@ -399,13 +412,4 @@ export const ToasterUI: React.FC<\n   );\n };\n\n-export const getToastContext = () => {\n-  if (!addToastHandler || !dismissToastHandler || !wiggleHandler) {\n-    throw new Error('ToastContext is not initialized');\n-  }\n-  return {\n-    addToast: addToastHandler,\n-    dismissToast: dismissToastHandler,\n-    wiggleToast: wiggleHandler,\n-  };\n-};\n+\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - apps/*\n  - packages/*\n\nonlyBuiltDependencies:\n  - '@evilmartians/lefthook'\n  - '@firebase/util'\n  - '@sentry/cli'\n  - '@shopify/react-native-skia'\n  - esbuild\n  - lefthook\n  - protobufjs\n  - react-native-logs\n  - sharp\n  - unrs-resolver\n  - workerd\n\npatchedDependencies:\n  react-native-mmkv: patches/react-native-mmkv.patch\n  sonner-native@0.23.0: patches/sonner-native@0.23.0.patch\n"
  },
  {
    "path": "rnrepo.config.json",
    "content": "{\n\t\"denyList\": [\"react-native-reanimated\", \"react-native-worklets\"]\n}\n"
  },
  {
    "path": "scripts/update-lyricon.sh",
    "content": "#!/bin/bash\n# Script to manually update Lyricon source code in the Orpheus package.\n# Usage: ./scripts/update-lyricon.sh [version/tag/commit]\n# Example: ./scripts/update-lyricon.sh 0.1.68\n\nset -e\n\nVERSION=${1:-master}\nTARGET_JAVA_DIR=\"packages/orpheus/android/src/main/java\"\nTARGET_AIDL_DIR=\"packages/orpheus/android/src/main/aidl\"\nLYRICON_REPO=\"https://github.com/tomakino/lyricon.git\"\nTEMP_DIR=\"/tmp/lyricon-update-$$\"\n\necho \"🔄 Updating Lyricon source to version/commit: $VERSION\"\n\n# 1. Clone the repository\necho \"📥 Cloning $LYRICON_REPO...\"\ngit clone \"$LYRICON_REPO\" \"$TEMP_DIR\"\ncd \"$TEMP_DIR\"\ngit checkout \"$VERSION\"\ncd - > /dev/null\n\n# 2. Prepare target directories (Clean up specifically the io/github/proify/lyricon path)\necho \"🧹 Cleaning up old Lyricon source...\"\nrm -rf \"$TARGET_JAVA_DIR/io/github/proify/lyricon\"\nrm -rf \"$TARGET_AIDL_DIR/io/github/proify/lyricon\"\n\n# 3. Copy source files\necho \"📂 Copying source files...\"\n# Kotlin files\n# We use /io/. to copy the contents of io into the target's io folder\nmkdir -p \"$TARGET_JAVA_DIR\"\ncp -R \"$TEMP_DIR/lyric/model/src/main/kotlin/io\" \"$TARGET_JAVA_DIR/\"\ncp -R \"$TEMP_DIR/lyric/bridge/provider/src/main/kotlin/io\" \"$TARGET_JAVA_DIR/\"\n\n# AIDL files\nmkdir -p \"$TARGET_AIDL_DIR\"\ncp -R \"$TEMP_DIR/lyric/bridge/provider/src/main/aidl/io\" \"$TARGET_AIDL_DIR/\"\n\n# 4. Apply necessary patches for Kotlin 2.1.20 compatibility\necho \"🔧 Applying compatibility patches...\"\nBINDER_FILE=\"$TARGET_JAVA_DIR/io/github/proify/lyricon/provider/ProviderBinder.kt\"\n\nif [ -f \"$BINDER_FILE\" ]; then\n    # Add missing encodeToString import if not present\n    if grep -q \"kotlinx.serialization.encodeToString\" \"$BINDER_FILE\"; then\n        echo \"  - Import already exists.\"\n    else\n        echo \"  - Adding kotlinx.serialization.encodeToString import...\"\n        # Using a more robust sed approach for both BSD and GNU sed\n        sed -i.bak 's/import io.github.proify.lyricon.provider.service.RemoteServiceBinder/import io.github.proify.lyricon.provider.service.RemoteServiceBinder\\nimport kotlinx.serialization.encodeToString/' \"$BINDER_FILE\"\n        rm \"${BINDER_FILE}.bak\"\n    fi\nelse\n    echo \"⚠️  Warning: ProviderBinder.kt not found at $BINDER_FILE\"\nfi\n\n# 5. Update .lyricon_version\necho \"📝 Updating .lyricon_version...\"\ncd \"$TEMP_DIR\"\nACTUAL_COMMIT=$(git rev-parse HEAD)\ncd - > /dev/null\nVERSION_FILE=\"packages/orpheus/.lyricon_version\"\necho \"$ACTUAL_COMMIT\" > \"$VERSION_FILE\"\necho \"  - Set $VERSION_FILE to $ACTUAL_COMMIT\"\n\n# 6. Cleanup\necho \"🧹 Cleaning up temporary files...\"\nrm -rf \"$TEMP_DIR\"\n\necho \"✅ Update complete!\"\n"
  },
  {
    "path": "skills-lock.json",
    "content": "{\n\t\"version\": 1,\n\t\"skills\": {\n\t\t\"react-native-ease-refactor\": {\n\t\t\t\"source\": \"appandflow/react-native-ease\",\n\t\t\t\"sourceType\": \"github\",\n\t\t\t\"computedHash\": \"54e548edf12233522cc48731cb4bca613aa54d7062625618e325d79471bd1d12\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n\t\"files\": [],\n\t\"references\": [\n\t\t{ \"path\": \"./apps/mobile/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/orpheus/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/orpheus/example/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/image-theme-colors/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/image-theme-colors/example/tsconfig.json\" },\n\t\t{ \"path\": \"./apps/docs/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/splash/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/heatmap/tsconfig.json\" },\n\t\t{ \"path\": \"./packages/logs/tsconfig.json\" },\n\t\t{ \"path\": \"./apps/update-publisher/tsconfig.json\" },\n\t\t{ \"path\": \"./apps/backend/tsconfig.json\" }\n\t],\n\t\"compilerOptions\": {\n\t\t\"skipLibCheck\": true,\n\t\t\"exactOptionalPropertyTypes\": false\n\t}\n}\n"
  },
  {
    "path": "update.json",
    "content": "{\n\t\"version\": \"2.4.3\",\n\t\"url\": \"https://github.com/bbplayer-app/BBPlayer/releases/tag/v2.4.3\",\n\t\"notes\": \"你版本太低了，赶紧更新！\",\n\t\"listed_notes\": [\n\t\t\"1. 支持使用「词幕」作为状态栏歌词后端\",\n\t\t\"2. 桌面歌词支持显示罗马音/翻译、逐字歌词\",\n\t\t\"3. 完全重构主页样式\",\n\t\t\"4. 重写随机队列模式，现在可以查看随机后的队列\",\n\t\t\"5. 歌单合并功能\",\n\t\t\"6. 其他各种细节优化和 bug 修复\"\n\t],\n\t\"forced\": false\n}\n"
  }
]