diff --git a/.env.example b/.env.example deleted file mode 100644 index 4334c731..00000000 --- a/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -VITE_API_URL=https://dandan-proxy.suemor.com/api/v2 -VITE_SENTRY_DSN= - -# mac 公证 -APPLE_ID= -APPLE_APP_SPECIFIC_PASSWORD= -APPLE_TEAM_ID= -APPLE_APP_BUNDLE_ID= \ No newline at end of file diff --git a/.gitignore b/.gitignore index a9472eaf..0298ee8a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ out *.log* .eslintcache .env -*.mas.* \ No newline at end of file +*.mas.* +/native/build \ No newline at end of file diff --git a/.npmrc b/.npmrc index c483022c..eeaae883 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -shamefully-hoist=true \ No newline at end of file +shamefully-hoist=true +registry=http://registry.npmjs.org \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1fbb2b6d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Marchen Player is a local anime video player with danmaku (bullet comments) support. It automatically matches danmaku to imported anime videos. Built with Electron, supporting Web, macOS, Windows, and Linux platforms. + +## Development Commands + +```bash +# Install dependencies (requires pnpm) +corepack enable +pnpm install + +# Development +pnpm dev # Start Electron dev server +pnpm dev:web # Start web-only dev server + +# Building +pnpm build # Full build with typecheck +pnpm build:mac # Build macOS (dmg + zip) +pnpm build:win # Build Windows installer +pnpm build:linux # Build Linux AppImage +pnpm build:web # Build web version + +# Code quality +pnpm typecheck # Run TypeScript type checking +pnpm lint # Run ESLint +pnpm lint:fix # Auto-fix ESLint issues +pnpm format # Format with Prettier + +# Version management +pnpm bump # Bump version (uses nbump) +``` + +## Architecture + +### Process Structure (Electron) + +- **Main Process** (`src/main/`): Node.js backend - window management, file system access, FFmpeg operations +- **Preload** (`src/preload/`): Context bridge exposing `electron`, `api`, and `platform` to renderer +- **Renderer** (`src/renderer/`): React frontend application + +### IPC Communication + +Uses `@egoist/tipc` for type-safe IPC between main and renderer processes: + +- **Main handlers**: `src/main/tipc/` - Define routes (app, player, setting, utils) +- **Renderer client**: `src/renderer/src/lib/client.ts` - `tipcClient` for invoking main process, `handlers` for receiving events +- Routes are combined in `src/main/tipc/index.ts` and exported as `Router` type + +Example usage in renderer: +```typescript +import { tipcClient } from '@renderer/lib/client' +const result = await tipcClient?.getAnimeDetailByPath({ path }) +``` + +### State Management + +- **Jotai atoms** (`src/renderer/src/atoms/`): Global state for player, progress, window, and settings +- **TanStack Query**: Server state and API data caching +- **Dexie** (`src/renderer/src/database/`): IndexedDB wrapper for local persistence (history) + +### Routing + +Hash-based routing with React Router v7. Routes defined in `src/renderer/src/router/router.tsx`. Main pages: Player, History. + +### Path Aliases + +Configured in `electron.vite.config.ts`: +- `@main` → `src/main` +- `@renderer` → `src/renderer/src` +- `@pkg` → `package.json` + +### Custom Protocol + +Uses `marchen://` protocol for local file access. Files are referenced with `MARCHEN_PROTOCOL_PREFIX` + absolute path. + +## Key Dependencies + +- **Video**: `@suemor/xgplayer` (custom xgplayer fork), `danmu.js` +- **Subtitles**: `@jellyfin/libass-wasm` (ASS/SSA rendering) +- **Media processing**: `fluent-ffmpeg` with `@ffmpeg-installer/ffmpeg` +- **UI**: Tailwind CSS, shadcn/ui (Radix), DaisyUI, Framer Motion +- **Icons**: Lucide React, Iconify (mingcute) + +## Code Style + +- ESLint config: `eslint-config-hyoban` +- Prettier: No semicolons, single quotes, 100 char width +- Pre-commit hook runs lint-staged on all staged files diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist deleted file mode 100644 index 38c887b2..00000000 --- a/build/entitlements.mac.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - - diff --git a/build/icon.icns b/build/icon.icns deleted file mode 100755 index a0765937..00000000 Binary files a/build/icon.icns and /dev/null differ diff --git a/build/icon.ico b/build/icon.ico deleted file mode 100755 index b4210bb2..00000000 Binary files a/build/icon.ico and /dev/null differ diff --git a/build/icon.png b/build/icon.png deleted file mode 100644 index db210008..00000000 Binary files a/build/icon.png and /dev/null differ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 8dd9259f..0c46156c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -22,6 +22,18 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + '@main': resolve('src/main'), + }, + }, + build: { + rollupOptions: { + external: [ + /\.node$/, + ], + }, + }, }, renderer: { resolve: { diff --git a/native/binding.gyp b/native/binding.gyp new file mode 100644 index 00000000..05d94730 --- /dev/null +++ b/native/binding.gyp @@ -0,0 +1,78 @@ +{ + "targets": [ + { + "target_name": "marchen_decoder", + "cflags!": ["-fno-exceptions"], + "cflags_cc!": ["-fno-exceptions"], + "sources": ["decoder.cpp"], + "include_dirs": [ + " +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +} + +class MarchenDecoder : public Napi::ObjectWrap { +public: + static Napi::Object Init(Napi::Env env, Napi::Object exports) { + Napi::Function func = DefineClass(env, "MarchenDecoder", { + InstanceMethod("open", &MarchenDecoder::Open), + InstanceMethod("setVideoBuffer", &MarchenDecoder::SetVideoBuffer), + InstanceMethod("decodeFrame", &MarchenDecoder::DecodeFrame), + InstanceMethod("seek", &MarchenDecoder::Seek), + InstanceMethod("close", &MarchenDecoder::Close), + InstanceMethod("getHwAccelInfo", &MarchenDecoder::GetHwAccelInfo), + }); + + Napi::FunctionReference* constructor = new Napi::FunctionReference(); + *constructor = Napi::Persistent(func); + env.SetInstanceData(constructor); + + exports.Set("MarchenDecoder", func); + return exports; + } + + MarchenDecoder(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) {} + + ~MarchenDecoder() { + Cleanup(); + } + +private: + // FFmpeg 上下文 + AVFormatContext* format_ctx = nullptr; + AVCodecContext* codec_ctx = nullptr; + SwsContext* sws_ctx = nullptr; + AVFrame* frame = nullptr; + AVFrame* sw_frame = nullptr; // 用于硬解时从 GPU 拷贝 + AVFrame* rgb_frame = nullptr; + AVPacket* packet = nullptr; + AVBufferRef* hw_device_ctx = nullptr; + + int video_stream_index = -1; + bool using_hw_accel = false; + AVPixelFormat hw_pix_fmt = AV_PIX_FMT_NONE; + std::string hw_device_name; + + // 共享内存指针 + uint8_t* video_buffer_ptr = nullptr; + size_t video_buffer_size = 0; + + // 视频信息 + int video_width = 0; + int video_height = 0; + + void Cleanup() { + if (sws_ctx) { + sws_freeContext(sws_ctx); + sws_ctx = nullptr; + } + if (codec_ctx) { + avcodec_free_context(&codec_ctx); + codec_ctx = nullptr; + } + if (format_ctx) { + avformat_close_input(&format_ctx); + format_ctx = nullptr; + } + if (frame) { + av_frame_free(&frame); + frame = nullptr; + } + if (sw_frame) { + av_frame_free(&sw_frame); + sw_frame = nullptr; + } + if (rgb_frame) { + av_frame_free(&rgb_frame); + rgb_frame = nullptr; + } + if (packet) { + av_packet_free(&packet); + packet = nullptr; + } + if (hw_device_ctx) { + av_buffer_unref(&hw_device_ctx); + hw_device_ctx = nullptr; + } + + video_stream_index = -1; + using_hw_accel = false; + hw_pix_fmt = AV_PIX_FMT_NONE; + video_buffer_ptr = nullptr; + video_buffer_size = 0; + } + + // 尝试初始化硬件加速 + bool TryInitHwAccel(const AVCodec* codec) { + // 按优先级尝试不同的硬件加速方案 + const char* hw_types[] = { +#ifdef __APPLE__ + "videotoolbox", +#elif _WIN32 + "d3d11va", + "dxva2", + "cuda", +#else + "vaapi", + "vdpau", + "cuda", +#endif + nullptr + }; + + for (int i = 0; hw_types[i] != nullptr; i++) { + AVHWDeviceType type = av_hwdevice_find_type_by_name(hw_types[i]); + if (type == AV_HWDEVICE_TYPE_NONE) continue; + + // 检查 codec 是否支持该硬件加速 + for (int j = 0;; j++) { + const AVCodecHWConfig* config = avcodec_get_hw_config(codec, j); + if (!config) break; + + if (config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX && + config->device_type == type) { + + // 尝试创建硬件设备上下文 + int ret = av_hwdevice_ctx_create(&hw_device_ctx, type, nullptr, nullptr, 0); + if (ret >= 0) { + hw_pix_fmt = config->pix_fmt; + hw_device_name = hw_types[i]; + return true; + } + } + } + } + return false; + } + + static AVPixelFormat GetHwFormat(AVCodecContext* ctx, const AVPixelFormat* pix_fmts) { + MarchenDecoder* decoder = static_cast(ctx->opaque); + for (const AVPixelFormat* p = pix_fmts; *p != AV_PIX_FMT_NONE; p++) { + if (*p == decoder->hw_pix_fmt) { + return *p; + } + } + // 没找到硬件格式,回退到软解 + return pix_fmts[0]; + } + +public: + // 打开文件并初始化解码器 + Napi::Value Open(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Cleanup(); + + if (info.Length() < 1 || !info[0].IsString()) { + Napi::Error::New(env, "File path string expected").ThrowAsJavaScriptException(); + return env.Null(); + } + + std::string filename = info[0].As().Utf8Value(); + + // 可选参数:是否强制软解 + bool force_sw_decode = false; + if (info.Length() > 1 && info[1].IsObject()) { + Napi::Object options = info[1].As(); + if (options.Has("forceSoftwareDecode")) { + force_sw_decode = options.Get("forceSoftwareDecode").ToBoolean().Value(); + } + } + + // 打开文件 + int ret = avformat_open_input(&format_ctx, filename.c_str(), nullptr, nullptr); + if (ret < 0) { + char errbuf[256]; + av_strerror(ret, errbuf, sizeof(errbuf)); + Napi::Error::New(env, std::string("Could not open file: ") + errbuf).ThrowAsJavaScriptException(); + return env.Null(); + } + + ret = avformat_find_stream_info(format_ctx, nullptr); + if (ret < 0) { + Napi::Error::New(env, "Could not find stream info").ThrowAsJavaScriptException(); + return env.Null(); + } + + // 寻找视频流 + const AVCodec* codec = nullptr; + video_stream_index = av_find_best_stream(format_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0); + + if (video_stream_index < 0) { + Napi::Error::New(env, "No video stream found").ThrowAsJavaScriptException(); + return env.Null(); + } + + // 初始化解码器上下文 + codec_ctx = avcodec_alloc_context3(codec); + if (!codec_ctx) { + Napi::Error::New(env, "Could not allocate codec context").ThrowAsJavaScriptException(); + return env.Null(); + } + + ret = avcodec_parameters_to_context(codec_ctx, format_ctx->streams[video_stream_index]->codecpar); + if (ret < 0) { + Napi::Error::New(env, "Could not copy codec parameters").ThrowAsJavaScriptException(); + return env.Null(); + } + + // 尝试硬件加速 + if (!force_sw_decode && TryInitHwAccel(codec)) { + codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx); + codec_ctx->opaque = this; + codec_ctx->get_format = GetHwFormat; + using_hw_accel = true; + } + + // 打开解码器 + ret = avcodec_open2(codec_ctx, codec, nullptr); + if (ret < 0) { + char errbuf[256]; + av_strerror(ret, errbuf, sizeof(errbuf)); + Napi::Error::New(env, std::string("Could not open codec: ") + errbuf).ThrowAsJavaScriptException(); + return env.Null(); + } + + // 保存视频尺寸 + video_width = codec_ctx->width; + video_height = codec_ctx->height; + + // 预分配内存 + frame = av_frame_alloc(); + sw_frame = av_frame_alloc(); + rgb_frame = av_frame_alloc(); + packet = av_packet_alloc(); + + if (!frame || !sw_frame || !rgb_frame || !packet) { + Napi::Error::New(env, "Could not allocate frames/packet").ThrowAsJavaScriptException(); + return env.Null(); + } + + // 返回视频元数据 + Napi::Object result = Napi::Object::New(env); + result.Set("width", video_width); + result.Set("height", video_height); + result.Set("duration", (double)format_ctx->duration / AV_TIME_BASE); + result.Set("frameRate", av_q2d(format_ctx->streams[video_stream_index]->avg_frame_rate)); + result.Set("codecName", codec->name); + result.Set("hwAccel", using_hw_accel); + result.Set("hwDevice", hw_device_name); + + // 返回需要的 buffer 大小 + result.Set("bufferSize", video_width * video_height * 4); + + return result; + } + + // 接收 JS 传入的 Buffer + Napi::Value SetVideoBuffer(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (info.Length() < 1 || !info[0].IsTypedArray()) { + Napi::Error::New(env, "Uint8Array expected").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::TypedArray typedArray = info[0].As(); + + if (typedArray.TypedArrayType() != napi_uint8_array && + typedArray.TypedArrayType() != napi_uint8_clamped_array) { + Napi::Error::New(env, "Buffer must be Uint8Array or Uint8ClampedArray").ThrowAsJavaScriptException(); + return env.Null(); + } + + size_t length; + void* data; + napi_value arraybuffer; + size_t byte_offset; + + napi_status status = napi_get_typedarray_info( + env, info[0], nullptr, &length, &data, &arraybuffer, &byte_offset + ); + + if (status != napi_ok) { + Napi::Error::New(env, "Failed to get buffer pointer").ThrowAsJavaScriptException(); + return env.Null(); + } + + // 检查 buffer 大小 + size_t required_size = video_width * video_height * 4; + if (length < required_size) { + Napi::Error::New(env, "Buffer too small").ThrowAsJavaScriptException(); + return env.Null(); + } + + video_buffer_ptr = (uint8_t*)data; + video_buffer_size = length; + + return Napi::Boolean::New(env, true); + } + + // 解码下一帧并写入 Buffer + Napi::Value DecodeFrame(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (!codec_ctx || !video_buffer_ptr) { + return env.Null(); + } + + int response; + while (av_read_frame(format_ctx, packet) >= 0) { + if (packet->stream_index == video_stream_index) { + response = avcodec_send_packet(codec_ctx, packet); + if (response < 0) { + av_packet_unref(packet); + continue; + } + + response = avcodec_receive_frame(codec_ctx, frame); + if (response == AVERROR(EAGAIN) || response == AVERROR_EOF) { + av_packet_unref(packet); + continue; + } else if (response < 0) { + av_packet_unref(packet); + return env.Null(); + } + + // 如果是硬解,需要从 GPU 拷贝到 CPU + AVFrame* src_frame = frame; + if (using_hw_accel && frame->format == hw_pix_fmt) { + response = av_hwframe_transfer_data(sw_frame, frame, 0); + if (response < 0) { + av_packet_unref(packet); + continue; + } + src_frame = sw_frame; + } + + // 初始化/更新 sws 上下文 + if (!sws_ctx || + sws_ctx == nullptr) { // 可以添加更多条件检查 + + if (sws_ctx) sws_freeContext(sws_ctx); + + sws_ctx = sws_getContext( + video_width, video_height, + (AVPixelFormat)src_frame->format, + video_width, video_height, + AV_PIX_FMT_RGBA, + SWS_BILINEAR, nullptr, nullptr, nullptr + ); + + if (!sws_ctx) { + av_packet_unref(packet); + Napi::Error::New(env, "Could not create sws context").ThrowAsJavaScriptException(); + return env.Null(); + } + } + + // 直接输出到共享内存 + uint8_t* dest[4] = { video_buffer_ptr, nullptr, nullptr, nullptr }; + int dest_linesize[4] = { video_width * 4, 0, 0, 0 }; + + sws_scale(sws_ctx, + src_frame->data, src_frame->linesize, + 0, video_height, + dest, dest_linesize + ); + + fprintf(stderr, "Frame decoded: %dx%d, pts=%f, first pixel RGBA: %d,%d,%d,%d\n", + video_width, video_height, puts, + video_buffer_ptr[0], video_buffer_ptr[1], + video_buffer_ptr[2], video_buffer_ptr[3]); + + // 计算 PTS + double pts = frame->best_effort_timestamp * + av_q2d(format_ctx->streams[video_stream_index]->time_base); + + av_packet_unref(packet); + + Napi::Object ret = Napi::Object::New(env); + ret.Set("pts", pts); + ret.Set("width", video_width); + ret.Set("height", video_height); + return ret; + } + av_packet_unref(packet); + } + + return env.Null(); // EOF + } + + // Seek 到指定时间点 + Napi::Value Seek(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + if (!format_ctx || info.Length() < 1 || !info[0].IsNumber()) { + return Napi::Boolean::New(env, false); + } + + double timestamp = info[0].As().DoubleValue(); + int64_t ts = (int64_t)(timestamp * AV_TIME_BASE); + + int ret = av_seek_frame(format_ctx, -1, ts, AVSEEK_FLAG_BACKWARD); + if (ret < 0) { + return Napi::Boolean::New(env, false); + } + + // 清空解码器缓冲 + avcodec_flush_buffers(codec_ctx); + + return Napi::Boolean::New(env, true); + } + + Napi::Value Close(const Napi::CallbackInfo& info) { + Cleanup(); + return info.Env().Undefined(); + } + + // 获取硬件加速信息 + Napi::Value GetHwAccelInfo(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Object result = Napi::Object::New(env); + + result.Set("enabled", using_hw_accel); + result.Set("device", hw_device_name); + + // 列出所有可用的硬件加速类型 + Napi::Array available = Napi::Array::New(env); + int idx = 0; + AVHWDeviceType type = AV_HWDEVICE_TYPE_NONE; + while ((type = av_hwdevice_iterate_types(type)) != AV_HWDEVICE_TYPE_NONE) { + available.Set(idx++, Napi::String::New(env, av_hwdevice_get_type_name(type))); + } + result.Set("available", available); + + return result; + } +}; + +Napi::Object InitAll(Napi::Env env, Napi::Object exports) { + return MarchenDecoder::Init(env, exports); +} + +NODE_API_MODULE(marchen_decoder, InitAll) diff --git a/package.json b/package.json index 69a8d5f9..f648a34a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint": "eslint", "lint:fix": "eslint --fix", "postinstall": "electron-builder install-app-deps", - "prepare": "pnpm exec simple-git-hooks", + "prepare": "pnpm exec simple-git-hooks && node-gyp rebuild --directory=native", "start": "electron-vite preview", "typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", @@ -35,7 +35,8 @@ }, "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", - "@ffprobe-installer/ffprobe": "^2.1.2" + "@ffprobe-installer/ffprobe": "^2.1.2", + "node-addon-api": "^8.5.0" }, "devDependencies": { "@egoist/tipc": "^0.3.2", @@ -101,8 +102,11 @@ "lodash-es": "^4.17.21", "lowdb": "^7.0.1", "lucide-react": "^0.511.0", + "mediabunny": "^1.31.0", + "mp4box": "^2.3.0", "nbump": "^2.0.4", "next-themes": "^0.4.4", + "node-gyp": "^10.3.1", "node-machine-id": "^1.1.12", "ofetch": "^1.3.4", "opencc-js": "^1.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6eb220af..3bd7748d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@ffprobe-installer/ffprobe': specifier: ^2.1.2 version: 2.1.2 + node-addon-api: + specifier: ^8.5.0 + version: 8.5.0 devDependencies: '@egoist/tipc': specifier: ^0.3.2 @@ -204,12 +207,21 @@ importers: lucide-react: specifier: ^0.511.0 version: 0.511.0(react@19.1.0) + mediabunny: + specifier: ^1.31.0 + version: 1.31.0 + mp4box: + specifier: ^2.3.0 + version: 2.3.0 nbump: specifier: ^2.0.4 version: 2.1.2(conventional-commits-filter@5.0.0) next-themes: specifier: ^0.4.4 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + node-gyp: + specifier: ^10.3.1 + version: 10.3.1 node-machine-id: specifier: ^1.1.12 version: 1.1.12 @@ -1156,10 +1168,18 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@npmcli/agent@2.2.2': + resolution: {integrity: sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==} + engines: {node: ^16.14.0 || >=18.0.0} + '@npmcli/fs@2.1.2': resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + '@npmcli/fs@3.1.1': + resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + '@npmcli/move-file@2.0.1': resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2023,6 +2043,12 @@ packages: '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/dom-mediacapture-transform@0.1.11': + resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} @@ -2184,6 +2210,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2209,6 +2239,10 @@ packages: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.5.0: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} engines: {node: '>= 8.0.0'} @@ -2430,6 +2464,10 @@ packages: resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + cacache@18.0.4: + resolution: {integrity: sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==} + engines: {node: ^16.14.0 || >=18.0.0} + cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -3496,6 +3534,10 @@ packages: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3858,6 +3900,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4052,6 +4098,10 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + make-fetch-happen@13.0.1: + resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==} + engines: {node: ^16.14.0 || >=18.0.0} + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -4064,6 +4114,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + mediabunny@1.31.0: + resolution: {integrity: sha512-nqM+6cOpNC/aDxCAZKnZe7oXnGaCn4rlgprzAmiH6C8GRdOHFnB6bZC0+WXGTT6mtAxXQd+BXuZ2q2zkma7dWg==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -4150,10 +4203,18 @@ packages: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} engines: {node: '>= 8'} + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + minipass-fetch@2.1.2: resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + minipass-fetch@3.0.5: + resolution: {integrity: sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + minipass-flush@1.0.5: resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} engines: {node: '>= 8'} @@ -4196,6 +4257,10 @@ packages: motion-utils@12.12.1: resolution: {integrity: sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==} + mp4box@2.3.0: + resolution: {integrity: sha512-nnABYbdh4UguEYyV+uRwQBi1tbb8kXka2Fx9yKzmDKAeh8gkvRKYxoK1XDd8GQIjSfN4rvsXrW1CBo4yRQJZDA==} + engines: {node: '>=20.8.1'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4245,6 +4310,10 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + node-api-version@0.2.0: resolution: {integrity: sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==} @@ -4254,6 +4323,11 @@ packages: node-fetch-native@1.6.6: resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} + node-gyp@10.3.1: + resolution: {integrity: sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==} + engines: {node: ^16.14.0 || >=18.0.0} + hasBin: true + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -4270,6 +4344,11 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} hasBin: true + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -4543,6 +4622,10 @@ packages: engines: {node: '>=14'} hasBin: true + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -4925,6 +5008,10 @@ packages: resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} engines: {node: '>= 10'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + socks@2.8.3: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} @@ -4965,6 +5052,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssri@10.0.6: + resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ssri@9.0.1: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -5196,10 +5287,18 @@ packages: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + unique-filename@3.0.0: + resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + unique-slug@3.0.0: resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + unique-slug@4.0.0: + resolution: {integrity: sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -5330,6 +5429,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} @@ -6370,11 +6474,25 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@npmcli/agent@2.2.2': + dependencies: + agent-base: 7.1.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + lru-cache: 10.4.3 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 semver: 7.7.2 + '@npmcli/fs@3.1.1': + dependencies: + semver: 7.7.2 + '@npmcli/move-file@2.0.1': dependencies: mkdirp: 1.0.4 @@ -7208,6 +7326,12 @@ snapshots: '@types/doctrine@0.0.9': {} + '@types/dom-mediacapture-transform@0.1.11': + dependencies: + '@types/dom-webcodecs': 0.1.13 + + '@types/dom-webcodecs@0.1.13': {} + '@types/eslint@9.6.1': dependencies: '@types/estree': 1.0.7 @@ -7421,6 +7545,8 @@ snapshots: abbrev@1.1.1: {} + abbrev@2.0.0: {} + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -7446,6 +7572,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + agentkeepalive@4.5.0: dependencies: humanize-ms: 1.2.1 @@ -7769,6 +7897,21 @@ snapshots: transitivePeerDependencies: - bluebird + cacache@18.0.4: + dependencies: + '@npmcli/fs': 3.1.1 + fs-minipass: 3.0.3 + glob: 10.4.5 + lru-cache: 10.4.3 + minipass: 7.1.2 + minipass-collect: 2.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 4.0.0 + ssri: 10.0.6 + tar: 6.2.1 + unique-filename: 3.0.0 + cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -9091,6 +9234,10 @@ snapshots: dependencies: minipass: 3.3.6 + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.2 + fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -9465,6 +9612,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.1: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -9665,6 +9814,23 @@ snapshots: - bluebird - supports-color + make-fetch-happen@13.0.1: + dependencies: + '@npmcli/agent': 2.2.2 + cacache: 18.0.4 + http-cache-semantics: 4.2.0 + is-lambda: 1.0.1 + minipass: 7.1.2 + minipass-fetch: 3.0.5 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + proc-log: 4.2.0 + promise-retry: 2.0.1 + ssri: 10.0.6 + transitivePeerDependencies: + - supports-color + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -9674,6 +9840,11 @@ snapshots: media-typer@1.1.0: {} + mediabunny@1.31.0: + dependencies: + '@types/dom-mediacapture-transform': 0.1.11 + '@types/dom-webcodecs': 0.1.13 + meow@13.2.0: {} merge-descriptors@2.0.0: {} @@ -9735,6 +9906,10 @@ snapshots: dependencies: minipass: 3.3.6 + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.2 + minipass-fetch@2.1.2: dependencies: minipass: 3.3.6 @@ -9743,6 +9918,14 @@ snapshots: optionalDependencies: encoding: 0.1.13 + minipass-fetch@3.0.5: + dependencies: + minipass: 7.1.2 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + minipass-flush@1.0.5: dependencies: minipass: 3.3.6 @@ -9783,6 +9966,8 @@ snapshots: motion-utils@12.12.1: {} + mp4box@2.3.0: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -9826,6 +10011,8 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@8.5.0: {} + node-api-version@0.2.0: dependencies: semver: 7.7.2 @@ -9834,6 +10021,21 @@ snapshots: node-fetch-native@1.6.6: {} + node-gyp@10.3.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.1 + glob: 10.4.5 + graceful-fs: 4.2.11 + make-fetch-happen: 13.0.1 + nopt: 7.2.1 + proc-log: 4.2.0 + semver: 7.7.2 + tar: 6.2.1 + which: 4.0.0 + transitivePeerDependencies: + - supports-color + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 @@ -9859,6 +10061,10 @@ snapshots: dependencies: abbrev: 1.1.1 + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -10110,6 +10316,8 @@ snapshots: prettier@3.5.3: {} + proc-log@4.2.0: {} + process-nextick-args@2.0.1: {} progress@2.0.3: {} @@ -10545,6 +10753,14 @@ snapshots: transitivePeerDependencies: - supports-color + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + socks: 2.8.3 + transitivePeerDependencies: + - supports-color + socks@2.8.3: dependencies: ip-address: 9.0.5 @@ -10590,6 +10806,10 @@ snapshots: sprintf-js@1.1.3: {} + ssri@10.0.6: + dependencies: + minipass: 7.1.2 + ssri@9.0.1: dependencies: minipass: 3.3.6 @@ -10842,10 +11062,18 @@ snapshots: dependencies: unique-slug: 3.0.0 + unique-filename@3.0.0: + dependencies: + unique-slug: 4.0.0 + unique-slug@3.0.0: dependencies: imurmurhash: 0.1.4 + unique-slug@4.0.0: + dependencies: + imurmurhash: 0.1.4 + universalify@0.1.2: {} universalify@2.0.1: {} @@ -10945,6 +11173,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.1 + wide-align@1.1.5: dependencies: string-width: 4.2.3 diff --git a/repomix-output.xml b/repomix-output.xml new file mode 100644 index 00000000..b7bec83c --- /dev/null +++ b/repomix-output.xml @@ -0,0 +1,14665 @@ +This file is a merged representation of the entire codebase, combined into a single document by Repomix. + + +This section contains a summary of this file. + + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files (if enabled) +5. Multiple file entries, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + +.github/ + ISSUE_TEMPLATE/ + bug_report.md + feature_request.md + workflows/ + build.yml + deploy.yml + release.yml +scripts/ + after-pack.js + fix-linux-permissions.js + install-darwin-deps.js + notarize.js +src/ + main/ + constants/ + app.ts + protocol.ts + system.ts + initialize/ + flag.ts + index.ts + log.ts + menu.ts + sentry.ts + lib/ + cleaner.ts + danmaku.ts + db.ts + env.ts + ffmpeg.ts + icon.ts + mime-utils-types.ts + mime-utils.ts + protocols.ts + update.ts + utils.ts + modules/ + showDialog.ts + tipc/ + _instance.ts + app.ts + index.ts + player.ts + renderer-handlers.ts + setting.ts + utils.ts + types/ + ffmpeg-custom.d.ts + windows/ + main.ts + setting.ts + env.d.ts + index.ts + preload/ + index.d.ts + index.ts + renderer/ + src/ + atoms/ + settings/ + app.ts + helper.ts + index.ts + player.ts + player.ts + progress.ts + store.ts + window.ts + components/ + common/ + ErrorView.tsx + Show.tsx + icons/ + Close.tsx + CompleteIcon.tsx + Logo.tsx + layout/ + root/ + RootLayout.tsx + RouterLayout.tsx + sidebar/ + index.tsx + modules/ + app/ + Prepare.tsx + WindowsTitlebar.tsx + core/ + ffmpeg-player/ + FFmpegPlayer.tsx + html5-player/ + initialize/ + config.ts + Event.tsx + hooks.tsx + Subtitle.tsx + loading/ + dialog/ + hooks.ts + MatchAnimeDialog.tsx + hooks.ts + PlayerProvider.tsx + Timeline.tsx + setting/ + items/ + audio/ + Audio.tsx + damaku/ + AddDanmaku.tsx + Danmaku.tsx + DanmakuSource.tsx + playList/ + PlayList.tsx + subtitle/ + hooks.ts + Subtitle.tsx + SubtitleImport.tsx + SubtitleTimeOff.tsx + Container.tsx + Sheet.tsx + Context.tsx + HTML5Player.tsx + settings/ + views/ + about/ + About.tsx + general/ + DarkMode.tsx + General.tsx + player/ + DanmakuSetting.tsx + index.tsx + list.ts + PlayerSetting.tsx + Layout.tsx + hooks.tsx + index.tsx + provider.tsx + tabs.tsx + shared/ + setting/ + SettingSelect.tsx + SettingSlider.tsx + SettingSwitch.tsx + MatchDanmakuDialog.tsx + ui/ + accordion/ + Accordion.tsx + index.ts + alert/ + Alert.tsx + index.ts + animate/ + AnimatedOutlet.tsx + CreateTranstion.tsx + FadeTransitionView.tsx + badge/ + Badge.tsx + index.ts + button/ + Button.tsx + FunctionAreaButton.tsx + index.ts + checkbox/ + Checkbox.tsx + index.ts + command/ + Command.tsx + index.ts + dialog/ + Dialog.tsx + index.ts + divider/ + Divider.tsx + index.ts + dropdownMenu/ + DropdownMenu.tsx + index.ts + input/ + index.ts + Input.tsx + label/ + index.ts + Label.tsx + menu/ + ContextMenu.tsx + index.ts + modal/ + stacked/ + constants.ts + Context.tsx + declarative-modal.tsx + index.ts + Modal.tsx + Overlay.tsx + Provider.tsx + types.ts + index.ts + popover/ + index.ts + Popover.tsx + portal/ + index.ts + RootPortal.tsx + progress/ + index.ts + Progress.tsx + scrollArea/ + index.ts + ScrollArea.tsx + select/ + index.ts + Select.tsx + sheet/ + index.ts + Sheet.tsx + slider/ + index.ts + Slider.tsx + switch/ + index.ts + Switch.tsx + tabs/ + index.ts + Tabs.tsx + toast/ + index.ts + toast.tsx + toaster.tsx + use-toast.tsx + toggle/ + index.ts + Toggle.tsx + Tooltip/ + index.ts + Tooltip.tsx + xgplayer/ + plugins/ + exit/ + index.css + index.ts + fullScreen/ + index.css + index.ts + nextEpisode/ + index.css + index.ts + previousEpisode/ + index.css + index.ts + setting/ + index.css + index.ts + Popover.tsx + constants/ + index.ts + name.ts + ui.ts + database/ + schemas/ + base.ts + history.ts + constants.ts + db.schema.ts + db.ts + hooks/ + theme.ts + use-before-mounted.ts + use-dialog.tsx + use-event-callback.ts + use-is-unmounted.ts + use-network-status.ts + use-toast.ts + initialize/ + date.ts + index.ts + sentry.ts + lib/ + calc-file-hash.ts + cht-to-chs.ts + client.ts + danmaku.ts + dom.ts + env.ts + log.ts + ns.ts + query-client.ts + utils.ts + page/ + history/ + index.tsx + latest-anime/ + index.tsx + player/ + index.tsx + providers/ + index.tsx + ProviderComposer.tsx + TipcListener.tsx + request/ + api/ + bangumi.ts + comment.ts + match.ts + related.ts + search.ts + models/ + bangumi.ts + base.ts + comment.ts + match.ts + related.ts + search.ts + index.ts + ofetch.ts + router/ + index.ts + name.ts + router.tsx + styles/ + base.css + font.css + main.css + player.css + shadcn.css + tailwind-extend.css + tailwind.css + App.tsx + env.d.ts + main.tsx + index.html +.gitignore +.npmrc +.prettierrc.mjs +CHANGELOG.md +CLAUDE.md +components.json +cssAsPlugin.js +dev-app-update.yml +electron-builder.yml +electron.vite.config.ts +eslint.config.mjs +LICENSE +package.json +postcss.config.cjs +README.md +renovate.json +tailwind.config.ts +tsconfig.json +tsconfig.node.json +tsconfig.web.json +vite.config.ts + + + +This section contains the contents of the repository's files. + + +--- +name: Bug report +about: Report an issue +title: '[Bug] ' +labels: bug +assignees: suemor233 +--- + +## Description + + + +import { notarize } from '@electron/notarize' +import { config } from 'dotenv' + +config() + +export default async function notarizing(context) { + if (context.electronPlatformName !== 'darwin') { + return + } + + const appBundleId = process.env.APPLE_APP_BUNDLE_ID + const appleId = process.env.APPLE_ID + const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD + const teamId = process.env.APPLE_TEAM_ID + + if (!appBundleId || !appleId || !appleIdPassword || !teamId) { + return + } + + const appName = context.packager.appInfo.productFilename + const appPath = `${context.appOutDir}/${appName}.app` + // eslint-disable-next-line no-console + console.log('Notarizing app:', appPath) + + await notarize({ + appPath, + appBundleId, + appleId, + appleIdPassword, + teamId, + }) +} + + + +import fs from 'node:fs' +import path from 'node:path' + +import { app } from 'electron' + +export const savePath = () => path.resolve(app.getPath('appData'), app.getName()) + +export const screenshotsPath = () => path.resolve(savePath(), 'screenshots') +export const subtitlesPath = () => path.resolve(savePath(), 'subtitles') +export const logPath = () => path.resolve(savePath(), 'log') +export const dbPath = () => path.resolve(savePath(), 'db') + +export const createStorageFolder = () => { + if (!fs.existsSync(screenshotsPath())) { + fs.mkdirSync(screenshotsPath(), { recursive: true }) + } + + if (!fs.existsSync(subtitlesPath())) { + fs.mkdirSync(subtitlesPath(), { recursive: true }) + } +} + + + +import path from 'node:path' + +import { logPath } from '@main/constants/app' +import logger from 'electron-log' + +export const registerLog = () => { + logger.transports.file.maxSize = 1002430 // 10M + logger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}' + logger.transports.file.resolvePathFn = () => path.resolve(logPath(), 'main.log') +} + + + +// import { DEVICE_ID } from '@main/constants/system' +import { isDev } from '@main/lib/env' +// import * as Sentry from '@sentry/electron' +// import { app } from 'electron' + +export const registerSentry = () => { + if (isDev) { + return + } + // Sentry.init({ + // dsn: import.meta.env.VITE_SENTRY_DSN, + // integrations: [Sentry.captureConsoleIntegration()], + // }) + + // Sentry.setTag('device_id', DEVICE_ID) + // Sentry.setTag('app_version', app.getVersion()) + // Sentry.setTag('build', 'electron') +} + + + +import { resolve } from 'node:path' + +import { dbPath } from '@main/constants/app' +import { JSONFileSyncPreset } from 'lowdb/node' + +let db: { + data: Record + write: () => void + read: () => void +} + +const createOrGetDb = () => { + if (!db) { + db = JSONFileSyncPreset(resolve(dbPath(), 'db.json'), {}) + } + return db +} + +export const store = { + get: (key: string) => { + const { data } = createOrGetDb() + return data[key] as any + }, + set: (key: string, value: unknown) => { + const { data, write } = createOrGetDb() + data[key] = value + write() + }, + remove: (key: string) => { + const { data, write } = createOrGetDb() + delete data[key] + write() + }, + clear: () => { + const { data, write } = createOrGetDb() + Object.keys(data).forEach((key) => delete data[key]) + write() + }, +} + + + +import os from 'node:os' + +export const mode = process.env.NODE_ENV +export const isDev = mode === 'development' + +const { platform } = process +export const isMacOS = platform === 'darwin' + +export const isWindows = platform === 'win32' + +export const isLinux = platform === 'linux' +export const isWindows11 = isWindows && os.version().startsWith('Windows 11') + + + +import path from 'node:path' + +export const getIconPath = () => path.join(__dirname, '../../resources/icon.png') + + + +const mimeTypes = [ + { t: 'application/andrew-inset', e: ['ez'] }, + { t: 'application/applixware', e: ['aw'] }, + { t: 'application/atom+xml', e: ['atom'] }, + { t: 'application/atomcat+xml', e: ['atomcat'] }, + { t: 'application/atomsvc+xml', e: ['atomsvc'] }, + { t: 'application/ccxml+xml', e: ['ccxml'] }, + { t: 'application/cdmi-capability', e: ['cdmia'] }, + { t: 'application/cdmi-container', e: ['cdmic'] }, + { t: 'application/cdmi-domain', e: ['cdmid'] }, + { t: 'application/cdmi-object', e: ['cdmio'] }, + { t: 'application/cdmi-queue', e: ['cdmiq'] }, + { t: 'application/cu-seeme', e: ['cu'] }, + { t: 'application/davmount+xml', e: ['davmount'] }, + { t: 'application/docbook+xml', e: ['dbk'] }, + { t: 'application/dssc+der', e: ['dssc'] }, + { t: 'application/dssc+xml', e: ['xdssc'] }, + { t: 'application/ecmascript', e: ['ecma'] }, + { t: 'application/emma+xml', e: ['emma'] }, + { t: 'application/epub+zip', e: ['epub'] }, + { t: 'application/exi', e: ['exi'] }, + { t: 'application/font-tdpfr', e: ['pfr'] }, + { t: 'application/font-woff', e: ['woff'] }, + { t: 'application/gml+xml', e: ['gml'] }, + { t: 'application/gpx+xml', e: ['gpx'] }, + { t: 'application/gxf', e: ['gxf'] }, + { t: 'application/hyperstudio', e: ['stk'] }, + { t: 'application/inkml+xml', e: ['ink', 'inkml'] }, + { t: 'application/ipfix', e: ['ipfix'] }, + { t: 'application/java-archive', e: ['jar'] }, + { t: 'application/java-serialized-object', e: ['ser'] }, + { t: 'application/java-vm', e: ['class'] }, + { t: 'application/javascript', e: ['js'] }, + { t: 'application/json', e: ['json'] }, + { t: 'application/jsonml+json', e: ['jsonml'] }, + { t: 'application/lost+xml', e: ['lostxml'] }, + { t: 'application/mac-binhex40', e: ['hqx'] }, + { t: 'application/mac-compactpro', e: ['cpt'] }, + { t: 'application/mads+xml', e: ['mads'] }, + { t: 'application/marc', e: ['mrc'] }, + { t: 'application/marcxml+xml', e: ['mrcx'] }, + { t: 'application/mathematica', e: ['ma', 'nb', 'mb'] }, + { t: 'application/mathml+xml', e: ['mathml'] }, + { t: 'application/mbox', e: ['mbox'] }, + { t: 'application/mediaservercontrol+xml', e: ['mscml'] }, + { t: 'application/metalink+xml', e: ['metalink'] }, + { t: 'application/metalink4+xml', e: ['meta4'] }, + { t: 'application/mets+xml', e: ['mets'] }, + { t: 'application/mods+xml', e: ['mods'] }, + { t: 'application/mp21', e: ['m21', 'mp21'] }, + { t: 'application/mp4', e: ['mp4s'] }, + { t: 'application/msword', e: ['doc', 'dot'] }, + { t: 'application/mxf', e: ['mxf'] }, + { + t: 'application/octet-stream', + e: ['bin', 'dms', 'lrf', 'mar', 'so', 'dist', 'distz', 'pkg', 'bpk', 'dump', 'elc', 'deploy'], + }, + { t: 'application/oda', e: ['oda'] }, + { t: 'application/oebps-package+xml', e: ['opf'] }, + { t: 'application/ogg', e: ['ogx'] }, + { t: 'application/omdoc+xml', e: ['omdoc'] }, + { t: 'application/onenote', e: ['onetoc', 'onetoc2', 'onetmp', 'onepkg'] }, + { t: 'application/oxps', e: ['oxps'] }, + { t: 'application/patch-ops-error+xml', e: ['xer'] }, + { t: 'application/pdf', e: ['pdf'] }, + { t: 'application/pgp-encrypted', e: ['pgp'] }, + { t: 'application/pgp-signature', e: ['asc', 'sig'] }, + { t: 'application/pics-rules', e: ['prf'] }, + { t: 'application/pkcs10', e: ['p10'] }, + { t: 'application/pkcs7-mime', e: ['p7m', 'p7c'] }, + { t: 'application/pkcs7-signature', e: ['p7s'] }, + { t: 'application/pkcs8', e: ['p8'] }, + { t: 'application/pkix-attr-cert', e: ['ac'] }, + { t: 'application/pkix-cert', e: ['cer'] }, + { t: 'application/pkix-crl', e: ['crl'] }, + { t: 'application/pkix-pkipath', e: ['pkipath'] }, + { t: 'application/pkixcmp', e: ['pki'] }, + { t: 'application/pls+xml', e: ['pls'] }, + { t: 'application/postscript', e: ['ai', 'eps', 'ps'] }, + { t: 'application/prs.cww', e: ['cww'] }, + { t: 'application/pskc+xml', e: ['pskcxml'] }, + { t: 'application/rdf+xml', e: ['rdf'] }, + { t: 'application/reginfo+xml', e: ['rif'] }, + { t: 'application/relax-ng-compact-syntax', e: ['rnc'] }, + { t: 'application/resource-lists+xml', e: ['rl'] }, + { t: 'application/resource-lists-diff+xml', e: ['rld'] }, + { t: 'application/rls-services+xml', e: ['rs'] }, + { t: 'application/rpki-ghostbusters', e: ['gbr'] }, + { t: 'application/rpki-manifest', e: ['mft'] }, + { t: 'application/rpki-roa', e: ['roa'] }, + { t: 'application/rsd+xml', e: ['rsd'] }, + { t: 'application/rss+xml', e: ['rss'] }, + { t: 'application/rtf', e: ['rtf'] }, + { t: 'application/sbml+xml', e: ['sbml'] }, + { t: 'application/scvp-cv-request', e: ['scq'] }, + { t: 'application/scvp-cv-response', e: ['scs'] }, + { t: 'application/scvp-vp-request', e: ['spq'] }, + { t: 'application/scvp-vp-response', e: ['spp'] }, + { t: 'application/sdp', e: ['sdp'] }, + { t: 'application/set-payment-initiation', e: ['setpay'] }, + { t: 'application/set-registration-initiation', e: ['setreg'] }, + { t: 'application/shf+xml', e: ['shf'] }, + { t: 'application/smil+xml', e: ['smi', 'smil'] }, + { t: 'application/sparql-query', e: ['rq'] }, + { t: 'application/sparql-results+xml', e: ['srx'] }, + { t: 'application/srgs', e: ['gram'] }, + { t: 'application/srgs+xml', e: ['grxml'] }, + { t: 'application/sru+xml', e: ['sru'] }, + { t: 'application/ssdl+xml', e: ['ssdl'] }, + { t: 'application/ssml+xml', e: ['ssml'] }, + { t: 'application/tei+xml', e: ['tei', 'teicorpus'] }, + { t: 'application/thraud+xml', e: ['tfi'] }, + { t: 'application/timestamped-data', e: ['tsd'] }, + { t: 'application/vnd.3gpp.pic-bw-large', e: ['plb'] }, + { t: 'application/vnd.3gpp.pic-bw-small', e: ['psb'] }, + { t: 'application/vnd.3gpp.pic-bw-var', e: ['pvb'] }, + { t: 'application/vnd.3gpp2.tcap', e: ['tcap'] }, + { t: 'application/vnd.3m.post-it-notes', e: ['pwn'] }, + { t: 'application/vnd.accpac.simply.aso', e: ['aso'] }, + { t: 'application/vnd.accpac.simply.imp', e: ['imp'] }, + { t: 'application/vnd.acucobol', e: ['acu'] }, + { t: 'application/vnd.acucorp', e: ['atc', 'acutc'] }, + { t: 'application/vnd.adobe.air-application-installer-package+zip', e: ['air'] }, + { t: 'application/vnd.adobe.formscentral.fcdt', e: ['fcdt'] }, + { t: 'application/vnd.adobe.fxp', e: ['fxp', 'fxpl'] }, + { t: 'application/vnd.adobe.xdp+xml', e: ['xdp'] }, + { t: 'application/vnd.adobe.xfdf', e: ['xfdf'] }, + { t: 'application/vnd.ahead.space', e: ['ahead'] }, + { t: 'application/vnd.airzip.filesecure.azf', e: ['azf'] }, + { t: 'application/vnd.airzip.filesecure.azs', e: ['azs'] }, + { t: 'application/vnd.amazon.ebook', e: ['azw'] }, + { t: 'application/vnd.americandynamics.acc', e: ['acc'] }, + { t: 'application/vnd.amiga.ami', e: ['ami'] }, + { t: 'application/vnd.android.package-archive', e: ['apk'] }, + { t: 'application/vnd.anser-web-certificate-issue-initiation', e: ['cii'] }, + { t: 'application/vnd.anser-web-funds-transfer-initiation', e: ['fti'] }, + { t: 'application/vnd.antix.game-component', e: ['atx'] }, + { t: 'application/vnd.apple.installer+xml', e: ['mpkg'] }, + { t: 'application/vnd.apple.mpegurl', e: ['m3u8'] }, + { t: 'application/vnd.aristanetworks.swi', e: ['swi'] }, + { t: 'application/vnd.astraea-software.iota', e: ['iota'] }, + { t: 'application/vnd.audiograph', e: ['aep'] }, + { t: 'application/vnd.blueice.multipass', e: ['mpm'] }, + { t: 'application/vnd.bmi', e: ['bmi'] }, + { t: 'application/vnd.businessobjects', e: ['rep'] }, + { t: 'application/vnd.chemdraw+xml', e: ['cdxml'] }, + { t: 'application/vnd.chipnuts.karaoke-mmd', e: ['mmd'] }, + { t: 'application/vnd.cinderella', e: ['cdy'] }, + { t: 'application/vnd.claymore', e: ['cla'] }, + { t: 'application/vnd.cloanto.rp9', e: ['rp9'] }, + { t: 'application/vnd.clonk.c4group', e: ['c4g', 'c4d', 'c4f', 'c4p', 'c4u'] }, + { t: 'application/vnd.cluetrust.cartomobile-config', e: ['c11amc'] }, + { t: 'application/vnd.cluetrust.cartomobile-config-pkg', e: ['c11amz'] }, + { t: 'application/vnd.commonspace', e: ['csp'] }, + { t: 'application/vnd.contact.cmsg', e: ['cdbcmsg'] }, + { t: 'application/vnd.cosmocaller', e: ['cmc'] }, + { t: 'application/vnd.crick.clicker', e: ['clkx'] }, + { t: 'application/vnd.crick.clicker.keyboard', e: ['clkk'] }, + { t: 'application/vnd.crick.clicker.palette', e: ['clkp'] }, + { t: 'application/vnd.crick.clicker.template', e: ['clkt'] }, + { t: 'application/vnd.crick.clicker.wordbank', e: ['clkw'] }, + { t: 'application/vnd.criticaltools.wbs+xml', e: ['wbs'] }, + { t: 'application/vnd.ctc-posml', e: ['pml'] }, + { t: 'application/vnd.cups-ppd', e: ['ppd'] }, + { t: 'application/vnd.curl.car', e: ['car'] }, + { t: 'application/vnd.curl.pcurl', e: ['pcurl'] }, + { t: 'application/vnd.dart', e: ['dart'] }, + { t: 'application/vnd.data-vision.rdz', e: ['rdz'] }, + { t: 'application/vnd.dece.data', e: ['uvf', 'uvvf', 'uvd', 'uvvd'] }, + { t: 'application/vnd.dece.ttml+xml', e: ['uvt', 'uvvt'] }, + { t: 'application/vnd.dece.unspecified', e: ['uvx', 'uvvx'] }, + { t: 'application/vnd.dece.zip', e: ['uvz', 'uvvz'] }, + { t: 'application/vnd.denovo.fcselayout-link', e: ['fe_launch'] }, + { t: 'application/vnd.dna', e: ['dna'] }, + { t: 'application/vnd.dolby.mlp', e: ['mlp'] }, + { t: 'application/vnd.dpgraph', e: ['dpg'] }, + { t: 'application/vnd.dreamfactory', e: ['dfac'] }, + { t: 'application/vnd.ds-keypoint', e: ['kpxx'] }, + { t: 'application/vnd.dvb.ait', e: ['ait'] }, + { t: 'application/vnd.dvb.service', e: ['svc'] }, + { t: 'application/vnd.dynageo', e: ['geo'] }, + { t: 'application/vnd.ecowin.chart', e: ['mag'] }, + { t: 'application/vnd.enliven', e: ['nml'] }, + { t: 'application/vnd.epson.esf', e: ['esf'] }, + { t: 'application/vnd.epson.msf', e: ['msf'] }, + { t: 'application/vnd.epson.quickanime', e: ['qam'] }, + { t: 'application/vnd.epson.salt', e: ['slt'] }, + { t: 'application/vnd.epson.ssf', e: ['ssf'] }, + { t: 'application/vnd.eszigno3+xml', e: ['es3', 'et3'] }, + { t: 'application/vnd.ezpix-album', e: ['ez2'] }, + { t: 'application/vnd.ezpix-package', e: ['ez3'] }, + { t: 'application/vnd.fdf', e: ['fdf'] }, + { t: 'application/vnd.fdsn.mseed', e: ['mseed'] }, + { t: 'application/vnd.fdsn.seed', e: ['seed', 'dataless'] }, + { t: 'application/vnd.flographit', e: ['gph'] }, + { t: 'application/vnd.fluxtime.clip', e: ['ftc'] }, + { t: 'application/vnd.framemaker', e: ['fm', 'frame', 'maker', 'book'] }, + { t: 'application/vnd.frogans.fnc', e: ['fnc'] }, + { t: 'application/vnd.frogans.ltf', e: ['ltf'] }, + { t: 'application/vnd.fsc.weblaunch', e: ['fsc'] }, + { t: 'application/vnd.fujitsu.oasys', e: ['oas'] }, + { t: 'application/vnd.fujitsu.oasys2', e: ['oa2'] }, + { t: 'application/vnd.fujitsu.oasys3', e: ['oa3'] }, + { t: 'application/vnd.fujitsu.oasysgp', e: ['fg5'] }, + { t: 'application/vnd.fujitsu.oasysprs', e: ['bh2'] }, + { t: 'application/vnd.fujixerox.ddd', e: ['ddd'] }, + { t: 'application/vnd.fujixerox.docuworks', e: ['xdw'] }, + { t: 'application/vnd.fujixerox.docuworks.binder', e: ['xbd'] }, + { t: 'application/vnd.fuzzysheet', e: ['fzs'] }, + { t: 'application/vnd.genomatix.tuxedo', e: ['txd'] }, + { t: 'application/vnd.geogebra.file', e: ['ggb'] }, + { t: 'application/vnd.geogebra.tool', e: ['ggt'] }, + { t: 'application/vnd.geometry-explorer', e: ['gex', 'gre'] }, + { t: 'application/vnd.geonext', e: ['gxt'] }, + { t: 'application/vnd.geoplan', e: ['g2w'] }, + { t: 'application/vnd.geospace', e: ['g3w'] }, + { t: 'application/vnd.gmx', e: ['gmx'] }, + { t: 'application/vnd.google-earth.kml+xml', e: ['kml'] }, + { t: 'application/vnd.google-earth.kmz', e: ['kmz'] }, + { t: 'application/vnd.grafeq', e: ['gqf', 'gqs'] }, + { t: 'application/vnd.groove-account', e: ['gac'] }, + { t: 'application/vnd.groove-help', e: ['ghf'] }, + { t: 'application/vnd.groove-identity-message', e: ['gim'] }, + { t: 'application/vnd.groove-injector', e: ['grv'] }, + { t: 'application/vnd.groove-tool-message', e: ['gtm'] }, + { t: 'application/vnd.groove-tool-template', e: ['tpl'] }, + { t: 'application/vnd.groove-vcard', e: ['vcg'] }, + { t: 'application/vnd.hal+xml', e: ['hal'] }, + { t: 'application/vnd.handheld-entertainment+xml', e: ['zmm'] }, + { t: 'application/vnd.hbci', e: ['hbci'] }, + { t: 'application/vnd.hhe.lesson-player', e: ['les'] }, + { t: 'application/vnd.hp-hpgl', e: ['hpgl'] }, + { t: 'application/vnd.hp-hpid', e: ['hpid'] }, + { t: 'application/vnd.hp-hps', e: ['hps'] }, + { t: 'application/vnd.hp-jlyt', e: ['jlt'] }, + { t: 'application/vnd.hp-pcl', e: ['pcl'] }, + { t: 'application/vnd.hp-pclxl', e: ['pclxl'] }, + { t: 'application/vnd.hydrostatix.sof-data', e: ['sfd-hdstx'] }, + { t: 'application/vnd.ibm.minipay', e: ['mpy'] }, + { t: 'application/vnd.ibm.modcap', e: ['afp', 'listafp', 'list3820'] }, + { t: 'application/vnd.ibm.rights-management', e: ['irm'] }, + { t: 'application/vnd.ibm.secure-container', e: ['sc'] }, + { t: 'application/vnd.iccprofile', e: ['icc', 'icm'] }, + { t: 'application/vnd.igloader', e: ['igl'] }, + { t: 'application/vnd.immervision-ivp', e: ['ivp'] }, + { t: 'application/vnd.immervision-ivu', e: ['ivu'] }, + { t: 'application/vnd.insors.igm', e: ['igm'] }, + { t: 'application/vnd.intercon.formnet', e: ['xpw', 'xpx'] }, + { t: 'application/vnd.intergeo', e: ['i2g'] }, + { t: 'application/vnd.intu.qbo', e: ['qbo'] }, + { t: 'application/vnd.intu.qfx', e: ['qfx'] }, + { t: 'application/vnd.ipunplugged.rcprofile', e: ['rcprofile'] }, + { t: 'application/vnd.irepository.package+xml', e: ['irp'] }, + { t: 'application/vnd.is-xpr', e: ['xpr'] }, + { t: 'application/vnd.isac.fcs', e: ['fcs'] }, + { t: 'application/vnd.jam', e: ['jam'] }, + { t: 'application/vnd.jcp.javame.midlet-rms', e: ['rms'] }, + { t: 'application/vnd.jisp', e: ['jisp'] }, + { t: 'application/vnd.joost.joda-archive', e: ['joda'] }, + { t: 'application/vnd.kahootz', e: ['ktz', 'ktr'] }, + { t: 'application/vnd.kde.karbon', e: ['karbon'] }, + { t: 'application/vnd.kde.kchart', e: ['chrt'] }, + { t: 'application/vnd.kde.kformula', e: ['kfo'] }, + { t: 'application/vnd.kde.kivio', e: ['flw'] }, + { t: 'application/vnd.kde.kontour', e: ['kon'] }, + { t: 'application/vnd.kde.kpresenter', e: ['kpr', 'kpt'] }, + { t: 'application/vnd.kde.kspread', e: ['ksp'] }, + { t: 'application/vnd.kde.kword', e: ['kwd', 'kwt'] }, + { t: 'application/vnd.kenameaapp', e: ['htke'] }, + { t: 'application/vnd.kidspiration', e: ['kia'] }, + { t: 'application/vnd.kinar', e: ['kne', 'knp'] }, + { t: 'application/vnd.koan', e: ['skp', 'skd', 'skt', 'skm'] }, + { t: 'application/vnd.kodak-descriptor', e: ['sse'] }, + { t: 'application/vnd.las.las+xml', e: ['lasxml'] }, + { t: 'application/vnd.llamagraphics.life-balance.desktop', e: ['lbd'] }, + { t: 'application/vnd.llamagraphics.life-balance.exchange+xml', e: ['lbe'] }, + { t: 'application/vnd.lotus-1-2-3', e: ['123'] }, + { t: 'application/vnd.lotus-approach', e: ['apr'] }, + { t: 'application/vnd.lotus-freelance', e: ['pre'] }, + { t: 'application/vnd.lotus-notes', e: ['nsf'] }, + { t: 'application/vnd.lotus-organizer', e: ['org'] }, + { t: 'application/vnd.lotus-screencam', e: ['scm'] }, + { t: 'application/vnd.lotus-wordpro', e: ['lwp'] }, + { t: 'application/vnd.macports.portpkg', e: ['portpkg'] }, + { t: 'application/vnd.mcd', e: ['mcd'] }, + { t: 'application/vnd.medcalcdata', e: ['mc1'] }, + { t: 'application/vnd.mediastation.cdkey', e: ['cdkey'] }, + { t: 'application/vnd.mfer', e: ['mwf'] }, + { t: 'application/vnd.mfmp', e: ['mfm'] }, + { t: 'application/vnd.micrografx.flo', e: ['flo'] }, + { t: 'application/vnd.micrografx.igx', e: ['igx'] }, + { t: 'application/vnd.mif', e: ['mif'] }, + { t: 'application/vnd.mobius.daf', e: ['daf'] }, + { t: 'application/vnd.mobius.dis', e: ['dis'] }, + { t: 'application/vnd.mobius.mbk', e: ['mbk'] }, + { t: 'application/vnd.mobius.mqy', e: ['mqy'] }, + { t: 'application/vnd.mobius.msl', e: ['msl'] }, + { t: 'application/vnd.mobius.plc', e: ['plc'] }, + { t: 'application/vnd.mobius.txf', e: ['txf'] }, + { t: 'application/vnd.mophun.application', e: ['mpn'] }, + { t: 'application/vnd.mophun.certificate', e: ['mpc'] }, + { t: 'application/vnd.mozilla.xul+xml', e: ['xul'] }, + { t: 'application/vnd.ms-artgalry', e: ['cil'] }, + { t: 'application/vnd.ms-cab-compressed', e: ['cab'] }, + { t: 'application/vnd.ms-excel', e: ['xls', 'xlm', 'xla', 'xlc', 'xlt', 'xlw'] }, + { t: 'application/vnd.ms-excel.addin.macroenabled.12', e: ['xlam'] }, + { t: 'application/vnd.ms-excel.sheet.binary.macroenabled.12', e: ['xlsb'] }, + { t: 'application/vnd.ms-excel.sheet.macroenabled.12', e: ['xlsm'] }, + { t: 'application/vnd.ms-excel.template.macroenabled.12', e: ['xltm'] }, + { t: 'application/vnd.ms-fontobject', e: ['eot'] }, + { t: 'application/vnd.ms-htmlhelp', e: ['chm'] }, + { t: 'application/vnd.ms-ims', e: ['ims'] }, + { t: 'application/vnd.ms-lrm', e: ['lrm'] }, + { t: 'application/vnd.ms-officetheme', e: ['thmx'] }, + { t: 'application/vnd.ms-pki.seccat', e: ['cat'] }, + { t: 'application/vnd.ms-pki.stl', e: ['stl'] }, + { t: 'application/vnd.ms-powerpoint', e: ['ppt', 'pps', 'pot'] }, + { t: 'application/vnd.ms-powerpoint.addin.macroenabled.12', e: ['ppam'] }, + { t: 'application/vnd.ms-powerpoint.presentation.macroenabled.12', e: ['pptm'] }, + { t: 'application/vnd.ms-powerpoint.slide.macroenabled.12', e: ['sldm'] }, + { t: 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', e: ['ppsm'] }, + { t: 'application/vnd.ms-powerpoint.template.macroenabled.12', e: ['potm'] }, + { t: 'application/vnd.ms-project', e: ['mpp', 'mpt'] }, + { t: 'application/vnd.ms-word.document.macroenabled.12', e: ['docm'] }, + { t: 'application/vnd.ms-word.template.macroenabled.12', e: ['dotm'] }, + { t: 'application/vnd.ms-works', e: ['wps', 'wks', 'wcm', 'wdb'] }, + { t: 'application/vnd.ms-wpl', e: ['wpl'] }, + { t: 'application/vnd.ms-xpsdocument', e: ['xps'] }, + { t: 'application/vnd.mseq', e: ['mseq'] }, + { t: 'application/vnd.musician', e: ['mus'] }, + { t: 'application/vnd.muvee.style', e: ['msty'] }, + { t: 'application/vnd.mynfc', e: ['taglet'] }, + { t: 'application/vnd.neurolanguage.nlu', e: ['nlu'] }, + { t: 'application/vnd.nitf', e: ['ntf', 'nitf'] }, + { t: 'application/vnd.noblenet-directory', e: ['nnd'] }, + { t: 'application/vnd.noblenet-sealer', e: ['nns'] }, + { t: 'application/vnd.noblenet-web', e: ['nnw'] }, + { t: 'application/vnd.nokia.n-gage.data', e: ['ngdat'] }, + { t: 'application/vnd.nokia.n-gage.symbian.install', e: ['n-gage'] }, + { t: 'application/vnd.nokia.radio-preset', e: ['rpst'] }, + { t: 'application/vnd.nokia.radio-presets', e: ['rpss'] }, + { t: 'application/vnd.novadigm.edm', e: ['edm'] }, + { t: 'application/vnd.novadigm.edx', e: ['edx'] }, + { t: 'application/vnd.novadigm.ext', e: ['ext'] }, + { t: 'application/vnd.oasis.opendocument.chart', e: ['odc'] }, + { t: 'application/vnd.oasis.opendocument.chart-template', e: ['otc'] }, + { t: 'application/vnd.oasis.opendocument.database', e: ['odb'] }, + { t: 'application/vnd.oasis.opendocument.formula', e: ['odf'] }, + { t: 'application/vnd.oasis.opendocument.formula-template', e: ['odft'] }, + { t: 'application/vnd.oasis.opendocument.graphics', e: ['odg'] }, + { t: 'application/vnd.oasis.opendocument.graphics-template', e: ['otg'] }, + { t: 'application/vnd.oasis.opendocument.image', e: ['odi'] }, + { t: 'application/vnd.oasis.opendocument.image-template', e: ['oti'] }, + { t: 'application/vnd.oasis.opendocument.presentation', e: ['odp'] }, + { t: 'application/vnd.oasis.opendocument.presentation-template', e: ['otp'] }, + { t: 'application/vnd.oasis.opendocument.spreadsheet', e: ['ods'] }, + { t: 'application/vnd.oasis.opendocument.spreadsheet-template', e: ['ots'] }, + { t: 'application/vnd.oasis.opendocument.text', e: ['odt'] }, + { t: 'application/vnd.oasis.opendocument.text-master', e: ['odm'] }, + { t: 'application/vnd.oasis.opendocument.text-template', e: ['ott'] }, + { t: 'application/vnd.oasis.opendocument.text-web', e: ['oth'] }, + { t: 'application/vnd.olpc-sugar', e: ['xo'] }, + { t: 'application/vnd.oma.dd2+xml', e: ['dd2'] }, + { t: 'application/vnd.openofficeorg.extension', e: ['oxt'] }, + { t: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', e: ['pptx'] }, + { t: 'application/vnd.openxmlformats-officedocument.presentationml.slide', e: ['sldx'] }, + { t: 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', e: ['ppsx'] }, + { t: 'application/vnd.openxmlformats-officedocument.presentationml.template', e: ['potx'] }, + { t: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', e: ['xlsx'] }, + { t: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', e: ['xltx'] }, + { t: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', e: ['docx'] }, + { t: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', e: ['dotx'] }, + { t: 'application/vnd.osgeo.mapguide.package', e: ['mgp'] }, + { t: 'application/vnd.osgi.dp', e: ['dp'] }, + { t: 'application/vnd.osgi.subsystem', e: ['esa'] }, + { t: 'application/vnd.palm', e: ['pdb', 'pqa', 'oprc'] }, + { t: 'application/vnd.pawaafile', e: ['paw'] }, + { t: 'application/vnd.pg.format', e: ['str'] }, + { t: 'application/vnd.pg.osasli', e: ['ei6'] }, + { t: 'application/vnd.picsel', e: ['efif'] }, + { t: 'application/vnd.pmi.widget', e: ['wg'] }, + { t: 'application/vnd.pocketlearn', e: ['plf'] }, + { t: 'application/vnd.powerbuilder6', e: ['pbd'] }, + { t: 'application/vnd.previewsystems.box', e: ['box'] }, + { t: 'application/vnd.proteus.magazine', e: ['mgz'] }, + { t: 'application/vnd.publishare-delta-tree', e: ['qps'] }, + { t: 'application/vnd.pvi.ptid1', e: ['ptid'] }, + { t: 'application/vnd.quark.quarkxpress', e: ['qxd', 'qxt', 'qwd', 'qwt', 'qxl', 'qxb'] }, + { t: 'application/vnd.realvnc.bed', e: ['bed'] }, + { t: 'application/vnd.recordare.musicxml', e: ['mxl'] }, + { t: 'application/vnd.recordare.musicxml+xml', e: ['musicxml'] }, + { t: 'application/vnd.rig.cryptonote', e: ['cryptonote'] }, + { t: 'application/vnd.rim.cod', e: ['cod'] }, + { t: 'application/vnd.rn-realmedia', e: ['rm'] }, + { t: 'application/vnd.rn-realmedia-vbr', e: ['rmvb'] }, + { t: 'application/vnd.route66.link66+xml', e: ['link66'] }, + { t: 'application/vnd.sailingtracker.track', e: ['st'] }, + { t: 'application/vnd.seemail', e: ['see'] }, + { t: 'application/vnd.sema', e: ['sema'] }, + { t: 'application/vnd.semd', e: ['semd'] }, + { t: 'application/vnd.semf', e: ['semf'] }, + { t: 'application/vnd.shana.informed.formdata', e: ['ifm'] }, + { t: 'application/vnd.shana.informed.formtemplate', e: ['itp'] }, + { t: 'application/vnd.shana.informed.interchange', e: ['iif'] }, + { t: 'application/vnd.shana.informed.package', e: ['ipk'] }, + { t: 'application/vnd.simtech-mindmapper', e: ['twd', 'twds'] }, + { t: 'application/vnd.smaf', e: ['mmf'] }, + { t: 'application/vnd.smart.teacher', e: ['teacher'] }, + { t: 'application/vnd.solent.sdkm+xml', e: ['sdkm', 'sdkd'] }, + { t: 'application/vnd.spotfire.dxp', e: ['dxp'] }, + { t: 'application/vnd.spotfire.sfs', e: ['sfs'] }, + { t: 'application/vnd.stardivision.calc', e: ['sdc'] }, + { t: 'application/vnd.stardivision.draw', e: ['sda'] }, + { t: 'application/vnd.stardivision.impress', e: ['sdd'] }, + { t: 'application/vnd.stardivision.math', e: ['smf'] }, + { t: 'application/vnd.stardivision.writer', e: ['sdw', 'vor'] }, + { t: 'application/vnd.stardivision.writer-global', e: ['sgl'] }, + { t: 'application/vnd.stepmania.package', e: ['smzip'] }, + { t: 'application/vnd.stepmania.stepchart', e: ['sm'] }, + { t: 'application/vnd.sun.xml.calc', e: ['sxc'] }, + { t: 'application/vnd.sun.xml.calc.template', e: ['stc'] }, + { t: 'application/vnd.sun.xml.draw', e: ['sxd'] }, + { t: 'application/vnd.sun.xml.draw.template', e: ['std'] }, + { t: 'application/vnd.sun.xml.impress', e: ['sxi'] }, + { t: 'application/vnd.sun.xml.impress.template', e: ['sti'] }, + { t: 'application/vnd.sun.xml.math', e: ['sxm'] }, + { t: 'application/vnd.sun.xml.writer', e: ['sxw'] }, + { t: 'application/vnd.sun.xml.writer.global', e: ['sxg'] }, + { t: 'application/vnd.sun.xml.writer.template', e: ['stw'] }, + { t: 'application/vnd.sus-calendar', e: ['sus', 'susp'] }, + { t: 'application/vnd.svd', e: ['svd'] }, + { t: 'application/vnd.symbian.install', e: ['sis', 'sisx'] }, + { t: 'application/vnd.syncml+xml', e: ['xsm'] }, + { t: 'application/vnd.syncml.dm+wbxml', e: ['bdm'] }, + { t: 'application/vnd.syncml.dm+xml', e: ['xdm'] }, + { t: 'application/vnd.tao.intent-module-archive', e: ['tao'] }, + { t: 'application/vnd.tcpdump.pcap', e: ['pcap', 'cap', 'dmp'] }, + { t: 'application/vnd.tmobile-livetv', e: ['tmo'] }, + { t: 'application/vnd.trid.tpt', e: ['tpt'] }, + { t: 'application/vnd.triscape.mxs', e: ['mxs'] }, + { t: 'application/vnd.trueapp', e: ['tra'] }, + { t: 'application/vnd.ufdl', e: ['ufd', 'ufdl'] }, + { t: 'application/vnd.uiq.theme', e: ['utz'] }, + { t: 'application/vnd.umajin', e: ['umj'] }, + { t: 'application/vnd.unity', e: ['unityweb'] }, + { t: 'application/vnd.uoml+xml', e: ['uoml'] }, + { t: 'application/vnd.vcx', e: ['vcx'] }, + { t: 'application/vnd.visio', e: ['vsd', 'vst', 'vss', 'vsw'] }, + { t: 'application/vnd.visionary', e: ['vis'] }, + { t: 'application/vnd.vsf', e: ['vsf'] }, + { t: 'application/vnd.wap.wbxml', e: ['wbxml'] }, + { t: 'application/vnd.wap.wmlc', e: ['wmlc'] }, + { t: 'application/vnd.wap.wmlscriptc', e: ['wmlsc'] }, + { t: 'application/vnd.webturbo', e: ['wtb'] }, + { t: 'application/vnd.wolfram.player', e: ['nbp'] }, + { t: 'application/vnd.wordperfect', e: ['wpd'] }, + { t: 'application/vnd.wqd', e: ['wqd'] }, + { t: 'application/vnd.wt.stf', e: ['stf'] }, + { t: 'application/vnd.xara', e: ['xar'] }, + { t: 'application/vnd.xfdl', e: ['xfdl'] }, + { t: 'application/vnd.yamaha.hv-dic', e: ['hvd'] }, + { t: 'application/vnd.yamaha.hv-script', e: ['hvs'] }, + { t: 'application/vnd.yamaha.hv-voice', e: ['hvp'] }, + { t: 'application/vnd.yamaha.openscoreformat', e: ['osf'] }, + { t: 'application/vnd.yamaha.openscoreformat.osfpvg+xml', e: ['osfpvg'] }, + { t: 'application/vnd.yamaha.smaf-audio', e: ['saf'] }, + { t: 'application/vnd.yamaha.smaf-phrase', e: ['spf'] }, + { t: 'application/vnd.yellowriver-custom-menu', e: ['cmp'] }, + { t: 'application/vnd.zul', e: ['zir', 'zirz'] }, + { t: 'application/vnd.zzazz.deck+xml', e: ['zaz'] }, + { t: 'application/voicexml+xml', e: ['vxml'] }, + { t: 'application/widget', e: ['wgt'] }, + { t: 'application/winhlp', e: ['hlp'] }, + { t: 'application/wsdl+xml', e: ['wsdl'] }, + { t: 'application/wspolicy+xml', e: ['wspolicy'] }, + { t: 'application/x-7z-compressed', e: ['7z'] }, + { t: 'application/x-abiword', e: ['abw'] }, + { t: 'application/x-ace-compressed', e: ['ace'] }, + { t: 'application/x-apple-diskimage', e: ['dmg'] }, + { t: 'application/x-authorware-bin', e: ['aab', 'x32', 'u32', 'vox'] }, + { t: 'application/x-authorware-map', e: ['aam'] }, + { t: 'application/x-authorware-seg', e: ['aas'] }, + { t: 'application/x-bcpio', e: ['bcpio'] }, + { t: 'application/x-bittorrent', e: ['torrent'] }, + { t: 'application/x-blorb', e: ['blb', 'blorb'] }, + { t: 'application/x-bzip', e: ['bz'] }, + { t: 'application/x-bzip2', e: ['bz2', 'boz'] }, + { t: 'application/x-cbr', e: ['cbr', 'cba', 'cbt', 'cbz', 'cb7'] }, + { t: 'application/x-cdlink', e: ['vcd'] }, + { t: 'application/x-cfs-compressed', e: ['cfs'] }, + { t: 'application/x-chat', e: ['chat'] }, + { t: 'application/x-chess-pgn', e: ['pgn'] }, + { t: 'application/x-conference', e: ['nsc'] }, + { t: 'application/x-cpio', e: ['cpio'] }, + { t: 'application/x-csh', e: ['csh'] }, + { t: 'application/x-debian-package', e: ['deb', 'udeb'] }, + { t: 'application/x-dgc-compressed', e: ['dgc'] }, + { + t: 'application/x-director', + e: ['dir', 'dcr', 'dxr', 'cst', 'cct', 'cxt', 'w3d', 'fgd', 'swa'], + }, + { t: 'application/x-doom', e: ['wad'] }, + { t: 'application/x-dtbncx+xml', e: ['ncx'] }, + { t: 'application/x-dtbook+xml', e: ['dtb'] }, + { t: 'application/x-dtbresource+xml', e: ['res'] }, + { t: 'application/x-dvi', e: ['dvi'] }, + { t: 'application/x-envoy', e: ['evy'] }, + { t: 'application/x-eva', e: ['eva'] }, + { t: 'application/x-font-bdf', e: ['bdf'] }, + { t: 'application/x-font-ghostscript', e: ['gsf'] }, + { t: 'application/x-font-linux-psf', e: ['psf'] }, + { t: 'application/x-font-otf', e: ['otf'] }, + { t: 'application/x-font-pcf', e: ['pcf'] }, + { t: 'application/x-font-snf', e: ['snf'] }, + { t: 'application/x-font-ttf', e: ['ttf', 'ttc'] }, + { t: 'application/x-font-type1', e: ['pfa', 'pfb', 'pfm', 'afm'] }, + { t: 'application/x-freearc', e: ['arc'] }, + { t: 'application/x-futuresplash', e: ['spl'] }, + { t: 'application/x-gca-compressed', e: ['gca'] }, + { t: 'application/x-glulx', e: ['ulx'] }, + { t: 'application/x-gnumeric', e: ['gnumeric'] }, + { t: 'application/x-gramps-xml', e: ['gramps'] }, + { t: 'application/x-gtar', e: ['gtar'] }, + { t: 'application/x-hdf', e: ['hdf'] }, + { t: 'application/x-install-instructions', e: ['install'] }, + { t: 'application/x-iso9660-image', e: ['iso'] }, + { t: 'application/x-java-jnlp-file', e: ['jnlp'] }, + { t: 'application/x-latex', e: ['latex'] }, + { t: 'application/x-lzh-compressed', e: ['lzh', 'lha'] }, + { t: 'application/x-mie', e: ['mie'] }, + { t: 'application/x-mobipocket-ebook', e: ['prc', 'mobi'] }, + { t: 'application/x-ms-application', e: ['application'] }, + { t: 'application/x-ms-shortcut', e: ['lnk'] }, + { t: 'application/x-ms-wmd', e: ['wmd'] }, + { t: 'application/x-ms-wmz', e: ['wmz'] }, + { t: 'application/x-ms-xbap', e: ['xbap'] }, + { t: 'application/x-msaccess', e: ['mdb'] }, + { t: 'application/x-msbinder', e: ['obd'] }, + { t: 'application/x-mscardfile', e: ['crd'] }, + { t: 'application/x-msclip', e: ['clp'] }, + { t: 'application/x-msdownload', e: ['exe', 'dll', 'com', 'bat', 'msi'] }, + { t: 'application/x-msmediaview', e: ['mvb', 'm13', 'm14'] }, + { t: 'application/x-msmetafile', e: ['wmf', 'wmz', 'emf', 'emz'] }, + { t: 'application/x-msmoney', e: ['mny'] }, + { t: 'application/x-mspublisher', e: ['pub'] }, + { t: 'application/x-msschedule', e: ['scd'] }, + { t: 'application/x-msterminal', e: ['trm'] }, + { t: 'application/x-mswrite', e: ['wri'] }, + { t: 'application/x-netcdf', e: ['nc', 'cdf'] }, + { t: 'application/x-nzb', e: ['nzb'] }, + { t: 'application/x-pkcs12', e: ['p12', 'pfx'] }, + { t: 'application/x-pkcs7-certificates', e: ['p7b', 'spc'] }, + { t: 'application/x-pkcs7-certreqresp', e: ['p7r'] }, + { t: 'application/x-rar-compressed', e: ['rar'] }, + { t: 'application/x-research-info-systems', e: ['ris'] }, + { t: 'application/x-sh', e: ['sh'] }, + { t: 'application/x-shar', e: ['shar'] }, + { t: 'application/x-shockwave-flash', e: ['swf'] }, + { t: 'application/x-silverlight-app', e: ['xap'] }, + { t: 'application/x-sql', e: ['sql'] }, + { t: 'application/x-stuffit', e: ['sit'] }, + { t: 'application/x-stuffitx', e: ['sitx'] }, + { t: 'application/x-subrip', e: ['srt'] }, + { t: 'application/x-sv4cpio', e: ['sv4cpio'] }, + { t: 'application/x-sv4crc', e: ['sv4crc'] }, + { t: 'application/x-t3vm-image', e: ['t3'] }, + { t: 'application/x-tads', e: ['gam'] }, + { t: 'application/x-tar', e: ['tar'] }, + { t: 'application/x-tcl', e: ['tcl'] }, + { t: 'application/x-tex', e: ['tex'] }, + { t: 'application/x-tex-tfm', e: ['tfm'] }, + { t: 'application/x-texinfo', e: ['texinfo', 'texi'] }, + { t: 'application/x-tgif', e: ['obj'] }, + { t: 'application/x-ustar', e: ['ustar'] }, + { t: 'application/x-wais-source', e: ['src'] }, + { t: 'application/x-x509-ca-cert', e: ['der', 'crt'] }, + { t: 'application/x-xfig', e: ['fig'] }, + { t: 'application/x-xliff+xml', e: ['xlf'] }, + { t: 'application/x-xpinstall', e: ['xpi'] }, + { t: 'application/x-xz', e: ['xz'] }, + { t: 'application/x-zmachine', e: ['z1', 'z2', 'z3', 'z4', 'z5', 'z6', 'z7', 'z8'] }, + { t: 'application/xaml+xml', e: ['xaml'] }, + { t: 'application/xcap-diff+xml', e: ['xdf'] }, + { t: 'application/xenc+xml', e: ['xenc'] }, + { t: 'application/xhtml+xml', e: ['xhtml', 'xht'] }, + { t: 'application/xml', e: ['xml', 'xsl'] }, + { t: 'application/xml-dtd', e: ['dtd'] }, + { t: 'application/xop+xml', e: ['xop'] }, + { t: 'application/xproc+xml', e: ['xpl'] }, + { t: 'application/xslt+xml', e: ['xslt'] }, + { t: 'application/xspf+xml', e: ['xspf'] }, + { t: 'application/xv+xml', e: ['mxml', 'xhvml', 'xvml', 'xvm'] }, + { t: 'application/yang', e: ['yang'] }, + { t: 'application/yin+xml', e: ['yin'] }, + { t: 'application/zip', e: ['zip'] }, + { t: 'audio/adpcm', e: ['adp'] }, + { t: 'audio/basic', e: ['au', 'snd'] }, + { t: 'audio/midi', e: ['mid', 'midi', 'kar', 'rmi'] }, + { t: 'audio/mp4', e: ['m4a', 'mp4a'] }, + { t: 'audio/mpeg', e: ['mpga', 'mp2', 'mp2a', 'mp3', 'm2a', 'm3a'] }, + { t: 'audio/ogg', e: ['oga', 'ogg', 'spx'] }, + { t: 'audio/s3m', e: ['s3m'] }, + { t: 'audio/silk', e: ['sil'] }, + { t: 'audio/vnd.dece.audio', e: ['uva', 'uvva'] }, + { t: 'audio/vnd.digital-winds', e: ['eol'] }, + { t: 'audio/vnd.dra', e: ['dra'] }, + { t: 'audio/vnd.dts', e: ['dts'] }, + { t: 'audio/vnd.dts.hd', e: ['dtshd'] }, + { t: 'audio/vnd.lucent.voice', e: ['lvp'] }, + { t: 'audio/vnd.ms-playready.media.pya', e: ['pya'] }, + { t: 'audio/vnd.nuera.ecelp4800', e: ['ecelp4800'] }, + { t: 'audio/vnd.nuera.ecelp7470', e: ['ecelp7470'] }, + { t: 'audio/vnd.nuera.ecelp9600', e: ['ecelp9600'] }, + { t: 'audio/vnd.rip', e: ['rip'] }, + { t: 'audio/webm', e: ['weba'] }, + { t: 'audio/x-aac', e: ['aac'] }, + { t: 'audio/x-aiff', e: ['aif', 'aiff', 'aifc'] }, + { t: 'audio/x-caf', e: ['caf'] }, + { t: 'audio/x-flac', e: ['flac'] }, + { t: 'audio/x-matroska', e: ['mka'] }, + { t: 'audio/x-mpegurl', e: ['m3u'] }, + { t: 'audio/x-ms-wax', e: ['wax'] }, + { t: 'audio/x-ms-wma', e: ['wma'] }, + { t: 'audio/x-pn-realaudio', e: ['ram', 'ra'] }, + { t: 'audio/x-pn-realaudio-plugin', e: ['rmp'] }, + { t: 'audio/x-wav', e: ['wav'] }, + { t: 'audio/wav', e: ['wav'] }, + { t: 'audio/wave', e: ['wav'] }, + { t: 'audio/xm', e: ['xm'] }, + { t: 'chemical/x-cdx', e: ['cdx'] }, + { t: 'chemical/x-cif', e: ['cif'] }, + { t: 'chemical/x-cmdf', e: ['cmdf'] }, + { t: 'chemical/x-cml', e: ['cml'] }, + { t: 'chemical/x-csml', e: ['csml'] }, + { t: 'chemical/x-xyz', e: ['xyz'] }, + { t: 'image/bmp', e: ['bmp'] }, + { t: 'image/cgm', e: ['cgm'] }, + { t: 'image/g3fax', e: ['g3'] }, + { t: 'image/gif', e: ['gif'] }, + { t: 'image/ief', e: ['ief'] }, + { t: 'image/jpeg', e: ['jpeg', 'jpg', 'jpe'] }, + { t: 'image/jpg', e: ['jpeg', 'jpg', 'jpe'] }, + { t: 'image/ktx', e: ['ktx'] }, + { t: 'image/png', e: ['png'] }, + { t: 'image/prs.btif', e: ['btif'] }, + { t: 'image/sgi', e: ['sgi'] }, + { t: 'image/svg+xml', e: ['svg', 'svgz'] }, + { t: 'image/tiff', e: ['tiff', 'tif'] }, + { t: 'image/vnd.adobe.photoshop', e: ['psd'] }, + { t: 'image/vnd.dece.graphic', e: ['uvi', 'uvvi', 'uvg', 'uvvg'] }, + { t: 'image/vnd.djvu', e: ['djvu', 'djv'] }, + { t: 'image/vnd.dvb.subtitle', e: ['sub'] }, + { t: 'image/vnd.dwg', e: ['dwg'] }, + { t: 'image/vnd.dxf', e: ['dxf'] }, + { t: 'image/vnd.fastbidsheet', e: ['fbs'] }, + { t: 'image/vnd.fpx', e: ['fpx'] }, + { t: 'image/vnd.fst', e: ['fst'] }, + { t: 'image/vnd.fujixerox.edmics-mmr', e: ['mmr'] }, + { t: 'image/vnd.fujixerox.edmics-rlc', e: ['rlc'] }, + { t: 'image/vnd.ms-modi', e: ['mdi'] }, + { t: 'image/vnd.ms-photo', e: ['wdp'] }, + { t: 'image/vnd.net-fpx', e: ['npx'] }, + { t: 'image/vnd.wap.wbmp', e: ['wbmp'] }, + { t: 'image/vnd.xiff', e: ['xif'] }, + { t: 'image/webp', e: ['webp'] }, + { t: 'image/x-3ds', e: ['3ds'] }, + { t: 'image/x-cmu-raster', e: ['ras'] }, + { t: 'image/x-cmx', e: ['cmx'] }, + { t: 'image/x-freehand', e: ['fh', 'fhc', 'fh4', 'fh5', 'fh7'] }, + { t: 'image/x-icon', e: ['ico'] }, + { t: 'image/x-mrsid-image', e: ['sid'] }, + { t: 'image/x-pcx', e: ['pcx'] }, + { t: 'image/x-pict', e: ['pic', 'pct'] }, + { t: 'image/x-portable-anymap', e: ['pnm'] }, + { t: 'image/x-portable-bitmap', e: ['pbm'] }, + { t: 'image/x-portable-graymap', e: ['pgm'] }, + { t: 'image/x-portable-pixmap', e: ['ppm'] }, + { t: 'image/x-rgb', e: ['rgb'] }, + { t: 'image/x-tga', e: ['tga'] }, + { t: 'image/x-xbitmap', e: ['xbm'] }, + { t: 'image/x-xpixmap', e: ['xpm'] }, + { t: 'image/x-xwindowdump', e: ['xwd'] }, + { t: 'message/rfc822', e: ['eml', 'mime'] }, + { t: 'model/iges', e: ['igs', 'iges'] }, + { t: 'model/mesh', e: ['msh', 'mesh', 'silo'] }, + { t: 'model/vnd.collada+xml', e: ['dae'] }, + { t: 'model/vnd.dwf', e: ['dwf'] }, + { t: 'model/vnd.gdl', e: ['gdl'] }, + { t: 'model/vnd.gtw', e: ['gtw'] }, + { t: 'model/vnd.mts', e: ['mts'] }, + { t: 'model/vnd.vtu', e: ['vtu'] }, + { t: 'model/vrml', e: ['wrl', 'vrml'] }, + { t: 'model/x3d+binary', e: ['x3db', 'x3dbz'] }, + { t: 'model/x3d+vrml', e: ['x3dv', 'x3dvz'] }, + { t: 'model/x3d+xml', e: ['x3d', 'x3dz'] }, + { t: 'text/cache-manifest', e: ['appcache'] }, + { t: 'text/calendar', e: ['ics', 'ifb'] }, + { t: 'text/css', e: ['css'] }, + { t: 'text/csv', e: ['csv'] }, + { t: 'text/html', e: ['html', 'htm'] }, + { t: 'text/n3', e: ['n3'] }, + { t: 'text/plain', e: ['txt', 'text', 'conf', 'def', 'list', 'log', 'in'] }, + { t: 'text/prs.lines.tag', e: ['dsc'] }, + { t: 'text/richtext', e: ['rtx'] }, + { t: 'text/sgml', e: ['sgml', 'sgm'] }, + { t: 'text/tab-separated-values', e: ['tsv'] }, + { t: 'text/troff', e: ['t', 'tr', 'roff', 'man', 'me', 'ms'] }, + { t: 'text/turtle', e: ['ttl'] }, + { t: 'text/uri-list', e: ['uri', 'uris', 'urls'] }, + { t: 'text/vcard', e: ['vcard'] }, + { t: 'text/vnd.curl', e: ['curl'] }, + { t: 'text/vnd.curl.dcurl', e: ['dcurl'] }, + { t: 'text/vnd.curl.mcurl', e: ['mcurl'] }, + { t: 'text/vnd.curl.scurl', e: ['scurl'] }, + { t: 'text/vnd.dvb.subtitle', e: ['sub'] }, + { t: 'text/vnd.fly', e: ['fly'] }, + { t: 'text/vnd.fmi.flexstor', e: ['flx'] }, + { t: 'text/vnd.graphviz', e: ['gv'] }, + { t: 'text/vnd.in3d.3dml', e: ['3dml'] }, + { t: 'text/vnd.in3d.spot', e: ['spot'] }, + { t: 'text/vnd.sun.j2me.app-descriptor', e: ['jad'] }, + { t: 'text/vnd.wap.wml', e: ['wml'] }, + { t: 'text/vnd.wap.wmlscript', e: ['wmls'] }, + { t: 'text/x-asm', e: ['s', 'asm'] }, + { t: 'text/x-c', e: ['c', 'cc', 'cxx', 'cpp', 'h', 'hh', 'dic'] }, + { t: 'text/x-fortran', e: ['f', 'for', 'f77', 'f90'] }, + { t: 'text/x-java-source', e: ['java'] }, + { t: 'text/x-nfo', e: ['nfo'] }, + { t: 'text/x-opml', e: ['opml'] }, + { t: 'text/x-pascal', e: ['p', 'pas'] }, + { t: 'text/x-setext', e: ['etx'] }, + { t: 'text/x-sfv', e: ['sfv'] }, + { t: 'text/x-uuencode', e: ['uu'] }, + { t: 'text/x-vcalendar', e: ['vcs'] }, + { t: 'text/x-vcard', e: ['vcf'] }, + { t: 'video/3gpp', e: ['3gp'] }, + { t: 'video/3gpp2', e: ['3g2'] }, + { t: 'video/h261', e: ['h261'] }, + { t: 'video/h263', e: ['h263'] }, + { t: 'video/h264', e: ['h264'] }, + { t: 'video/jpeg', e: ['jpgv'] }, + { t: 'video/jpm', e: ['jpm', 'jpgm'] }, + { t: 'video/mj2', e: ['mj2', 'mjp2'] }, + { t: 'video/mp4', e: ['mp4', 'mp4v', 'mpg4'] }, + { t: 'video/mpeg', e: ['mpeg', 'mpg', 'mpe', 'm1v', 'm2v'] }, + { t: 'video/ogg', e: ['ogv'] }, + { t: 'video/quicktime', e: ['qt', 'mov'] }, + { t: 'video/vnd.dece.hd', e: ['uvh', 'uvvh'] }, + { t: 'video/vnd.dece.mobile', e: ['uvm', 'uvvm'] }, + { t: 'video/vnd.dece.pd', e: ['uvp', 'uvvp'] }, + { t: 'video/vnd.dece.sd', e: ['uvs', 'uvvs'] }, + { t: 'video/vnd.dece.video', e: ['uvv', 'uvvv'] }, + { t: 'video/vnd.dvb.file', e: ['dvb'] }, + { t: 'video/vnd.fvt', e: ['fvt'] }, + { t: 'video/vnd.mpegurl', e: ['mxu', 'm4u'] }, + { t: 'video/vnd.ms-playready.media.pyv', e: ['pyv'] }, + { t: 'video/vnd.uvvu.mp4', e: ['uvu', 'uvvu'] }, + { t: 'video/vnd.vivo', e: ['viv'] }, + { t: 'video/webm', e: ['webm'] }, + { t: 'video/x-f4v', e: ['f4v'] }, + { t: 'video/x-fli', e: ['fli'] }, + { t: 'video/x-flv', e: ['flv'] }, + { t: 'video/x-m4v', e: ['m4v'] }, + { t: 'video/x-matroska', e: ['mkv', 'mk3d', 'mks'] }, + { t: 'video/x-mng', e: ['mng'] }, + { t: 'video/x-ms-asf', e: ['asf', 'asx'] }, + { t: 'video/x-ms-vob', e: ['vob'] }, + { t: 'video/x-ms-wm', e: ['wm'] }, + { t: 'video/x-ms-wmv', e: ['wmv'] }, + { t: 'video/x-ms-wmx', e: ['wmx'] }, + { t: 'video/x-ms-wvx', e: ['wvx'] }, + { t: 'video/x-msvideo', e: ['avi'] }, + { t: 'video/x-sgi-movie', e: ['movie'] }, + { t: 'video/x-smv', e: ['smv'] }, + { t: 'x-conference/x-cooltalk', e: ['ice'] }, +] + +// Note: if the list above is ever updated, make sure Markdown doesn't appear +// twice. In general, put any change here, so that we know what differs from the +// original list. +mimeTypes.push({ t: 'text/markdown', e: ['md', 'markdown'] }, { t: 'image/avif', e: ['avif'] }) + +export default mimeTypes + + + +import { getRendererHandlers, updateProgress } from '@main/windows/setting' +import { version } from '@pkg' +import logger from 'electron-log' +import updater from 'electron-updater' + +import { parseReleaseNotes, sleep } from './utils' + +const { autoUpdater } = updater + +export async function autoUpdateInit() { + // 避免启动代码过多,更新检测延迟1s + await sleep(1000) + //每次启动自动更新检查 更新版本 + autoUpdater.checkForUpdates() + autoUpdater.logger = logger + autoUpdater.disableWebInstaller = false + autoUpdater.autoDownload = false //这个必须写成false,写成true时,我这会报没权限更新,也没清楚什么原因 + autoUpdater.forceDevUpdateConfig = true + autoUpdater.allowPrerelease = false + autoUpdater.on('error', (error) => { + logger.error(['检查更新失败', error]) + }) + //当有可用更新的时候触发。 更新将自动下载。 + autoUpdater.on('update-available', (info) => { + logger.info('检查到有更新,开始下载新版本') + logger.info(info) + autoUpdater.downloadUpdate() + }) + //当没有可用更新的时候触发。 + autoUpdater.on('update-not-available', (info) => { + const releaseContent = parseReleaseNotes(info.releaseNotes) + if (info.version === version) { + getRendererHandlers()?.getReleaseNotes.send(releaseContent) + } + + logger.info('没有可用更新') + }) + // 在应用程序启动时设置差分下载逻辑 + autoUpdater.on('download-progress', async (progress) => { + logger.info(progress) + updateProgress({ progress: progress.percent, status: 'downloading' }) + }) + //在更新下载完成的时候触发。 + autoUpdater.on('update-downloaded', (res) => { + logger.info('下载完毕!提示安装更新') + logger.info(res) + updateProgress({ progress: 100, status: 'installing' }) + }) +} + + + +import { dialog } from 'electron' + +export const showFileSelectionDialog = async (params: { filters: Electron.FileFilter[] }) => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: params.filters, + }) + if (result.canceled) { + return + } + return result.filePaths[0] +} + + + +import { appRoute } from './app' +import { playerRoute } from './player' +import { settingRoute } from './setting' +import { utilsRoute } from './utils' + +export const router = { + ...settingRoute, + ...appRoute, + ...playerRoute, + ...utilsRoute, +} + +export type Router = typeof router + + + +import 'fluent-ffmpeg' + +declare module 'fluent-ffmpeg' { + interface FfmpegCommand { + _inputs: { source: string }[] + } + + interface FfprobeStream { + tags: { + language: string + title: string + } + } +} + + + +import type { ElectronAPI } from '@electron-toolkit/preload' + +declare global { + interface Window { + electron: ElectronAPI + api: { + showFilePath: (file: File) => string + } + platform: string + } +} + + + +import { electronAPI } from '@electron-toolkit/preload' +import { contextBridge, webUtils } from 'electron' + +// Custom APIs for renderer +const api = { + showFilePath(file: File) { + // It's best not to expose the full file path to the web content if + // possible. + const path = webUtils.getPathForFile(file) + return path + }, +} +// Use `contextBridge` APIs to expose Electron APIs to +// renderer only if context isolation is enabled, otherwise +// just add to the DOM global. +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', api) + contextBridge.exposeInMainWorld('platform', process.platform) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore (define in dts) + window.electron = electronAPI + // @ts-ignore (define in dts) + window.api = api + // @ts-ignore (define in dts) + window.platform = process.platform +} + + + +import { getStorageNS } from '@renderer/lib/ns' +import { atomWithStorage } from 'jotai/utils' + +export const createSettingATom = ( + settingKey: string, + createDefaultSettings: () => T, +) => { + return atomWithStorage(getStorageNS(settingKey), createDefaultSettings(), undefined, { + getOnInit: true, + }) +} + + + +import { useAppSettingsValue } from './app' +import { usePlayerSettingsValue } from './player' + +export const useSettingsValue = () => { + return { + app: useAppSettingsValue(), + player: usePlayerSettingsValue(), + } +} + + + +import { createStore } from 'jotai' + +export const jotaiStore = createStore() + + + +import type { FC } from 'react' + +export const CloseIcon: FC<{ className?: string }> = ({ className }) => ( + + + +) + + + +import { cn } from '@renderer/lib/utils' +import type { FC } from 'react' + +export const CompleteIcon: FC<{ isHighLight: boolean }> = ({ isHighLight }) => ( + + + +) + + + +import { useClearPlayingVideo } from '@renderer/atoms/player' +import { RouteName } from '@renderer/router' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useLocation } from 'react-router' + +export const RootLayout: FC = ({ children }) => { + const location = useLocation() + const clearPlayingVideo = useClearPlayingVideo() + + // 确保切换路由时,能够清除播放器状态 + useEffect(() => { + if (location.pathname !== RouteName.PLAYER) { + clearPlayingVideo() + } + }, [location.pathname]) + + return
{children}
+} +
+ + +import { cn, isWeb } from '@renderer/lib/utils' +import { RouteName, useCurrentRoute } from '@renderer/router' +import type { FC, PropsWithChildren, ReactNode } from 'react' + +interface RouterLayoutProps extends PropsWithChildren { + FunctionArea?: ReactNode +} + +export const RouterLayout: FC = ({ children, FunctionArea }) => { + const currentRoute = useCurrentRoute() + if (currentRoute?.path === RouteName.PLAYER) { + return + } + return ( +
+
+

+ {currentRoute?.meta.title} +

+ {FunctionArea} +
+ {children} +
+ ) +} +
+ + +import { useModalStack } from '@renderer/components/ui/modal' +import { useCallback } from 'react' + +import { ModalTitle, SettingModal } from '.' +import { SettingProvider } from './provider' +import type { SettingTabsModel } from './tabs' +import { settingTabs } from './tabs' + +export const useSettingModal = () => { + const { present, id } = useModalStack() + return useCallback( + (params?: { settingTabsModel?: SettingTabsModel }) => { + present({ + id: 'SETTING', + title: , + overlay: true, + classNames: { + modalClassName: + 'min-w-[600px] pb-0 pr-0 w-[800px] max-w-[95vw] min-h-[580px] h-[700px] max-h-[80vh]', + }, + content: () => ( + + + + ), + }) + return id + }, + [present], + ) +} + + + +import { jotaiStore } from '@renderer/atoms/store' +import { useBeforeMounted } from '@renderer/hooks/use-before-mounted' +import { atom, useAtomValue } from 'jotai' +import type { FC, PropsWithChildren } from 'react' + +import type { SettingTabsModel } from './tabs' +import { settingTabs } from './tabs' + +const currentSettingAtom = atom(settingTabs[0]) + +export const SettingProvider: FC = (props) => { + const { children, data } = props + useBeforeMounted(() => { + jotaiStore.set(currentSettingAtom, data) + }) + + return children +} + +export const useCurrentSetting = () => { + const currentSetting = useAtomValue(currentSettingAtom) + if (currentSetting === null) { + throw new Error('current setting is null') + } + return currentSetting +} + +export const setCurrentSetting = (data: SettingTabsModel = settingTabs[0]) => { + jotaiStore.set(currentSettingAtom, data) +} + + + +import type { SliderProps } from '@radix-ui/react-slider' +import { Slider } from '@renderer/components/ui/slider' +import { cn } from '@renderer/lib/utils' +import { debounce } from 'lodash-es' +import type { FC } from 'react' +import { memo, useCallback, useState } from 'react' + +interface SettingSliderProps extends SliderProps { + onValueChangeWithDebounce?: (value: number[]) => void + classNames?: { + slider?: string + span?: string + } +} + +export const SettingSlider: FC = memo((props) => { + const [progress, setProgress] = useState(props.defaultValue) + const { classNames, className, onValueChangeWithDebounce, ...rest } = props + + const handleOnValueChangeWithDebounce = useCallback( + debounce((value: number[]) => { + onValueChangeWithDebounce?.(value) + }, 500), + [props], + ) + + return ( +
+ { + setProgress(value) + handleOnValueChangeWithDebounce(value) + }} + {...rest} + className={cn('w-[170px]', className, classNames?.slider)} + /> + {progress}s +
+ ) +}) +
+ + +export * from './Accordion' + + + +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import * as React from 'react' + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +const Alert = ({ + ref, + className, + variant, + ...props +}: React.HTMLAttributes & + VariantProps & { ref?: React.RefObject }) => ( +
+) +Alert.displayName = 'Alert' + +const AlertTitle = ({ + ref, + className, + ...props +}: React.HTMLAttributes & { ref?: React.RefObject }) => ( +
+) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = ({ + ref, + className, + ...props +}: React.HTMLAttributes & { + ref?: React.RefObject +}) =>
+AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertDescription, AlertTitle } + + + +export * from './Alert' + + + +import { AnimatePresence } from 'framer-motion' +import * as React from 'react' +import { useLocation, useOutlet } from 'react-router' + +const AnimatedOutlet = (): React.JSX.Element => { + const location = useLocation() + const element = useOutlet() + + return ( + + {element && + React.createElement(element.type, { + ...(typeof element.props === 'object' ? element.props : {}), + key: location.pathname, + })} + + ) +} + +export default AnimatedOutlet + + + +import { cn } from '@renderer/lib/utils' +import type { HTMLMotionProps, Target } from 'framer-motion' +import { m } from 'framer-motion' +import type { ForwardRefExoticComponent, PropsWithChildren, RefAttributes } from 'react' +import { memo } from 'react' + +export interface CreateTranstionParams { + from?: Target + to?: Target + exit?: Target +} + +export interface TransitionViewProps extends HTMLMotionProps<'div'> { + lcpOptimization?: boolean + as?: keyof typeof m + duration?: number +} + +export const createTransition = (params: CreateTranstionParams) => { + const { from, to, exit } = params + + const TransitionView = ({ + ref, + ...props + }: PropsWithChildren & { ref?: React.RefObject }) => { + const { as = 'div', duration = 0.2, children, className, ...rest } = props + const MotionComponent = m[as] as ForwardRefExoticComponent< + HTMLMotionProps & RefAttributes + > + const motionProps = { + initial: from, + animate: to, + exit, + transition: { + duration, + }, + } + + return ( + + {children} + + ) + } + + TransitionView.displayName = 'TransitionView' + + const MemoTransitionView = memo(TransitionView) + MemoTransitionView.displayName = 'MemoTransitionView' + return MemoTransitionView +} + + + +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import * as React from 'react' + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-cn-primary text-cn-primary-foreground hover:bg-cn-primary/80', + secondary: + 'border-transparent bg-cn-secondary text-cn-secondary-foreground hover:bg-cn-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } + + + +export * from './Badge' + + + +import { Slot } from '@radix-ui/react-slot' +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import * as React from 'react' + +const buttonVariants = cva( + 'inline-flex cursor-auto items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-cn-primary text-cn-primary-foreground hover:bg-cn-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-cn-accent hover:text-accent-foreground', + secondary: 'bg-cn-secondary text-cn-secondary-foreground hover:bg-cn-secondary/80', + ghost: 'hover:bg-cn-accent hover:text-cn-accent-foreground', + link: 'text-cn-primary underline-offset-4 hover:underline', + icon: 'hover:bg-muted hover:text-muted-foreground ', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-2.5', + lg: 'h-11 rounded-md px-8', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = ({ + ref, + className, + variant, + size, + asChild = false, + ...props +}: ButtonProps & { ref?: React.RefObject }) => { + const Comp = asChild ? Slot : 'button' + return +} +Button.displayName = 'Button' + +export { Button, buttonVariants } + + + +export * from './Button' +export * from './FunctionAreaButton' + + + +export * from './Dialog' + + + +import { cn } from '@renderer/lib/utils' +import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react' + +export const Divider: FC, HTMLHRElement>> = ( + props, +) => { + const { className, ...rest } = props + return ( +
+ ) +} + +export const DividerVertical: FC< + DetailedHTMLProps, HTMLSpanElement> +> = (props) => { + const { className, ...rest } = props + return ( + + w + + ) +} +
+ + +export * from './Divider' + + + +export * from './Input' + + + +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = ({ + ref, + className, + type, + ...props +}: InputProps & { ref?: React.RefObject }) => { + return ( + + ) +} +Input.displayName = 'Input' + +export { Input } + + + +export * from './ContextMenu' + + + +import { jotaiStore } from '@renderer/atoms/store' +import { cn } from '@renderer/lib/utils' +import { AnimatePresence } from 'framer-motion' +import type { FC, PropsWithChildren, ReactNode } from 'react' +import { useId, useMemo } from 'react' + +import { modalStackAtom } from './Context' +import { ModalInternal } from './Modal' +import type { ModalProps } from './types' + +export interface DeclarativeModalProps extends Omit { + open?: boolean + onOpenChange?: (open: boolean) => void + children?: ReactNode + + id?: string +} + +const Noop = () => null +const DeclarativeModalImpl: FC = ({ + open, + onOpenChange, + children, + ...rest +}) => { + const index = useMemo(() => jotaiStore.get(modalStackAtom).length, []) + + const id = useId() + const item = useMemo( + () => ({ + ...rest, + content: Noop, + id, + }), + [id, rest], + ) + return ( + + {open && ( + + {children} + + )} + + ) +} + +const FooterAction: FC> = ({ children, className }) => ( +
{children}
+) + +export const DeclarativeModal = Object.assign(DeclarativeModalImpl, { + FooterAction, +}) +
+ + +export * from './Context' +export * from './Modal' +export * from './Provider' +export * from './types' + + + +import { m } from 'framer-motion' +import type { FC, ForwardedRef } from 'react' + +import { RootPortal } from '../../portal' + +interface ModalOverlayProps { + zIndex?: number + ref?: ForwardedRef +} +export const ModalOverlay: FC = ({ ref, ...props }) => { + const { zIndex } = props + return ( + + + + ) +} + + + +import { jotaiStore } from '@renderer/atoms/store' +import { AnimatePresence } from 'framer-motion' +import { useAtomValue } from 'jotai' +import type { FC, PropsWithChildren } from 'react' +import { useCallback, useEffect, useId, useRef } from 'react' +import { useLocation } from 'react-router' + +import { MODAL_STACK_Z_INDEX } from './constants' +import { modalIdToPropsMap, modalStackAtom } from './Context' +import { ModalInternal } from './Modal' +import { ModalOverlay } from './Overlay' +import type { ModalProps } from './types' + +const useDismissAllWhenRouterChange = () => { + const { pathname } = useLocation() + useEffect(() => { + actions.dismissAll() + }, [pathname]) +} + +export const ModalStackProvider: FC = ({ children }) => ( + <> + + {children} + +) + +interface ModalStackOptions { + wrapper?: FC +} +export const useModalStack = (options?: ModalStackOptions) => { + const id = useId() + const currentCount = useRef(0) + const { wrapper } = options || {} + return { + present: useCallback( + (props: ModalProps & { id?: string }) => { + const fallbackModelId = `${id}-${++currentCount.current}` + const modalId = props.id ?? fallbackModelId + + const currentStack = jotaiStore.get(modalStackAtom) + + const existingModal = currentStack.find((item) => item.id === modalId) + if (existingModal) { + // Move to top + jotaiStore.set(modalStackAtom, (p) => { + const index = p.indexOf(existingModal) + return [...p.slice(0, index), ...p.slice(index + 1), existingModal] + }) + } else { + jotaiStore.set(modalStackAtom, (p) => { + const modalProps = { + ...props, + id: modalId, + wrapper, + } + modalIdToPropsMap[modalProps.id] = modalProps + return p.concat(modalProps) + }) + } + + return () => { + jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId)) + } + }, + [id], + ), + id, + + ...actions, + } +} + +const actions = { + dismiss(id: string) { + jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== id)) + }, + dismissTop() { + jotaiStore.set(modalStackAtom, (p) => p.slice(0, -1)) + }, + dismissAll() { + jotaiStore.set(modalStackAtom, []) + }, +} + +const ModalStack = () => { + const stack = useAtomValue(modalStackAtom) + + // Vite HMR issue + useDismissAllWhenRouterChange() + + const forceOverlay = stack.some((item) => item.overlay) + + return ( + + {stack.map((item, index) => ( + + ))} + {stack.length > 0 && forceOverlay && ( + + )} + + ) +} + + + +import type { FC, PropsWithChildren, ReactNode } from 'react' + +import type { ModalContentPropsInternal } from './Context' + +export interface ModalProps { + title?: ReactNode + overlay?: boolean + wrapper?: FC + CustomModalComponent?: FC + clickOutsideToDismiss?: boolean + max?: boolean + content: FC + classNames?: classNamesProps +} + +interface classNamesProps { + modalContainerClassName?: string + modalClassName?: string +} + + + +/** + * @see https://github.com/Innei/rc-modal + * @copyright Innei + */ +export * from './stacked' + + + +export * from './Popover' + + + +export * from './RootPortal' + + + +import type { FC, PropsWithChildren } from 'react' +import { createPortal } from 'react-dom' + +export const RootPortal: FC = ({ children }) => { + return createPortal(children, document.body) +} + + + +export * from './Progress' + + + +export * from './ScrollArea' + + + +export * from './Select' + + + +'use client' + +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import { X } from 'lucide-react' +import * as React from 'react' + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps { + container?: Element | DocumentFragment | null | undefined + overlay?: boolean + classNames?: { + sheetOverlay: string + } +} + +const SheetContent = ({ + ref, + side = 'right', + className, + classNames, + overlay = true, + container = document.body, + children, + ...props +}: SheetContentProps & { + ref?: React.RefObject> +}) => ( + + {overlay && } + + {children} + + + Close + + + +) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref: React.RefObject> +}) => ( + +) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetOverlay, + SheetPortal, + SheetTitle, + SheetTrigger, +} + + + +export * from './Switch' + + + +export * from './Tabs' + + + +'use client' + +import * as ToastPrimitives from '@radix-ui/react-toast' +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import { X } from 'lucide-react' +import * as React from 'react' + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + { + variants: { + variant: { + default: 'border bg-background text-foreground', + destructive: + 'destructive group border-destructive bg-destructive text-destructive-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +const Toast = ({ + ref, + className, + variant, + ...props +}: React.ComponentPropsWithoutRef & + VariantProps & { + ref?: React.RefObject> + }) => ( + +) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + Toast, + ToastAction, + type ToastActionElement, + ToastClose, + ToastDescription, + type ToastProps, + ToastProvider, + ToastTitle, + ToastViewport, +} + + + +'use client' + +import { loadingDanmuProgressAtom, LoadingStatus } from '@renderer/atoms/player' +import { cn } from '@renderer/lib/utils' +import { useAtomValue } from 'jotai' + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from './toast' +import { useToast } from './use-toast' + +export function Toaster() { + const { toasts } = useToast() + const loadingProgress = useAtomValue(loadingDanmuProgressAtom) + const videoPlaying = loadingProgress === LoadingStatus.START_PLAY + return ( + + {toasts.map(({ id, title, description, action, ...props }) => ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ))} + {/* 防止弹窗遮住视频进度条 */} + +
+ ) +} +
+ + +'use client' + +import * as TogglePrimitive from '@radix-ui/react-toggle' +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import * as React from 'react' + +const toggleVariants = cva( + 'inline-flex items-center justify-center cursor-default rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-cn-accent data-[state=on]:text-cn-accent-foreground', + { + variants: { + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent hover:bg-cn-accent hover:text-cn-accent-foreground', + }, + size: { + default: 'h-10 px-3', + sm: 'h-9 px-2.5', + lg: 'h-11 px-5', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +const Toggle = ({ + ref, + className, + variant, + size, + ...props +}: React.ComponentPropsWithoutRef & + VariantProps & { + ref?: React.RefObject> + }) => ( + +) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } + + + +import './index.css' + +import { Plugin } from '@suemor/xgplayer' + +export default class Exit extends Plugin { + static readonly pluginName = 'exit' + static readonly pluginClassName = { + icon: `xgplayer-plugin-${Exit.pluginName}-icon`, + } + + icon: HTMLElement | undefined + private toggleButtonClickListener: () => void + + constructor(args) { + super(args) + + this.icon = this.find(`.${Exit.pluginClassName.icon}`) as HTMLDivElement + + this.toggleButtonClickListener = this.toggleButtonClickFunction.bind(this) + } + + static get defaultConfig() { + return { + position: this.POSITIONS.ROOT_TOP, + } + } + + afterCreate() { + this.icon?.addEventListener('click', this.toggleButtonClickListener) + } + + private toggleButtonClickFunction() { + this.player.emit('exit') + } + + destroy(): void { + this.icon?.removeEventListener('click', this.toggleButtonClickListener) + this.icon = undefined + } + + render(): string { + return `
+
+ + 关闭 +
+
` + } +} +
+ + +import './index.css' + +import { Events, Plugin } from '@suemor/xgplayer' + +export default class NextEpisode extends Plugin { + static readonly pluginName = 'nextEpisode' + static readonly pluginClassName = { + icon: `xgplayer-plugin-${NextEpisode.pluginName}-icon`, + } + + icon: HTMLElement | undefined + private toggleButtonClickListener: () => void + + constructor(args) { + super(args) + this.icon = this.find(`.${NextEpisode.pluginClassName.icon}`) as HTMLDivElement + this.toggleButtonClickListener = this.toggleButtonClickFunction.bind(this) + } + + static get defaultConfig() { + return { + position: this.POSITIONS.CONTROLS_LEFT, + index: 1, + urlList: [], + } + } + + afterCreate() { + if (this.isLastEpisode()) { + return + } + this.icon?.addEventListener('click', this.toggleButtonClickListener) + } + + toggleButtonClickFunction() { + if (this.isLastEpisode()) { + return + } + const urlList = this.config.urlList as string[] + const nextAnimeUrl = urlList?.indexOf(this.player.config.url as string) + this.player.emit(Events.PLAYNEXT, urlList[nextAnimeUrl + 1] ?? urlList[0]) + } + + private isLastEpisode() { + const urlList = this.config.urlList as string[] + return urlList?.indexOf(this.player.config.url as string) === urlList.length - 1 + } + + destroy(): void { + this.icon?.removeEventListener('click', this.toggleButtonClickListener) + this.icon = undefined + } + + render() { + if (this.isLastEpisode()) { + return '' + } + return `
+ +
` + } +} +
+ + +import './index.css' + +import { Events, Plugin } from '@suemor/xgplayer' + +export default class PreviousEpisode extends Plugin { + static readonly pluginName = 'previousEpisode' + static readonly pluginClassName = { + icon: `xgplayer-plugin-${PreviousEpisode.pluginName}-icon`, + } + + icon: HTMLElement | undefined + private toggleButtonClickListener: () => void + + constructor(args) { + super(args) + this.icon = this.find(`.${PreviousEpisode.pluginClassName.icon}`) as HTMLDivElement + this.toggleButtonClickListener = this.toggleButtonClickFunction.bind(this) + } + + static get defaultConfig() { + return { + position: this.POSITIONS.CONTROLS_LEFT, + index: -1, + urlList: [], + } + } + + afterCreate() { + if (this.isFirstEpisode()) { + return + } + this.icon?.addEventListener('click', this.toggleButtonClickListener) + } + + private toggleButtonClickFunction() { + const urlList = this.config.urlList as string[] + const nextAnimeUrl = urlList.indexOf(this.player.config.url as string) + this.player.emit(Events.PLAYNEXT, urlList[nextAnimeUrl - 1] ?? urlList[0]) + } + + private isFirstEpisode() { + const urlList = this.config.urlList as string[] + return urlList?.indexOf(this.player.config.url as string) <= 0 + } + + destroy(): void { + this.icon?.removeEventListener('click', this.toggleButtonClickListener) + this.icon = undefined + } + + render() { + if (this.isFirstEpisode()) { + return '' + } + return `
+ +
` + } +} +
+ + +import './index.css' + +import { showPlayerSettingSheet } from '@renderer/atoms/player' +import { Plugin } from '@suemor/xgplayer' + +export default class Setting extends Plugin { + static readonly pluginName = 'setting' + static readonly pluginClassName = { + icon: `xgplayer-plugin-${Setting.pluginName}-icon`, + } + + icon: HTMLElement | undefined + private toggleButtonClickListener: () => void + + constructor(args) { + super(args) + + this.icon = this.find(`.${Setting.pluginClassName.icon}`) as HTMLDivElement + + this.toggleButtonClickListener = this.toggleButtonClickFunction.bind(this) + } + + static get defaultConfig() { + return { + position: this.POSITIONS.CONTROLS_RIGHT, + } + } + + afterCreate() { + this.icon?.addEventListener('click', this.toggleButtonClickListener) + } + + private toggleButtonClickFunction() { + showPlayerSettingSheet() + } + + destroy(): void { + this.icon?.removeEventListener('click', this.toggleButtonClickListener) + this.icon = undefined + } + + render(): string { + return `
+ +
` + } +} +
+ + +export * from './name' +export * from './ui' + + + +export const PROJECT_NAME = 'Marchen' + + + +export const ElECTRON_CUSTOM_TITLEBAR_HEIGHT = 30 +export const ELECTRON_WINDOWS_RADIUS = 12 + + + +export interface DB_Base { + id?: string +} + + + +export const LOCAL_DB_NAME = 'MARCHEN_DB' + +export enum TABLES { + HISTORY = 'history', +} + + + +import { useRef } from 'react' + +export const useBeforeMounted = (fn?: () => any) => { + const mountRef = useRef(false) + if (!mountRef.current) { + mountRef.current = true + fn?.() + } +} + + + +import { useCallback, useRef } from 'react' + +export const useEventCallback = any>(fn: T) => { + const ref = useRef(fn) + ref.current = fn + + return useCallback((...args: any[]) => ref.current(...args), []) as T +} + + + +import { useEffect, useRef } from 'react' + +export const useIsUnMounted = () => { + const unmounted = useRef(false) + useEffect(() => { + unmounted.current = false + return () => { + unmounted.current = true + } + }, []) + + return unmounted +} + + + +import { useClearPlayingVideo } from '@renderer/atoms/player' +import { useToast } from '@renderer/components/ui/toast' +import { tipcClient } from '@renderer/lib/client' +import { isWeb } from '@renderer/lib/utils' +import { useCallback } from 'react' + +export const usePlayAnimeFailedToast = () => { + const toast = useAppToast() + const reset = useClearPlayingVideo() + + const showFailedToast = useCallback( + (params: { title: string; description: string; duration?: number }) => { + const { title, description, duration } = params + reset() + toast({ + title, + description, + variant: 'destructive', + duration, + }) + }, + [toast, reset], + ) + return { + showFailedToast, + } +} + +const useAppToast = () => { + const { toast } = useToast() + + return useCallback( + (params: { + title: string + description: string + variant?: 'default' | 'destructive' + duration?: number + }) => { + const { title, description, duration, variant = 'default' } = params + if (isWeb) { + return toast({ + title, + description, + variant, + duration, + }) + } + + return tipcClient?.showWarningDialog({ title, content: description }) + }, + [toast], + ) +} + + + +import 'dayjs/locale/zh-cn' + +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' + +export const relativeTimeToNow = (date: Date | string): string => { + return dayjs().to(dayjs(date)) +} + +export const initializeDayjs = () => { + dayjs.extend(relativeTime) + dayjs.locale('zh-cn') +} + + + +import SparkMD5 from 'spark-md5' + +export const calculateFileHash = async (file: File): Promise => { + // 取前16MB + const blob = file.slice(0, 16 * 1024 * 1024) + // 使用 Blob#arrayBuffer() 读取文件内容 + const arrayBuffer = await blob.arrayBuffer() + // 使用 SparkMD5 计算 MD5 + const spark = new SparkMD5.ArrayBuffer() + spark.append(arrayBuffer) + const hash = spark.end().toLowerCase() // 转换成小写 + return hash +} + +export const calculateFileHashByBuffer = async (buffer: Buffer): Promise => { + // 使用 Buffer 的 subarray 方法取前16MB + const slice = buffer.subarray(0, 16 * 1024 * 1024) + // 使用 SparkMD5 计算 MD5 + const spark = new SparkMD5.ArrayBuffer() + spark.append(slice) + const hash = spark.end().toLowerCase() // 转换成小写 + return hash +} + + + +import { createClient, createEventHandlers } from '@egoist/tipc/renderer' +import type { Router } from '@main/tipc' +import type { RendererHandlers } from '@main/tipc/renderer-handlers' + +export const tipcClient = window.electron + ? createClient({ + ipcInvoke: window.electron.ipcRenderer.invoke, + }) + : null + +// renderer/tipc.ts +export const handlers = window.electron + ? createEventHandlers({ + on: (channel, callback) => { + if (!window.electron) return () => {} + const remover = window.electron.ipcRenderer.on(channel, callback) + return () => { + remover() + } + }, + + send: window.electron.ipcRenderer.send, + }) + : null + + + +import type { DB_Danmaku } from '@renderer/database/schemas/history' +import type { CommentModel } from '@renderer/request/models/comment' + +/** + * 将32位整数表示的颜色转换成十六进制颜色格式 + * @param color 32位整数表示的颜色 + * @returns 十六进制颜色格式字符串,例如 #ffffff + */ +export function intToHexColor(color: number | string): string { + if (typeof color === 'string' && color.startsWith('#')) { + return color + } + const _color = +color + // 提取红色分量 + const r = (_color >> 16) & 0xff + // 提取绿色分量 + const g = (_color >> 8) & 0xff + // 提取蓝色分量 + const b = _color & 0xff + + // 将每个分量转换成两位的十六进制字符串 + const rHex = r.toString(16).padStart(2, '0') + const gHex = g.toString(16).padStart(2, '0') + const bHex = b.toString(16).padStart(2, '0') + + // 拼接结果 + return `#${rHex}${gHex}${bHex}` +} + +type Mode = 'top' | 'bottom' | 'scroll' +export const DanmuPosition: Record = { + 1: 'scroll', + 4: 'bottom', + 5: 'top', +} + +const thirdpartyDanmakuMap = { + bilibili: '哔哩哔哩', + 'ani.gamer.com.tw': '巴哈姆特动漫疯', + acfun: 'AcFun', + tucao: '吐槽弹幕网', + iqiyi: '爱奇艺', + youku: '优酷', +} + +export const danmakuPlatformMap = (danmaku?: DB_Danmaku) => { + if (!danmaku) { + return '未知弹幕' + } + let mapName = '' + + switch (danmaku.type) { + case 'dandanplay': { + mapName = '弹弹play' + break + } + case 'local': { + mapName = '本地弹幕' + break + } + + case 'third-party-manual': + case 'third-party-auto': { + Object.entries(thirdpartyDanmakuMap).forEach(([key, value]) => { + danmaku.source?.includes(key) && (mapName = value) + }) + if (!mapName) { + mapName = new URL(danmaku.source || '').hostname + } + break + } + default: { + mapName = '未知弹幕' + break + } + } + + return `${mapName} (${danmaku.content.count}条)` +} + +export const mostDanmakuPlatform = (danmaku?: DB_Danmaku[]) => { + if (!danmaku || danmaku.length === 0) { + return '暂无弹幕' + } + const danmakuCount = danmaku.filter((item) => item.selected).map((item) => item.content.count) + if (danmakuCount.length === 0) { + return '暂无弹幕' + } + const maxDanmakuItem = danmaku.find((item) => item.content.count === Math.max(...danmakuCount)) + return danmakuPlatformMap(maxDanmakuItem) +} + +export const parseDanmakuData = (params: { danmuData?: CommentModel[]; duration: number }) => + params.danmuData?.map((comment) => { + const [start, postition, color] = comment.p.split(',') + const startInMs = +start * 1000 + const mode = DanmuPosition[+postition] + const danmakuColor = intToHexColor(color) + return { + duration: params.duration, // 弹幕持续显示时间,毫秒(最低为5000毫秒) + id: comment.cid, // 弹幕id,需唯一 + start: startInMs, // 弹幕出现时间,毫秒BB + txt: comment.m, // 弹幕文字内容 + mode, + style: { + color: danmakuColor, + fontWeight: 600, + textShadow: ` + rgb(0, 0, 0) 1px 0px 1px, + rgb(0, 0, 0) 0px 1px 1px, + rgb(0, 0, 0) 0px -1px 1px, + rgb(0, 0, 0) -1px 0px 1px + `, + }, + } + }) + +export const mergeDanmaku = (danmakuData: DB_Danmaku[] | undefined) => { + if (!danmakuData) { + return + } + return danmakuData + .filter((danmaku) => danmaku.selected) + .map((danmaku) => danmaku?.content) + .flatMap((danmaku) => danmaku.comments) +} + + + +import type { ReactEventHandler } from 'react' + +export const nextFrame = (fn: () => void) => requestAnimationFrame(() => requestAnimationFrame(fn)) + +export const stopPropagation: ReactEventHandler = (e) => e.stopPropagation() + + + +export const API_URL = import.meta.env.VITE_API_URL +export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN + +export const isDev = import.meta.env.DEV +export const isProd = import.meta.env.PROD + + + +/* eslint-disable no-console */ +export const appLog = (...args: any[]) => { + console.log( + `%c ${APP_NAME} %c`, + 'color: #fff; margin: 0; padding: 5px 0; background: skyblue; border-radius: 3px;', + ...args.reduce((acc, cur) => { + acc.push('', cur) + return acc + }, []), + ) +} + + + +import { QueryClient } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchIntervalInBackground: false, + refetchOnReconnect: false, + gcTime: 0, + staleTime: 0, + }, + }, +}) + +export default queryClient + + + +'use client' + +import type { FC, JSX, PropsWithChildren } from 'react' +import * as React from 'react' + +// 平铺式 provider,避免 provider 嵌套地狱 +export const ProviderComposer: FC< + { + contexts: JSX.Element[] + } & PropsWithChildren +> = ({ contexts, children }) => + contexts.reduceRight( + (kids: any, parent: any) => React.cloneElement(parent, { children: kids }), + children, + ) + + + +import type { MatchResponseV2, MatchVideoRequestModel } from '../models/match' +import { Post } from '../ofetch' + +export enum Matchkeys { + postVideoEpisodeId = 'postVideoEpisodeId', +} + +function postVideoEpisodeId(data: MatchVideoRequestModel) { + return Post('/match', data) +} + +export const match = { + postVideoEpisodeId, + Matchkeys, +} + + + +import type { relatedPlatformModel } from '../models/related' +import { Get } from '../ofetch' + +export enum relatedkeys { + getRelatedDanmakuByEpisodeId = 'getRelatedDanmakuByEpisodeId', +} + +function getRelatedDanmakuByEpisodeId(episodeId: number) { + return Get(`/related/${episodeId}`) +} + +export const related = { + getRelatedDanmakuByEpisodeId, + relatedkeys, +} + + + +import type { ReponseBaseModel } from './base' + +/** + * 视频文件匹配请求模型 + */ +export interface MatchVideoRequestModel { + /** + * 视频文件名,不包含文件夹名称和扩展名,特殊字符需进行转义。 + */ + fileName: string + + /** + * 文件前16MB (16x1024x1024 Byte) 数据的32位MD5结果,不区分大小写。 + */ + fileHash: string + + /** + * 文件总长度,单位为Byte。 + */ + fileSize: number + + /** + * 32位整数的视频时长,单位为秒。默认为0。 + */ + videoDuration?: number + + /** + * 匹配模式。 + * 可选值: 'hashAndFileName', 'fileNameOnly', 'hashOnly' + */ + matchMode?: 'hashAndFileName' | 'fileNameOnly' | 'hashOnly' +} + +// 定义 MatchResultV2 接口 +export interface MatchResultV2 { + /** + * 弹幕库ID + */ + episodeId: number + + /** + * 作品ID + */ + animeId: number + + /** + * 作品标题 + */ + animeTitle?: string + + /** + * 剧集标题 + */ + episodeTitle?: string + + /** + * 作品类别 + * 可选值: 'tvseries', 'tvspecial', 'ova', 'movie', 'musicvideo', 'web', 'other', 'jpmovie', 'jpdrama', 'unknown' + */ + type?: + | 'tvseries' + | 'tvspecial' + | 'ova' + | 'movie' + | 'musicvideo' + | 'web' + | 'other' + | 'jpmovie' + | 'jpdrama' + | 'unknown' + + /** + * 类型描述 + */ + typeDescription?: string + + /** + * 弹幕偏移时间(弹幕应延迟多少秒出现)。此数字为负数时表示弹幕应提前多少秒出现。 + */ + shift?: number +} + +// 定义 MatchResponseV2 接口 +export interface MatchResponseV2 extends ReponseBaseModel { + /** + * 是否已精确关联到某个弹幕库 + */ + isMatched: boolean + + /** + * 搜索匹配的结果 + */ + matches?: MatchResultV2[] +} + + + +interface Related { + url: string + shift: number +} + +export interface relatedPlatformModel { + relateds: Related[] + errorCode: number + success: boolean + errorMessage: string +} + + + +import type { ReponseBaseModel } from './base' + +interface EpisodeModel { + episodeId: number + episodeTitle: string +} + +interface AnimeModel { + animeId: number + animeTitle: string + type: string + typeDescription: string + episodes: EpisodeModel[] +} + +export interface SearchAnimeModel extends ReponseBaseModel { + hasMore: boolean + animes: AnimeModel[] +} + +export interface SearchAnimeRequestModel { + anime: string + episode?: string +} + + + +import { bangumi } from './api/bangumi' +import { comment } from './api/comment' +import { match } from './api/match' +import { related } from './api/related' +import { search } from './api/search' + +export const apiClient = { + match, + comment, + search, + bangumi, + related, +} + + + +export * from './name' +export * from './router' + + + +export enum RouteName { + PLAYER = '/player', + LATEST_ANIME = '/latest-anime', + HISTORY = '/history', +} + + + +import App from '@renderer/App' +import ErrorView from '@renderer/components/common/ErrorView' +import History from '@renderer/page/history' +import VideoPlayer from '@renderer/page/player' +import type { NonIndexRouteObject, RouteObject } from 'react-router' +import { createHashRouter, Navigate, useLocation } from 'react-router' + +import { RouteName } from '.' + +export interface SidebarRouteObject extends NonIndexRouteObject { + meta?: { + icon: string + title: string + } +} + +export const siderbarRoutes = [ + { + path: RouteName.PLAYER, + meta: { + icon: 'icon-[mingcute--video-camera-line]', + title: '视频播放', + }, + errorElement: , + element: , + }, + // { + // path: RouteName.LATEST_ANIME, + // meta: { + // icon: 'icon-[mingcute--lightning-line]', + // title: '最新番剧', + // }, + // element: , + // }, + { + path: RouteName.HISTORY, + meta: { + icon: 'icon-[mingcute--history-line]', + title: '播放记录', + }, + errorElement: , + element: , + }, +] satisfies SidebarRouteObject[] + +export const router = [ + { + path: '/', + element: , + errorElement: , + children: [ + { + path: '/', + element: , + }, + ...siderbarRoutes, + ], + }, +] satisfies RouteObject[] + +export const useCurrentRoute = () => { + const { pathname } = useLocation() + return siderbarRoutes.find((route) => route.path === pathname) +} + +export const reactRouter = createHashRouter(router) + + + +html { + --font-family: sans-serif, system-ui; + font-size: 16px; + overflow: hidden; +} + +body, +#root { + @apply font-theme box-border; +} + +* { + user-select: none; +} + + + +@import './tailwind.css'; +@import './shadcn.css'; +@import './font.css'; +@import './base.css'; +@import './player.css'; + + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } +} + + + +@layer components { + .drag-region { + -webkit-app-region: drag; + } + + .no-drag-region { + -webkit-app-region: no-drag; + } + + .grid-auto-cols { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } +} + + + +@tailwind base; +@tailwind components; +@tailwind utilities; + + + +import type { JSX } from 'react' + +import { RootLayout } from './components/layout/root/RootLayout' +import { Sidebar } from './components/layout/sidebar' +import { Prepare } from './components/modules/app/Prepare' +import AnimatedOutlet from './components/ui/animate/AnimatedOutlet' +import { isWeb } from './lib/utils' +import { RootProviders } from './providers' +import { TipcListener } from './providers/TipcListener' + +function App(): JSX.Element { + return ( + + + + + + + {!isWeb && } + + + ) +} + +const Content = () => ( +
+ +
+) +export default App +
+ + +/// +declare const APP_NAME: string + +interface ImportMetaEnv { + readonly VITE_API_URL: string + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + + + +import './styles/main.css' + +import * as Sentry from '@sentry/react' +import { ClickToComponent } from 'click-to-react-component' +import ReactDOM from 'react-dom/client' +import { RouterProvider } from 'react-router' + +import { initializeApp } from './initialize' +import { reactRouter } from './router' + +initializeApp() + +const root = ReactDOM.createRoot(document.querySelector('#root') as HTMLElement, { + // Callback called when an error is thrown and not caught by an Error Boundary. + onUncaughtError: Sentry.reactErrorHandler((error, errorInfo) => { + console.warn('Uncaught error', error, errorInfo.componentStack) + }), + // Callback called when React catches an error in an Error Boundary. + onCaughtError: Sentry.reactErrorHandler(), + // Callback called when React automatically recovers from errors. + onRecoverableError: Sentry.reactErrorHandler(), +}) + +root.render( + <> + + + , +) + + + + + + + + + + + + Marchen + + + +
+ + + +
+ + +shamefully-hoist=true + + + +export default { + semi: false, + singleQuote: true, + printWidth: 100, + tabWidth: 2, + trailingComma: 'all', +} + + + +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} + + + +// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856 +// cssAsPlugin.js +const postcss = require('postcss') +const postcssJs = require('postcss-js') +const { readFileSync } = require('node:fs') + +require.extensions['.css'] = function (module, filename) { + const cssAsPlugin = ({ addBase, addComponents, addUtilities }) => { + const css = readFileSync(filename, 'utf8') + const root = postcss.parse(css) + const jss = postcssJs.objectify(root) + + if ('@layer base' in jss) { + addBase(jss['@layer base']) + } + if ('@layer components' in jss) { + addComponents(jss['@layer components']) + } + if ('@layer utilities' in jss) { + addUtilities(jss['@layer utilities']) + } + } + module.exports = cssAsPlugin +} + + + +# provider: generic +# url: http://localhost:3000 +# updaterCacheDirName: marchen-player-updater +provider: github +owner: marchen-dev +repo: marchen-player + + + +// @ts-check +import { defineConfig } from 'eslint-config-hyoban' + +export default defineConfig( + { + formatting: false, + lessOpinionated: true, + preferESM: false, + }, + { + settings: { + tailwindcss: { + whitelist: ['center'], + }, + }, + rules: { + 'unicorn/prefer-math-trunc': 'off', + 'package-json/valid-name': 'off', + 'no-restricted-globals': [ + 'error', + { + name: 'location', + message: + "Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \n\n" + + 'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.', + }, + ], + }, + }, +) + + + +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + + + +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["group:allNonMajor"] +} + + + +import './cssAsPlugin' + +import { addDynamicIconSelectors } from '@iconify/tailwind' +import daisyui from 'daisyui' + +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: 'class', + content: ['./src/**/*.{ts,tsx}', './node_modules/rc-modal-sheet/**/*.js'], + prefix: '', + theme: { + container: { + center: true, + padding: '2rem', + }, + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + '3xl': '1920px', + }, + extend: { + fontFamily: { + theme: 'var(--font-family)', + default: 'sans-serif, system-ui', + logo: 'Manrope, sans-serif, system-ui', + }, + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + 'cn-primary': { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + 'cn-secondary': { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + 'cn-accent': { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [ + addDynamicIconSelectors(), + daisyui, + require('./src/renderer/src/styles/tailwind-extend.css'), + require('tailwindcss-animate'), + ], + daisyui: { + // themes: ['cmyk', 'dark'], + themes: [ + { + cmyk: { + ...require('daisyui/src/theming/themes')['cmyk'], + // primary: 'hsl(222.2 47.4% 11.2%)', + // secondary: 'hsl(210 40% 96.1%)', + }, + dark: { + ...require('daisyui/src/theming/themes')['dark'], + }, + }, + ], + }, +} + + + +{ + "files": [], + "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] +} + + + +--- +name: New feature +about: Propose a new feature +title: '[Feature] ' +labels: enhancement +assignees: suemor233 +--- + +## Description + +## Proposed Solution + + + +on: + pull_request: + push: + branches: [main] + +name: CI Typecheck, Lint, and Build + +jobs: + build: + name: Lint and Typecheck + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + lfs: true + - name: Checkout LFS objects + run: git lfs checkout + + - uses: pnpm/action-setup@v4.1.0 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + + - name: Lint and Typecheck + run: | + pnpm run typecheck + npm run lint + + - name: Build project + run: pnpm run build:web + + + +import fs from 'node:fs'; +import path from 'node:path'; + +export default async function fixLinuxPermissions(context) { + const { packager } = context; + const platform = packager.platform.nodeName; + if (platform !== 'linux') { + return; + } + + const { appOutDir } = context; + const ffprobePath = path.join( + appOutDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + '@ffprobe-installer', + 'linux-x64', + 'ffprobe' + ); + + try { + // 检查文件是否存在 + if (fs.existsSync(ffprobePath)) { + // 添加执行权限 + fs.chmodSync(ffprobePath, '755'); + console.info('Successfully added execute permission to ffprobe'); + } else { + console.warn('ffprobe not found in resources directory'); + } + } catch (error) { + console.error('Error fixing ffprobe permissions:', error); + } +} + + + +export const MARCHEN_PROTOCOL = 'marchen' + +export const MARCHEN_PROTOCOL_PREFIX = `${MARCHEN_PROTOCOL}://` + + + +import { machineIdSync } from 'node-machine-id' + +export const DEVICE_ID = machineIdSync() + + + +import path from 'node:path' + +import { registerIpcMain } from '@egoist/tipc/main' +import { app, protocol } from 'electron' +import logger from 'electron-log' + +import { createStorageFolder } from '../constants/app' +import { MARCHEN_PROTOCOL } from '../constants/protocol' +import { isDev, isWindows } from '../lib/env' +import { quickLaunchViaVideo } from '../lib/utils' +import { router } from '../tipc' +import { getMainWindow } from '../windows/main' +import { getRendererHandlers } from '../windows/setting' +import { enableHardwareDecodingOnLinux } from './flag' +import { registerLog } from './log' +import { registerAppMenu } from './menu' +import { registerSentry } from './sentry' + +export const initializeApp = () => { + limitSingleInstance() + enableHardwareDecodingOnLinux() + registerSentry() + registerIpcMain(router) + registerAppMenu() + registerLog() + app.setAsDefaultProtocolClient(MARCHEN_PROTOCOL) + protocol.registerSchemesAsPrivileged([ + { + scheme: MARCHEN_PROTOCOL, + privileges: { + bypassCSP: true, + stream: true, + standard: true, + }, + }, + ]) + if (isDev) { + app.setPath('appData', path.join(app.getPath('appData'), 'Marchen (dev)')) + } + createStorageFolder() + + // macOS 通过视频文件快捷打开 + app.on('open-file', (event, url) => { + event.preventDefault() + logger.info('[app] macOS open-file url', url) + const mainWindow = getMainWindow() + // 当主窗口已经创建时,通过 tipc 通知渲染进程打开视频文件 + if (mainWindow) { + return getRendererHandlers()?.importAnime.send({ path: url }) + } + + // 当主窗口未创建时,将视频文件路径添加到 process.argv 中, 等在主窗口创建后再处理 + process.argv.push(url) + }) + + // windows 当主窗口已经创建情况下, 通过视频文件快捷打开 + if (isWindows) { + app.on('second-instance', () => { + const mainWindow = getMainWindow() + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + } + + quickLaunchViaVideo() + }) + } +} + +const limitSingleInstance = () => { + const gotTheLock = app.requestSingleInstanceLock() + + if (!gotTheLock) { + app.quit() + return + } +} + + + +import type { MenuItem, MenuItemConstructorOptions } from 'electron' +import { Menu } from 'electron' + +import { isMacOS } from '../lib/env' +import { clearData, createSettingWindow, importAnime } from '../windows/setting' + +export const registerAppMenu = () => { + if (!isMacOS) { + return + } + const menu: Array = [ + { + label: 'Marchen', + submenu: [ + { + type: 'normal', + label: `关于 Marchen Play`, + click: () => createSettingWindow('关于'), + }, + { type: 'separator' }, + { + label: '设置...', + accelerator: 'CmdOrCtrl+,', + click: () => createSettingWindow(), + }, + { role: 'services', label: '服务' }, + { type: 'separator' }, + { role: 'hide', label: '隐藏 Marchen Play' }, + { role: 'hideOthers', label: '隐藏其他' }, + { type: 'separator' }, + { + label: '清除数据', + click: clearData, + }, + { role: 'quit', label: `退出 Marchen Play` }, + ], + }, + { + role: 'fileMenu', + submenu: [ + { + type: 'normal', + label: '导入动漫', + click: importAnime, + }, + { type: 'separator' }, + { role: 'close', label: '关闭' }, + ], + }, + { + role: 'editMenu', + }, + { + role: 'viewMenu', + }, + { + role: 'windowMenu', + }, + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(menu)) +} + + + +import { parseStringPromise } from 'xml2js' + +export const parseBilibiliDanmaku = async (params: { + fileData: string + type: '.xml' | '.json' +}) => { + const { fileData, type } = params + switch (type) { + case '.xml': { + const result = (await parseStringPromise(fileData)) as BilibiliXmlDanmakus + const danmakus = result.i.d + return danmakus?.map((item) => { + const [time, type, _, color, timestamp] = item.$.p.split(',').map(Number) + const txt = item._ + return { + cid: timestamp, + m: txt, + p: `${time},${type},${decimalToHex(color)},${timestamp}`, + } + }) + } + case '.json': { + const danmakus = JSON.parse(fileData) as BilibiliJsonDanmakuItem[] + return danmakus.map((item) => ({ + cid: item.ctime, + m: item.content, + p: `${((item?.progress ?? 0) / 1000).toFixed(1)},${item.mode},${decimalToHex(item.color)},${item.id}`, + })) + } + } +} + +type Mode = 'top' | 'bottom' | 'scroll' +export const DanmuPosition: Record = { + 1: 'scroll', + 4: 'bottom', + 5: 'top', +} + +export interface BilibliXmlDanmakuItem { + _: string + $: { + p: string + } +} + +export interface BilibiliXmlDanmakus { + i: { + chatserver: string[] + chatid: string[] + mission: string[] + maxlimit: string[] + state: string[] + real_name: string[] + source: string[] + d: BilibliXmlDanmakuItem[] + } +} + +interface BilibiliJsonDanmakuItem { + id: number // 评论的唯一标识符 + mode: number // 弹幕的显示模式(例如滚动、顶部、底部等) + progress?: number // 弹幕的显示进度 + fontsize: number // 字体大小 + color: number // 字体颜色,通常为十进制表示的 RGB 值 + midHash: string // 用户的哈希值,可能是用于匿名标识用户 + content: string // 弹幕内容 + ctime: number // 创建时间,通常是 UNIX 时间戳 + weight: number // 权重,可能影响弹幕的显示优先级 + idStr: string // 字符串形式的唯一标识符 + attr: number // 弹幕的附加属性 +} + +export const decimalToHex = (decimal?: number): string => { + if (!decimal) { + return '#FFFFFF' + } + return `#${decimal.toString(16).padStart(6, '0').toUpperCase()}` +} + + + +import mimeTypes from './mime-utils-types' + +export const fromFileExtension = (ext: string) => { + ext = ext.toLowerCase() + for (const t of mimeTypes) { + if (t.e.includes(ext)) { + return t.t + } + } + return null +} + +export const fromFilename = (name: string) => { + if (!name) return null + const splitted = name.trim().split('.') + if (splitted.length <= 1) return null + return fromFileExtension(splitted.at(-1) ?? '') +} + +export const toFileExtension = (mimeType: string) => { + mimeType = mimeType.toLowerCase() + for (const t of mimeTypes) { + if (mimeType === t.t) { + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let j = 0; j < t.e.length; j++) { + if (t.e[j].length === 3) return t.e[j] + } + return t.e[0] + } + } + return null +} + +export const fromDataUrl = (dataUrl: string) => { + const defaultMime = 'text/plain' + const p = dataUrl.slice(0, Math.max(0, dataUrl.indexOf(','))).split(';') + let s = p[0] + const result = s.split(':') + if (result.length <= 1) return defaultMime + s = result[1] + return s.includes('/') ? s : defaultMime +} + + + +import path from 'node:path' + +import { getRendererHandlers } from '@main/windows/setting' +import logger from 'electron-log' + +import FFmpeg from './ffmpeg' +import { getFilePathFromProtocolURL } from './protocols' + +export async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export const parseReleaseNotes = (releaseNotes: string | unknown[] | null | undefined) => { + let releaseContent = '' + + if (releaseNotes) { + if (typeof releaseNotes === 'string') { + releaseContent = releaseNotes + } else if (Array.isArray(releaseNotes)) { + releaseNotes.forEach((releaseNote) => { + releaseContent += `${releaseNote}\n` + }) + } + } else { + releaseContent = '暂无更新说明' + } + + return releaseContent +} + +export const isVideoFile = (filePath: string) => { + const videoExtensions = ['mp4', 'mkv'] + const ext = filePath.split('.').pop() + return videoExtensions.includes(ext!) +} + +// 通过视频文件快捷打开 +export function quickLaunchViaVideo() { + const { argv } = process + const filePath = argv.at(-1) + if (!filePath) { + return + } + if (isVideoFile(filePath)) { + logger.info('[app] windows open File', filePath) + getRendererHandlers()?.importAnime.send({ path: filePath }) + } +} + +export async function coverSubtitleToAss(targetPath: string) { + const filePath = getFilePathFromProtocolURL(targetPath) + const extName = path.extname(filePath) + if (!extName) { + return + } + if (extName === '.ass' || extName === '.ssa') { + return { + fileName: path.basename(filePath), + filePath, + } + } + const ffmepg = new FFmpeg(filePath) + const outPutPath = await ffmepg.coverToAssSubtitle() + return outPutPath +} + + + +import { tipc } from '@egoist/tipc/main' + +export const t = tipc.create() + +export type T = typeof t + + + +import { BrowserWindow, nativeTheme } from 'electron' + +import { t } from './_instance' + +export type AppTheme = 'cmyk' | 'dark' | 'system' + +export const settingRoute = { + getWindowIsFullScreen: t.procedure.action(async ({ context }) => { + const webContents = context.sender + return BrowserWindow.fromWebContents(webContents)?.isFullScreen() + }), + setTheme: t.procedure.input().action(async ({ input }) => { + if (input === 'cmyk') { + nativeTheme.themeSource = 'light' + return + } + nativeTheme.themeSource = input + }), +} + + + +import { getFilePathFromProtocolURL } from '@main/lib/protocols' +import { coverSubtitleToAss } from '@main/lib/utils' + +import { t } from './_instance' + +export const utilsRoute = { + getFilePathFromProtocolURL: t.procedure.input<{ path: string }>().action(async ({ input }) => { + return getFilePathFromProtocolURL(input.path) + }), + coverSubtitleToAss: t.procedure.input<{ path: string }>().action(async ({ input }) => { + return coverSubtitleToAss(input.path) + }), +} + + + +/// + +interface ImportMetaEnv { + readonly VITE_SENTRY_DSN: string + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + + + +import { electronApp, optimizer } from '@electron-toolkit/utils' +import { name } from '@pkg' +import { app, BrowserWindow, protocol } from 'electron' + +import { MARCHEN_PROTOCOL } from './constants/protocol' +import { initializeApp } from './initialize' +import { isDev } from './lib/env' +import { getIconPath } from './lib/icon' +import { getFilePathFromProtocolURL, handleCustomProtocol } from './lib/protocols' +import { autoUpdateInit } from './lib/update' +import createWindow from './windows/main' + +function bootstrap() { + initializeApp() + app.whenReady().then(() => { + autoUpdateInit() + electronApp.setAppUserModelId(`re.${name}`) + + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + protocol.handle(MARCHEN_PROTOCOL, async (request) => { + const filePath = getFilePathFromProtocolURL(request.url) + return handleCustomProtocol(filePath, request) + }) + + createWindow() + + if (app.dock && isDev) { + app.dock.setIcon(getIconPath()) + } + + app.on('activate', () => { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) + }) + + // Quit when all windows are closed, except on macOS. There, it's common + // for applications and their menu bar to stay active until the user quits + // explicitly with Cmd + Q. + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) +} + +bootstrap() + + + +import { useAtom, useAtomValue } from 'jotai' + +import { createSettingATom } from './helper' + +const createAppDefaultSettings = () => { + return { + showPoster: false, + launchAtLogin: false, + showUpdateNote: false, + firstOpen: true, + } +} + +export const appSettingAtom = createSettingATom('app', createAppDefaultSettings) + +export const useAppSettings = () => useAtom(appSettingAtom) +export const useAppSettingsValue = () => useAtomValue(appSettingAtom) + + + +import { atom, useSetAtom } from 'jotai' +import { atomWithReset, useResetAtom } from 'jotai/utils' + +import { jotaiStore } from './store' + +export const videoAtom = atomWithReset<{ + url: string + hash: string + size: number + name: string + playList: { urlWithPrefix: string; name: string }[] +}>({ + url: '', + hash: '', + size: 0, + name: '', + playList: [], +}) + +export enum LoadingStatus { + IMPORT_VIDEO = 0, + CALC_HASH = 1, + MATCH_ANIME = 2, + GET_DANMU = 3, + READY_PLAY = 4, + START_PLAY = 5, +} + +export const loadingDanmuProgressAtom = atomWithReset(null) + +export const initialMatchedVideo = { + episodeId: 0, + animeTitle: '', + episodeTitle: '', + animeId: 0, +} + +export const playerSettingSheetAtom = atomWithReset(false) + +export type MatchedVideoType = typeof initialMatchedVideo + +export const currentMatchedVideoAtom = atomWithReset(initialMatchedVideo) + +export const isLoadDanmakuAtom = atom((get) => get(currentMatchedVideoAtom).episodeId !== 0) + +export const isPlayingAtom = atom( + (get) => get(loadingDanmuProgressAtom) === LoadingStatus.START_PLAY, +) +export const useSetLoadingDanmuProgress = () => useSetAtom(loadingDanmuProgressAtom) + +export const useClearPlayingVideo = () => { + const resetVideo = useResetAtom(videoAtom) + const resetProgress = useResetAtom(loadingDanmuProgressAtom) + const resetCurrentMatchedVideo = useResetAtom(currentMatchedVideoAtom) + const resetPlayerSettingSheet = useResetAtom(playerSettingSheetAtom) + + return () => { + resetVideo() + resetProgress() + resetCurrentMatchedVideo() + resetPlayerSettingSheet() + } +} + +export const showPlayerSettingSheet = () => jotaiStore.set(playerSettingSheetAtom, true) + + + +import { atom } from 'jotai' + +export const updateProgressAtom = atom<{ + progress: number + status: 'downloading' | 'installing' +} | null>(null) + + + +import { atom, useAtomValue } from 'jotai' + +export enum WindowState { + MINIMIZED = 'minimized', + MAXIMIZED = 'maximized', + NORMAL = 'normal', +} + +export const windowStateAtom = atom(WindowState.NORMAL) + +export const useWindowState = () => useAtomValue(windowStateAtom) + + + +import type { JSX } from 'react' + +type RequiredParameter = T extends () => unknown ? never : T + +function Show JSX.Element>(props: { + when: T | boolean + fallback?: JSX.Element + children: JSX.Element | RequiredParameter +}): any { + const { when, fallback = null, children } = props + + if (typeof when === 'boolean') { + return when ? children : fallback + } else { + return when ? (typeof children === 'function' ? children(when) : children) : fallback + } +} + +export default Show + + + +import { useAppTheme } from '@renderer/hooks/theme' +import { cn } from '@renderer/lib/utils' +import type { FC } from 'react' + +const LogoSvg = ({ + ref, + ...props +}: React.SVGProps & { ref?: React.RefObject }) => ( + + + + + +) + +export const Logo: FC<{ clasNames?: { wrapper?: string; icon?: string }; round?: boolean }> = ( + props, +) => { + const { isDarkMode } = useAppTheme() + const wrapper = props.clasNames?.wrapper + const icon = props.clasNames?.icon + if (!props.round) { + return + } + return ( +
+ +
+ ) +} +
+ + +import { updateProgressAtom } from '@renderer/atoms/progress' +import { useAppSettingsValue } from '@renderer/atoms/settings/app' +import Show from '@renderer/components/common/Show' +import { Logo } from '@renderer/components/icons/Logo' +import { Alert, AlertDescription, AlertTitle } from '@renderer/components/ui/alert' +import { Button, ButtonWithIcon } from '@renderer/components/ui/button' +import { Progress } from '@renderer/components/ui/progress' +import { PROJECT_NAME } from '@renderer/constants' +import { useNetworkStatus } from '@renderer/hooks/use-network-status' +import { tipcClient } from '@renderer/lib/client' +import { getStorageNS } from '@renderer/lib/ns' +import { cn, isMac, isWeb } from '@renderer/lib/utils' +import type { SidebarRouteObject } from '@renderer/router' +import { RouteName, siderbarRoutes } from '@renderer/router' +import { useAtomValue } from 'jotai' +import { AlertCircle } from 'lucide-react' +import type { FC } from 'react' +import { Link, NavLink, useLocation } from 'react-router' + +import { useSettingModal } from '../../modules/settings/hooks' + +export const Sidebar = () => { + const showModal = useSettingModal() + return ( +
+
+
+ + +

+ + {PROJECT_NAME} +

+
+ + showModal()} /> +
+ +
+
+ {isWeb ? ( + + ) : ( + <> + + + + )} +
+
+ ) +} + +const NavLinkItem: FC = ({ path, meta }) => { + const { pathname } = useLocation() + if (!meta || !path) { + return null + } + const { title, icon } = meta + return ( + +

+ + {title} +

+
+ ) +} + +export const DownloadClient = () => { + return ( + + ) +} + +export const NetWorkCheck = () => { + const status = useNetworkStatus() + if (status) { + return null + } + return ( + + + 网络异常 + 请检查网络连接 + + ) +} + +export const UpdateProgress = () => { + const update = useAtomValue(updateProgressAtom) + const appSettingsValue = useAppSettingsValue() + if (!update) { + return + } + + if (update.status === 'downloading') { + return ( +
+

发现新版本,正在下载更新...

+ +
+ ) + } + + return ( +
+ +
+ ) +} +
+ + +import { isPlayingAtom } from '@renderer/atoms/player' +import { useWindowState, WindowState } from '@renderer/atoms/window' +import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, ELECTRON_WINDOWS_RADIUS } from '@renderer/constants' +import { tipcClient } from '@renderer/lib/client' +import { useAtomValue } from 'jotai' + +export const Titlebar = () => { + const isPlaying = useAtomValue(isPlayingAtom) + const windowState = useWindowState() + + // Hide titlebar when playing + if (isPlaying) { + return null + } + + return ( +
+ + + + + +
+ ) +} +
+ + +export const FFmpegPlayer = () => { + return
FFmpeg Player
+} +
+ + +import Exit from '@renderer/components/ui/xgplayer/plugins/exit' +import FullEntireScreen from '@renderer/components/ui/xgplayer/plugins/fullScreen' +import Setting from '@renderer/components/ui/xgplayer/plugins/setting' +import { isDev } from '@renderer/lib/env' +import type { IPlayerOptions } from '@suemor/xgplayer' +import { Danmu } from '@suemor/xgplayer' + +const playerBaseConfigForClient = { + height: '100%', + width: '100%', + lang: 'zh', + autoplay: true, + miniprogress: true, + closeVideoDblclick: true, + closeVideoClick: true, + [FullEntireScreen.pluginName]: { + index: 0, + }, + cssFullscreen: { + index: 1, + target: document.body, + }, + [Setting.pluginName]: { + index: 2, + }, + volume: { + index: 3, + default: isDev ? 0 : 1, + }, + rotate: { + index: 4, + }, + playbackRate: { + index: 5, + }, + keyboard: { + keyCodeMap: { + esc: { + disable: true, + }, + }, + }, + + plugins: [Danmu, Setting, FullEntireScreen, Exit], + ignores: ['fullscreen'], +} satisfies IPlayerOptions + +const playerBaseConfigForWeb = { + height: '100%', + width: '100%', + lang: 'zh', + fullscreenTarget: document.body, + autoplay: true, + miniprogress: true, + fullscreen: { + index: 0, + }, + cssFullscreen: { + index: 1, + }, + [Setting.pluginName]: { + index: 2, + }, + volume: { + index: 3, + default: 1, + }, + rotate: { + index: 4, + }, + playbackRate: { + index: 5, + }, + plugins: [Danmu, Setting, Exit], +} satisfies IPlayerOptions + +const danmakuConfig = { + fontSize: 25, + area: { + start: 0, + end: 0.25, + }, + ext: { + mouseControl: true, + mouseControlPause: true, + }, +} + +export { danmakuConfig, playerBaseConfigForClient, playerBaseConfigForWeb } + + + +import { useClearPlayingVideo, videoAtom } from '@renderer/atoms/player' +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { db } from '@renderer/database/db' +import { tipcClient } from '@renderer/lib/client' +import { isWeb } from '@renderer/lib/utils' +import * as Sentry from '@sentry/react' +import { Events } from '@suemor/xgplayer' +import { useAtomValue } from 'jotai' +import { throttle } from 'lodash-es' +import { useCallback, useEffect, useRef } from 'react' + +import { usePlayerInstance } from '../Context' +import { useVideo } from '../loading/hooks' +import type { PlayerType } from './hooks' + +export const InitializeEvent = () => { + const player = usePlayerInstance() + const { grabFrame, initializePlayerListener, initializePlayerEvent } = usePlayerInitialize(player) + + useEffect(() => { + if (!player) { + return + } + + initializePlayerEvent() + + const listenerClear = initializePlayerListener() + + return () => { + grabFrame() + if (listenerClear) { + listenerClear() + } + } + }, [initializePlayerEvent]) + return null +} + +const usePlayerInitialize = (player: PlayerType | null | undefined) => { + const clickTimeout = useRef(null) + const { hash } = useAtomValue(videoAtom) + const { importAnimeViaIPC } = useVideo() + const resetVideo = useClearPlayingVideo() + const { enableAutomaticEpisodeSwitching } = usePlayerSettingsValue() + // 需要对 xgplayer 自带的全屏事件进行重写,以适配 electron 的全屏 + const initializePlayerListener = useCallback(() => { + if (isWeb) { + return + } + const handleKeyDown = async (event: KeyboardEvent) => { + if (event.key === 'Escape') { + const isFull = await tipcClient?.getWindowIsFullScreen() + if (!isFull) { + player?.exitCssFullscreen() + } + tipcClient?.windowAction({ action: 'leave-full-screen' }) + } + } + window.addEventListener('keydown', handleKeyDown) + + const videoElement = player?.media as HTMLVideoElement + + const handleDoubleClick = () => { + if (clickTimeout.current !== null) { + window.clearTimeout(clickTimeout.current) + clickTimeout.current = null + } + tipcClient?.windowAction({ action: 'switch-full-screen' }) + } + + const handleSingleClick = () => { + if (clickTimeout.current !== null) { + window.clearTimeout(clickTimeout.current) + } + + clickTimeout.current = window.setTimeout(() => { + if (videoElement?.paused) { + videoElement.play() + } else { + videoElement.pause() + } + clickTimeout.current = null + }, 200) // 使用较短的延迟以区分单击和双击 + } + if (videoElement) { + videoElement.addEventListener('dblclick', handleDoubleClick) + videoElement.addEventListener('click', handleSingleClick) + } + + // 清理函数 + return () => { + window.removeEventListener('keydown', handleKeyDown) + if (videoElement) { + videoElement.removeEventListener('dblclick', handleDoubleClick) + videoElement.removeEventListener('click', handleSingleClick) + } + } + }, [player]) + + const initializePlayerEvent = useCallback(async () => { + if (!player) { + return + } + + // 进入窗口全屏 + player.getCssFullscreen() + + // 保存视频进度 + player?.on( + Events.TIME_UPDATE, + throttle((data) => { + if (!hash) { + return + } + db.history.update(hash, { + progress: data?.currentTime, + duration: data?.duration, + }) + }, 2000), + ) + + // 视频加载完成后,截取缩略图 + player.on(Events.LOADED_METADATA, (data) => { + if (!isWeb) { + const { duration } = data + grabFrame((duration / 2).toString()) + } + db.history.update(hash, { + duration: data?.duration, + }) + }) + + // 视频播放结束, 更新播放进度并请求下一集 + player.on(Events.ENDED, async () => { + if (!hash) { + return + } + const latestAnime = await db.history.get(hash) + await db.history.update(hash, { + progress: latestAnime?.duration, + }) + if (!enableAutomaticEpisodeSwitching || isWeb) { + return + } + const urlList = player.config.nextEpisode.urlList as string[] + const nextAnimeUrl = urlList?.indexOf(player.config.url as string) + if (nextAnimeUrl === urlList.length - 1) { + return + } + player.emit(Events.PLAYNEXT, urlList[nextAnimeUrl + 1] ?? urlList[0]) + }) + + player.on(Events.ERROR, async (error) => { + Sentry.captureException(error) + }) + + player.on(Events.PLAYNEXT, async (url: string) => { + const path = await tipcClient?.getFilePathFromProtocolURL({ path: url }) + if (!path) { + return + } + importAnimeViaIPC({ path }) + }) + // 点击左上角关闭按钮 + player.on('exit', async () => { + const isFull = await tipcClient?.getWindowIsFullScreen() + isFull && tipcClient?.windowAction({ action: 'leave-full-screen' }) + resetVideo() + // 如果是 css 全屏,需要延迟销毁,否则会导致退出动画无法正常播放 + // 全屏模式不执行动画,否者会卡顿 + if (player.cssfullscreen && !isFull) { + return setTimeout(() => { + player.destroy() + }, 300) + } + return player.destroy() + }) + }, [hash, player]) + + const grabFrame = useCallback( + async (time?: string) => { + if (!isWeb) { + const latestAnime = await db.history.get(hash) + const isEnd = latestAnime?.progress === latestAnime?.duration + if (!latestAnime) { + return + } + // 如果没有传入时间,则默认截取当前播放时间 + let grabTime = time + if (!grabTime) { + // 如果是播放结束,则截取倒数第三秒 + grabTime = isEnd + ? ((latestAnime?.progress ?? 3) - 3).toString() + : latestAnime?.progress.toString() || '0' + } + const base64Image = await tipcClient?.grabFrame({ + path: latestAnime?.path, + time: grabTime, + }) + await db.history.update(hash, { + thumbnail: base64Image, + }) + } + }, + [hash], + ) + + return { + initializePlayerListener, + initializePlayerEvent, + grabFrame, + } +} + + + +import { currentMatchedVideoAtom, isLoadDanmakuAtom, videoAtom } from '@renderer/atoms/player' +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { useToast } from '@renderer/components/ui/toast' +import NextEpisode from '@renderer/components/ui/xgplayer/plugins/nextEpisode' +import PreviousEpisode from '@renderer/components/ui/xgplayer/plugins/previousEpisode' +import { db } from '@renderer/database/db' +import { parseDanmakuData } from '@renderer/lib/danmaku' +import { isWeb } from '@renderer/lib/utils' +import type { CommentModel } from '@renderer/request/models/comment' +import type { Danmu, IPlayerOptions } from '@suemor/xgplayer' +import XgPlayer from '@suemor/xgplayer' +import { useAtomValue } from 'jotai' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useDanmakuData } from '../loading/hooks' +import { danmakuConfig, playerBaseConfigForClient, playerBaseConfigForWeb } from './config' + +export interface PlayerType extends XgPlayer { + danmu?: Danmu +} + +let _playerInstance: PlayerType | null = null + +export const useXgPlayer = (url: string) => { + const [playerInstance, setPlayerInstance] = useState(null) + const playerRef = useRef(null) + const { toast, dismiss } = useToast() + const currentMatchedVideo = useAtomValue(currentMatchedVideoAtom) + const isLoadDanmaku = useAtomValue(isLoadDanmakuAtom) + const video = useAtomValue(videoAtom) + const playerSettings = usePlayerSettingsValue() + const { danmakuDuration, danmakuFontSize, danmakuEndArea, enableMiniProgress } = playerSettings + const { setResponsiveSettingsUpdate } = useXgPlayerUtils() + const { mergedDanmakuData } = useDanmakuData() + useEffect(() => { + setResponsiveSettingsUpdate(playerInstance) + return () => { + dismiss() + } + }, [setResponsiveSettingsUpdate]) + + useEffect(() => { + const handleInitalizePlayer = async () => { + if (playerRef.current && !playerInstance) { + const anime = await db.history.get(video.hash) + const enablePositioningProgress = !!anime?.progress + let startTime = 0 + if (enablePositioningProgress) { + const playbackCompleted = anime?.progress === anime?.duration + if (playbackCompleted) { + startTime = 0 + } else { + startTime = anime?.progress || 0 + } + } + const xgplayerConfig = { + ...(isWeb ? playerBaseConfigForWeb : playerBaseConfigForClient), + miniprogress: enableMiniProgress, + el: playerRef.current, + url, + startTime, + } as IPlayerOptions + + let manualDanmaku: CommentModel[] = [] + + if (!isLoadDanmaku) { + manualDanmaku = + anime?.danmaku + ?.filter((item) => item.type === 'local' || item.type === 'third-party-manual') + .filter((danmaku) => danmaku.selected) + .map((danmaku) => danmaku?.content) + .flatMap((danmaku) => danmaku.comments) ?? [] + } + xgplayerConfig.danmu = { + ...danmakuConfig, + comments: parseDanmakuData({ + danmuData: [...(mergedDanmakuData || []), ...manualDanmaku], + duration: +danmakuDuration, + }), + fontSize: +danmakuFontSize, + area: { + start: 0, + end: +danmakuEndArea, + }, + } + + if (!isWeb) { + xgplayerConfig.plugins = [...(xgplayerConfig.plugins || []), NextEpisode, PreviousEpisode] + xgplayerConfig.nextEpisode = { + urlList: video.playList?.map((item) => item.urlWithPrefix) ?? [], + } + + xgplayerConfig.previousEpisode = { + urlList: video.playList?.map((item) => item.urlWithPrefix) ?? [], + } + } + _playerInstance = new XgPlayer(xgplayerConfig) + setPlayerInstance(_playerInstance) + if (isLoadDanmaku) { + toast({ + title: `${currentMatchedVideo.animeTitle} - ${currentMatchedVideo.episodeTitle}`, + description: ( +
+

共加载 {mergedDanmakuData?.length} 条弹幕

+
+ ), + duration: 5000, + }) + } + } + } + handleInitalizePlayer() + return () => { + _playerInstance?.destroy() + playerInstance?.destroy() + setPlayerInstance(null) + } + }, [playerRef]) + + return { playerRef, playerInstance } +} + +export const useXgPlayerUtils = () => { + const playerSettings = usePlayerSettingsValue() + + const setResponsiveSettingsUpdate = useCallback( + (playerInstance: PlayerType | null) => { + if (playerInstance?.isPlaying) { + const { danmakuDuration, danmakuEndArea, danmakuFontSize } = playerSettings + playerInstance.danmu?.setFontSize(+danmakuFontSize, 24) + playerInstance.danmu?.setAllDuration('all', +danmakuDuration) + playerInstance.danmu?.setArea({ + start: 0, + end: +danmakuEndArea, + }) + } + }, + [playerSettings], + ) + + return { + setResponsiveSettingsUpdate, + } +} +
+ + +import { isWeb } from '@renderer/lib/utils' +import { useEffect, useRef } from 'react' + +import { usePlayerInstance } from '../Context' +import { useSubtitle } from '../setting/items/subtitle/hooks' + +export const InitializeSubtitle = () => { + const { initializeSubtitle, isFetching } = useSubtitle() + const player = usePlayerInstance() + const onceRef = useRef(false) + useEffect(() => { + if (isWeb) { + return + } + if (!player || isFetching || onceRef.current) { + return + } + + onceRef.current = true + initializeSubtitle() + }, [player, isFetching]) + return null +} + + + +import { jotaiStore } from '@renderer/atoms/store' +import { apiClient } from '@renderer/request' +import { useQuery } from '@tanstack/react-query' +import { atomWithReset } from 'jotai/utils' +import { debounce } from 'lodash-es' +import type { ChangeEvent } from 'react' +import { useCallback, useState } from 'react' + +export const showMatchAnimeDialogAtom = atomWithReset<{ open: boolean; hash?: string }>({ + open: false, +}) + +export const showMatchAnimeDialog = (open: boolean, hash?: string) => + jotaiStore.set(showMatchAnimeDialogAtom, { open, hash }) + +export const useSearchAnime = () => { + const [searchText, setSearchText] = useState('') + const { data } = useQuery({ + queryKey: [apiClient.search.Searchkeys, searchText], + queryFn: () => apiClient.search.getSearchEpisodes({ anime: searchText }), + enabled: searchText.length > 1, + }) + const handleSearchAnime = useCallback( + // eslint-disable-next-line react-compiler/react-compiler + debounce((event: ChangeEvent) => { + setSearchText(event.target.value) + }, 400), + [], + ) + + return { handleSearchAnime, searchData: data } +} + + + +import type { MatchedVideoType } from '@renderer/atoms/player' +import Show from '@renderer/components/common/Show' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@renderer/components/ui/accordion' +import { Button } from '@renderer/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog' +import { Input } from '@renderer/components/ui/input' +import { ScrollArea } from '@renderer/components/ui/scrollArea' +import { useToast } from '@renderer/components/ui/toast' +import { cn } from '@renderer/lib/utils' +import type { MatchResponseV2, MatchResultV2 } from '@renderer/request/models/match' +import { useAtomValue } from 'jotai' +import type { FC } from 'react' +import { useEffect, useMemo } from 'react' + +import { showMatchAnimeDialog, showMatchAnimeDialogAtom, useSearchAnime } from './hooks' + +interface MatchAnimeDialogProps { + matchData?: MatchResponseV2 + onSelected?: (params?: MatchedVideoType) => void + onClosed?: () => void + isLoading?: boolean +} + +export const MatchAnimeDialog: FC = (props) => { + const { handleSearchAnime, searchData } = useSearchAnime() + const { matchData, onSelected, onClosed, isLoading } = props + const { toast } = useToast() + const { open } = useAtomValue(showMatchAnimeDialogAtom) + + useEffect(() => { + if (!isLoading) { + return + } + if (matchData && !matchData.isMatched) { + showMatchAnimeDialog(true) + } + }, [matchData]) + + const accordionData = useMemo(() => { + if (searchData) { + return searchData.animes.map((anime) => { + return anime.episodes.map((episode) => ({ + animeId: anime.animeId, + animeTitle: anime.animeTitle, + episodeId: episode.episodeId, + episodeTitle: episode.episodeTitle, + })) as MatchResultV2[] + }) + } + + if (!matchData?.matches) return [] + + const groupMatches = matchData.matches.reduce( + (acc, match) => { + if (!acc[match.animeId]) { + acc[match.animeId] = [] + } + + acc[match.animeId].push(match) + return acc + }, + {} as Record, + ) + return Object.values(groupMatches) + }, [matchData, searchData]) + return ( + showMatchAnimeDialog(open)}> + event.preventDefault()} + onInteractOutside={(e) => { + e.preventDefault() + }} + aria-describedby="请手动选择弹幕库" + > + + 请手动选择弹幕库 + +
+ + + + {accordionData?.length === 0 && ( +

+ 暂无匹配的弹幕库 +

+ )} + {accordionData?.map((match) => { + const { animeId, animeTitle } = match[0] + + return ( + + {animeTitle} + +
    + {match.map((item) => ( +
  • { + onSelected && + onSelected({ + episodeId: item.episodeId, + animeId: item.animeId, + animeTitle: item.animeTitle || '', + episodeTitle: item.episodeTitle || '', + }) + showMatchAnimeDialog(false) + toast({ + title: '已选择弹幕库', + description: `已选择 ${animeTitle} ${item.episodeTitle}`, + }) + }} + className="hover:text-info" + > + {item.episodeTitle} +
  • + ))} +
+
+
+ ) + })} +
+
+ +
+ +
+
+
+
+
+ ) +} +
+ + +import { MARCHEN_PROTOCOL_PREFIX } from '@main/constants/protocol' +import { + currentMatchedVideoAtom, + isLoadDanmakuAtom, + loadingDanmuProgressAtom, + LoadingStatus, + useClearPlayingVideo, + videoAtom, +} from '@renderer/atoms/player' +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { db } from '@renderer/database/db' +import type { DB_Danmaku, DB_History } from '@renderer/database/schemas/history' +import { usePlayAnimeFailedToast } from '@renderer/hooks/use-toast' +import { calculateFileHash } from '@renderer/lib/calc-file-hash' +import { chineseConverter } from '@renderer/lib/cht-to-chs' +import { tipcClient } from '@renderer/lib/client' +import { checkIsVideoType, isWeb } from '@renderer/lib/utils' +import { apiClient } from '@renderer/request' +import type { CommentsModel } from '@renderer/request/models/comment' +import type { MatchResponseV2 } from '@renderer/request/models/match' +import { RouteName } from '@renderer/router' +import type { UseQueryResult } from '@tanstack/react-query' +import { useQueries, useQuery } from '@tanstack/react-query' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import type { ChangeEvent, DragEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useLocation, useNavigate } from 'react-router' + +export const useVideo = () => { + const [video, setVideo] = useAtom(videoAtom) + const setProgress = useSetAtom(loadingDanmuProgressAtom) + const { showFailedToast } = usePlayAnimeFailedToast() + const clearPlayingVideo = useClearPlayingVideo() + + // 对于浏览器环境,当通过拖拽或者点击导入视频时,会触发该函数 + // 对于 electron 环境,通过拖拽导入时,会触发该函数 + const importAnimeViaDragging = async ( + e: DragEvent | ChangeEvent, + ) => { + e.preventDefault() + clearPlayingVideo() + setProgress(LoadingStatus.IMPORT_VIDEO) + let file: File | undefined + if (e.type === 'drop') { + const dragEvent = e as DragEvent + file = dragEvent.dataTransfer?.files[0] + } else if (e.type === 'change') { + const changeEvent = e as ChangeEvent + file = changeEvent.target?.files?.[0] + } + + if (!file || !checkIsVideoType(file.name)) { + return showFailedToast({ title: '格式错误', description: '请导入 mp4 或者 mkv 格式的动漫' }) + } + + let url = '' + let playList: { + urlWithPrefix: string + name: string + }[] = [] + + if (isWeb) { + url = URL.createObjectURL(file) + } else { + const path = window.api.showFilePath(file) + playList = (await tipcClient?.getAnimeInSamePath({ path })) ?? [] + url = `${MARCHEN_PROTOCOL_PREFIX}${path}` + tipcClient?.addRecentDocument({ path }) + } + const { size, name: fileName } = file + try { + const hash = await calculateFileHash(file) + setVideo((prev) => ({ ...prev, url, hash, size, name: fileName, playList })) + setProgress(LoadingStatus.CALC_HASH) + } catch (error) { + console.error('Failed to calculate file hash:', error) + showFailedToast({ title: '播放失败', description: '计算视频 hash 值出现异常,请重试' }) + } + } + + // 只在 electron 环境生效,点击导入视频时,会触发该函数 + const importAnimeViaIPC = useCallback(async (params?: { path?: string }) => { + clearPlayingVideo() + const path = params?.path ?? (await tipcClient?.importAnime()) + if (!path) { + return + } + const playList = (await tipcClient?.getAnimeInSamePath({ path })) ?? [] + const animeData = await tipcClient?.getAnimeDetailByPath({ path }) + if (!animeData?.ok) { + showFailedToast({ title: '播放失败', description: animeData?.message || '' }) + return + } + const { fileHash, fileName, fileSize, filePath } = animeData + if (!fileHash || !fileHash || !fileSize) { + showFailedToast({ title: '播放失败', description: '无法读取视频' }) + return + } + setVideo((prev) => ({ + ...prev, + url: filePath, + hash: fileHash, + size: fileSize, + name: fileName, + playList, + })) + tipcClient?.addRecentDocument({ path: filePath }) + setProgress(LoadingStatus.CALC_HASH) + }, []) + return { + importAnimeViaDragging, + importAnimeViaIPC, + video, + } +} + +// 匹配动漫 +export const useMatchAnimeData = () => { + const { hash, size, name, url } = useAtomValue(videoAtom) + const clearPlayingVideo = useClearPlayingVideo() + const location = useLocation() + const { showFailedToast } = usePlayAnimeFailedToast() + const setCurrentMatchedVideo = useSetAtom(currentMatchedVideoAtom) + const setLoadingProgress = useSetAtom(loadingDanmuProgressAtom) + + // 先直接通过 hash 去匹配,如果匹配失败,则弹出匹配框,让用户选择 + const { data: matchData, isError } = useQuery({ + queryKey: [apiClient.match.Matchkeys.postVideoEpisodeId, hash], + queryFn: async () => { + const historyData = await db.history.get(hash) + // 如果历史记录中有匹配的数据,直接返回 + if (historyData?.episodeId && historyData?.animeId) { + const { episodeId, episodeTitle, animeId, animeTitle } = historyData + return { + isMatched: true, + matches: [{ episodeTitle, episodeId, animeId, animeTitle }], + } satisfies MatchResponseV2 + } + return apiClient.match.postVideoEpisodeId({ fileSize: size, fileHash: hash, fileName: name }) + }, + enabled: !!hash, + }) + + const matchedVideo = useMemo(() => { + // 确保为精准匹配 + if (!matchData || !matchData.matches || !matchData.isMatched) { + return null + } + const matchedVideo = matchData?.matches[0] + return { + episodeId: matchedVideo.episodeId, + animeTitle: matchedVideo.animeTitle || '', + animeId: matchedVideo.animeId, + episodeTitle: matchedVideo.episodeTitle || '', + } + }, [matchData]) + + useEffect(() => { + // 如果精准匹配,就去设置 setCurrentMatchedVideo(matchedVideo),之后就会触发 useDanmuData() 里的 useQuery 和 useEffect + if (matchData && matchedVideo) { + setCurrentMatchedVideo(matchedVideo) + setLoadingProgress(LoadingStatus.MATCH_ANIME) + } + }, [matchData]) + + useEffect(() => { + if (isError) { + showFailedToast({ title: '匹配失败', description: '请检查网络连接或稍后再试' }) + clearPlayingVideo() + } + }, [location.pathname, isError]) + + return { matchData, url, clearPlayingVideo } +} + +export const useDanmakuData = () => { + const isLoadDanmaku = useAtomValue(isLoadDanmakuAtom) + const video = useAtomValue(videoAtom) + const { enableTraditionalToSimplified } = usePlayerSettingsValue() + const [currentMatchedVideo] = useAtom(currentMatchedVideoAtom) + const { episodeId } = currentMatchedVideo + const { data: thirdPartyDanmakuUrlData } = useQuery({ + queryKey: [apiClient.related.relatedkeys.getRelatedDanmakuByEpisodeId, episodeId], + queryFn: async () => { + const history = await db.history.get(video.hash) + // 如果历史记录中有弹幕库,就返回历史记录中的弹幕库 + if (history?.danmaku?.length) { + const historyDanmaku = history?.danmaku + ?.filter((item) => item.type === 'third-party-auto') + .map((item) => ({ url: item.source, shift: 0 })) + return historyDanmaku + } + const getRelatedDanmakuByEpisodeId = + await apiClient.related.getRelatedDanmakuByEpisodeId(episodeId) + return getRelatedDanmakuByEpisodeId.relateds + }, + enabled: isLoadDanmaku && !!episodeId, + }) + const onlyLoadDandanplayDanmaku = !thirdPartyDanmakuUrlData?.length + // setCurrentMatchedVideo() 之后会触发该 useQuery, 获取弹幕数据 + // 目前共两种可能性会触发该 useQuery + // 1. 上方 useMatchAnimeData() 为精准匹配 + // 2. 用户通过对话框, 手动匹配了弹幕库 + // 获取弹幕数据后,会触发下发 useEffect + const danmakuData = useQueries({ + queries: [ + ...(thirdPartyDanmakuUrlData?.map((related) => ({ + queryKey: [apiClient.comment.Commentkeys.getExtcomment, episodeId, related.url], + queryFn: async () => { + const history = await db.history.get(video.hash) + const historyDanmaku = history?.danmaku?.find((item) => item.source === related.url) + + const handleIsSelected = () => { + // bilibili 弹幕库感觉有重复的弹幕,目前只默认加载一个 bilibili 弹幕库 + if (related.url.includes('bilibili')) { + return ( + related.url === + thirdPartyDanmakuUrlData?.find((item) => item.url.includes('bilibili'))?.url + ) + } + return true + } + // 使用弹幕缓存 + if (historyDanmaku && !history?.newBangumi) { + return { + ...historyDanmaku?.content, + selected: historyDanmaku?.selected, + } + } + try { + const fetchData = await apiClient.comment.getExtcomment({ url: related.url }) + if (enableTraditionalToSimplified && related.url.includes('ani.gamer')) { + fetchData.comments.forEach((comment) => { + comment.m = chineseConverter.convert(comment.m) + }) + } + + return { + ...fetchData, + selected: handleIsSelected(), + } + } catch { + return null + } + + // 当开启繁体转简体时,且为动漫疯弹幕库时,将弹幕转为简体 + }, + enabled: !!episodeId, + refetchOnMount: false, + })) ?? []), + { + queryKey: [apiClient.comment.Commentkeys.getDanmu, episodeId], + queryFn: async () => { + const history = await db.history.get(video.hash) + const historyDanmaku = history?.danmaku?.find((item) => item.source === 'dandanplay') + if (historyDanmaku && !history?.newBangumi) { + return { + ...historyDanmaku.content, + selected: historyDanmaku.selected, + } + } + const fetchData = await apiClient.comment.getDanmu(+currentMatchedVideo.episodeId, { + chConvert: enableTraditionalToSimplified ? 1 : 0, + }) + return { + ...fetchData, + selected: true, + } + }, + enabled: !!episodeId, + refetchOnMount: false, + }, + { + queryKey: ['manual-danmaku', episodeId], + queryFn: async () => { + const history = await db.history.get(video.hash) + const historyDanmaku = history?.danmaku?.filter( + (item) => item.type === 'local' || item.type === 'third-party-manual', + ) + return historyDanmaku ?? [] + }, + enabled: !!episodeId, + refetchOnMount: false, + }, + ], + combine: (results) => { + const manualResult = results.at(-1)?.data as DB_Danmaku[] + const dandanplayResult = results.at(-2)?.data as CommentsModel & { selected: boolean } + const thirdPartyResult = results.slice(0, -2) as UseQueryResult< + CommentsModel & { selected: boolean } + >[] + const dandanplayDanmakuData = { + type: 'dandanplay', + source: 'dandanplay', + content: dandanplayResult, + selected: dandanplayResult?.selected, + } satisfies DB_Danmaku + const thirdPartyDanmakuData = thirdPartyResult.map((result, index) => ({ + type: 'third-party-auto', + content: result.data, + source: thirdPartyDanmakuUrlData?.[index].url, + selected: result.data?.selected, + })) as DB_Danmaku[] + + // 只加载官方弹幕库,返回弹幕数据 + if (onlyLoadDandanplayDanmaku && dandanplayDanmakuData.content) { + return [dandanplayDanmakuData, ...manualResult] + } + + const dandanplaySettled = dandanplayResult !== undefined + + const allThirdPartyQueriesSettled = thirdPartyResult.every( + (result) => result.data !== undefined, + ) + + // 官方弹幕库和第三方弹幕库都加载完成后,返回所有可用弹幕数据 + if ( + !onlyLoadDandanplayDanmaku && + dandanplaySettled && + allThirdPartyQueriesSettled && + thirdPartyResult.length === thirdPartyDanmakuUrlData.length + ) { + // 只包含成功获取到数据的弹幕 + const availableDanmaku: DB_Danmaku[] = [] + if (dandanplayDanmakuData.content) { + availableDanmaku.push(dandanplayDanmakuData) + } + const successfulThirdPartyData = thirdPartyDanmakuData.filter( + (item) => item.content !== undefined && item.content !== null, + ) + availableDanmaku.push(...successfulThirdPartyData, ...manualResult) + return availableDanmaku + } + + // // 未匹配弹幕库,只加载用户手动导入弹幕 + // if (!currentMatchedVideo.episodeId && manualResult?.length > 0) { + // return manualResult + // } + return + }, + }) + const mergedDanmakuData = useMemo(() => { + if (!danmakuData) { + return + } + return danmakuData + .filter((danmaku) => danmaku.selected) + .map((danmaku) => danmaku?.content) + .flatMap((danmaku) => danmaku.comments) + }, [danmakuData]) + + return { + danmakuData, + mergedDanmakuData, + } +} + +export const saveToHistory = async ( + params: Omit, +) => { + const { animeId, hash } = params + const existingAnime = await db.history.where({ hash }).first() + const historyData = { + ...params, + updatedAt: new Date().toISOString(), + } + if (!existingAnime) { + if (!animeId) { + return db.history.add({ + ...historyData, + progress: 0, + duration: 0, + }) + } + + const primaryKey = await db.history.add({ + ...historyData, + progress: 0, + duration: 0, + }) + const updateBangumiData = async () => { + const [bangumiDetail, bangumiShin] = await Promise.all([ + apiClient.bangumi.getBangumiDetailById(animeId), + apiClient.bangumi.getBangumiShin(), + ]) + + Object.assign(historyData, { + cover: bangumiDetail.bangumi.imageUrl, + newBangumi: bangumiShin.bangumiList.some((item) => item.animeId === +animeId), + }) + return db.history.update(primaryKey, historyData) + } + + // 减少加载时长,先插入数据库,直接播放动漫,之后再获取动漫详情 + updateBangumiData() + return + } + + return db.history.update(existingAnime.hash, historyData) +} + +export const useLoadingHistoricalAnime = () => { + const clearPlayingVideo = useClearPlayingVideo() + const setVideo = useSetAtom(videoAtom) + const location = useLocation() + const navigate = useNavigate() + const setProgress = useSetAtom(loadingDanmuProgressAtom) + const effectOnce = useRef(false) + const { showFailedToast } = usePlayAnimeFailedToast() + const episodeId = location.state?.episodeId + const hash = location.state?.hash + + const handleDeleteHistory = useCallback(async (hash: string) => { + try { + db.history.delete(hash) + } catch (error) { + console.error('Failed to delete history:', error) + } + }, []) + + const loadingAnime = useCallback(async () => { + clearPlayingVideo() + const anime = await db.history.get({ hash }) + if (!anime || Array.isArray(anime)) { + showFailedToast({ title: '播放失败', description: '未找到历史记录' }) + return + } + + const animeData = await tipcClient?.getAnimeDetailByPath({ path: anime.path }) + if (!animeData?.ok) { + showFailedToast({ title: '播放失败', description: animeData?.message || '未找到历史记录' }) + return + } + const { fileName, fileSize, fileHash } = animeData + if (!fileHash || !fileName || !fileSize) { + showFailedToast({ title: '播放失败', description: '未找到历史记录' }) + handleDeleteHistory(anime.hash) + return + } + + const playList = (await tipcClient?.getAnimeInSamePath({ path: anime.path })) ?? [] + setVideo({ + hash: fileHash, + name: fileName, + size: fileSize, + url: anime.path, + playList, + }) + tipcClient?.addRecentDocument({ path: anime.path }) + setProgress(LoadingStatus.CALC_HASH) + }, [episodeId, hash]) + + useEffect(() => { + if (!effectOnce.current) { + effectOnce.current = true + navigate(location.pathname, { replace: true }) + if (hash && location.pathname === RouteName.PLAYER) { + loadingAnime() + } + } + }, [loadingAnime]) + + useEffect(() => { + if (hash && location.pathname === RouteName.PLAYER) { + setProgress(LoadingStatus.IMPORT_VIDEO) + } + }, [hash]) +} + + + +import { + currentMatchedVideoAtom, + loadingDanmuProgressAtom, + LoadingStatus, + videoAtom, +} from '@renderer/atoms/player' +import { MatchAnimeDialog } from '@renderer/components/modules/core/html5-player/loading/dialog/MatchAnimeDialog' +import { LoadingDanmuTimeLine } from '@renderer/components/modules/core/html5-player/loading/Timeline' +import queryClient from '@renderer/lib/query-client' +import { apiClient } from '@renderer/request' +import { useAtom, useAtomValue } from 'jotai' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' + +import { + saveToHistory, + useDanmakuData, + useLoadingHistoricalAnime, + useMatchAnimeData, +} from './hooks' + +export const VideoProvider: FC = ({ children }) => { + useLoadingHistoricalAnime() + const { clearPlayingVideo, matchData } = useMatchAnimeData() + const { url, hash, name } = useAtomValue(videoAtom) + const { danmakuData } = useDanmakuData() + const [currentMatchedVideo, setCurrentMatchedVideo] = useAtom(currentMatchedVideoAtom) + const [loadingProgress, setLoadingProgress] = useAtom(loadingDanmuProgressAtom) + + // 当上方 useQuery 获取弹幕成功后,会触发下方 useEffect, 保存到历史记录并开始播放 + useEffect(() => { + let timeoutId: NodeJS.Timeout + const handleSaveToHistory = async () => { + if (danmakuData && currentMatchedVideo && hash) { + await saveToHistory({ + ...currentMatchedVideo, + hash, + danmaku: danmakuData, + path: url, + }) + setLoadingProgress(LoadingStatus.READY_PLAY) + timeoutId = setTimeout(() => { + setLoadingProgress(LoadingStatus.START_PLAY) + }, 100) + } + } + handleSaveToHistory() + return () => { + clearTimeout(timeoutId) + } + }, [danmakuData, currentMatchedVideo]) + + if (loadingProgress !== null && loadingProgress < LoadingStatus.START_PLAY) { + return ( + <> + {/* 加载进度条 */} + + {/* 如果在 useMatchAnimeData() 里面没有匹配弹幕库成功, 就会弹出下方对话框,让用户手动匹配*/} + { + // 如果用户选择不加载弹幕 + if (!params) { + // 保存到历史记录 + await saveToHistory({ + hash, + path: url, + animeTitle: name, + }) + // 如果用户选择不加载弹幕, 就直接开始播放 + return setLoadingProgress(LoadingStatus.START_PLAY) + } + // 如果用户手动选择了弹幕库,就使用用户选择的弹幕库,之后就会触发 useDanmuData() 里的 useQuery 和 useEffect + setCurrentMatchedVideo(params) + + // 因为用户手动选择了弹幕库,所以需要更新 queryClient 的数据, 确保下次加载的时候不会再次弹出对话框 + queryClient.setQueryData([apiClient.match.Matchkeys.postVideoEpisodeId, hash], { + isMatched: true, + matches: [{ ...params }], + }) + }} + onClosed={clearPlayingVideo} + isLoading + /> + + ) + } + return children +} + + + +import type { LoadingStatus } from '@renderer/atoms/player' +import { loadingDanmuProgressAtom } from '@renderer/atoms/player' +import { CompleteIcon } from '@renderer/components/icons/CompleteIcon' +import { cn } from '@renderer/lib/utils' +import { useAtomValue } from 'jotai' +import type { FC } from 'react' + +const itemsTitle = ['视频导入', '计算哈希', '匹配动漫', '获取弹幕', '准备播放'] +export const LoadingDanmuTimeLine = () => { + const loadingProgress = useAtomValue(loadingDanmuProgressAtom) + return ( +
    + {itemsTitle.map((item, index) => ( + + ))} +
+ ) +} + +interface TimelineProps { + title: string + index: number + start: boolean + end: boolean + progress: LoadingStatus | null +} + +const TimelineItem: FC = (props) => { + const { title, index, start, end, progress } = props + const isHighLight = index <= (progress || 0) + return ( +
  • + {!start &&
    } +
    + +
    +
    + {title} +
    + {!end &&
    } +
  • + ) +} +
    + + +export const Audio = () => { + return
    Audio
    +} +
    + + +import { videoAtom } from '@renderer/atoms/player' +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { Button } from '@renderer/components/ui/button' +import { Input } from '@renderer/components/ui/input' +import { Label } from '@renderer/components/ui/label' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import type { DB_Danmaku, DB_History } from '@renderer/database/schemas/history' +import { tipcClient } from '@renderer/lib/client' +import { mergeDanmaku, parseDanmakuData } from '@renderer/lib/danmaku' +import queryClient from '@renderer/lib/query-client' +import { isWeb } from '@renderer/lib/utils' +import { apiClient } from '@renderer/request' +import type { CommentModel } from '@renderer/request/models/comment' +import { useMutation } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import type { FormEvent } from 'react' +import { useCallback, useRef } from 'react' +import { z } from 'zod' + +import { usePlayerInstance } from '../../../Context' +import { useXgPlayerUtils } from '../../../initialize/hooks' +import { SettingProviderQueryKey, useSettingConfig } from '../../Sheet' + +export const AddDanmaku = () => { + const inputRef = useRef(null) + const { hash } = useAtomValue(videoAtom) + const { toast } = useToast() + const { danmaku } = useSettingConfig() + const { danmakuDuration } = usePlayerSettingsValue() + const player = usePlayerInstance() + const { setResponsiveSettingsUpdate } = useXgPlayerUtils() + const { mutate, isPending } = useMutation({ + mutationFn: async (url: string) => { + const matchedDanmaku = await apiClient.comment.getExtcomment({ url }) + if (!matchedDanmaku?.count) { + toast({ title: '没有找到弹幕' }) + return + } + if (!player) { + return + } + addDanmakuToPlayer(matchedDanmaku.comments) + + const _damaku = [ + ...(danmaku ?? []), + { type: 'third-party-manual', selected: true, source: url, content: matchedDanmaku }, + ] satisfies DB_Danmaku[] + + queryClient.setQueryData([SettingProviderQueryKey, hash], (oldData: DB_History) => ({ + ...oldData, + danmaku: _damaku, + })) + + await db.history.update(hash, { + danmaku: _damaku, + }) + toast({ title: '添加成功' }) + }, + onError: (error) => { + toast({ title: error.message }) + }, + }) + const handleOnSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault() + const inputValue = inputRef.current?.value + if (!inputValue) { + toast({ title: '请输入第三方网址' }) + return + } + + const isEmail = z.string().url().safeParse(inputValue) + if (!isEmail.success) { + toast({ title: '请输入正确的网址' }) + return + } + const existingSource = danmaku?.some((item) => item.source === inputValue) + if (existingSource) { + toast({ title: '已经添加过该来源' }) + clearInput() + return + } + mutate(inputValue) + clearInput() + }, + [danmaku, mutate], + ) + + const clearInput = useCallback(() => { + if (inputRef.current) { + inputRef.current.value = '' + } + }, []) + + const addDanmakuToPlayer = useCallback( + (danmuData?: CommentModel[]) => { + if (!player) { + return + } + const mergedDanmakuData = mergeDanmaku(danmaku) + + const parsedDanmaku = parseDanmakuData({ + danmuData: [...(mergedDanmakuData ?? []), ...(danmuData ?? [])], + duration: +danmakuDuration, + }) + player.danmu?.clear() + + player.danmu?.updateComments(parsedDanmaku, true) + setResponsiveSettingsUpdate(player) + }, + [danmaku, danmakuDuration, player, setResponsiveSettingsUpdate], + ) + + const handleImportDanmakuFile = useCallback(async () => { + const danmakuFile = await tipcClient?.immportDanmakuFile() + const danmakuFileData = danmakuFile?.data + if (!danmakuFile?.ok || !danmakuFileData?.danmaku) { + danmakuFile?.message && toast({ title: danmakuFile?.message }) + return + } + if (!player) { + return + } + const isExisting = danmaku?.some((item) => item.source === danmakuFileData?.source) + if (isExisting) { + toast({ title: '已经添加过该来源' }) + return + } + + addDanmakuToPlayer(danmakuFileData.danmaku) + + const _damaku = [ + ...(danmaku ?? []), + { + type: 'local', + selected: true, + source: danmakuFileData?.source ?? '', + content: { + count: danmakuFileData?.danmaku.length, + comments: danmakuFileData?.danmaku, + }, + }, + ] satisfies DB_Danmaku[] + + queryClient.setQueryData([SettingProviderQueryKey, hash], (oldData: DB_History) => ({ + ...oldData, + danmaku: _damaku, + })) + + await db.history.update(hash, { + danmaku: _damaku, + }) + + toast({ + title: `导入成功`, + }) + }, [danmaku, danmakuDuration, hash, player, setResponsiveSettingsUpdate, toast]) + return ( +
    +
    + + +
    + {!isWeb && ( +
    + + +
    + )} +
    + ) +} +
    + + +import { DanmakuSetting } from '@renderer/components/modules/settings/views/player/DanmakuSetting' +import { memo } from 'react' + +import { AddDanmaku } from './AddDanmaku' +import { DanmakuSource } from './DanmakuSource' + +export const Danmaku = memo(() => { + return ( + <> + + + + + + ) +}) + + + +import type { CheckedState } from '@radix-ui/react-checkbox' +import { Separator } from '@radix-ui/react-select' +import { playerSettingSheetAtom, videoAtom } from '@renderer/atoms/player' +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { jotaiStore } from '@renderer/atoms/store' +import { FieldLayout } from '@renderer/components/modules/settings/views/Layout' +import { Badge } from '@renderer/components/ui/badge' +import { Button } from '@renderer/components/ui/button' +import { Checkbox } from '@renderer/components/ui/checkbox' +import { Label } from '@renderer/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover' +import { db } from '@renderer/database/db' +import type { DB_History } from '@renderer/database/schemas/history' +import { useConfirmationDialog } from '@renderer/hooks/use-dialog' +import { + danmakuPlatformMap, + mergeDanmaku, + mostDanmakuPlatform, + parseDanmakuData, +} from '@renderer/lib/danmaku' +import queryClient from '@renderer/lib/query-client' +import { isWeb } from '@renderer/lib/utils' +import { useAtomValue } from 'jotai' +import { debounce } from 'lodash-es' +import type { FC, PropsWithChildren } from 'react' +import { memo } from 'react' + +import { usePlayerInstance } from '../../../Context' +import { useXgPlayerUtils } from '../../../initialize/hooks' +import { showMatchAnimeDialog } from '../../../loading/dialog/hooks' +import { useVideo } from '../../../loading/hooks' +import { SettingProviderQueryKey, useSettingConfig } from '../../Sheet' + +export const DanmakuSource = memo(() => { + const { danmaku } = useSettingConfig() + return ( + + + + + + + + +
    + + +
    +
    + +
    +
    +
    + ) +}) + +const SourceList = memo(() => { + const { danmakuDuration } = usePlayerSettingsValue() + const { danmaku } = useSettingConfig() + const video = useAtomValue(videoAtom) + const player = usePlayerInstance() + const { setResponsiveSettingsUpdate } = useXgPlayerUtils() + const handleCheckDanmaku = debounce((params: { checked: CheckedState; source: string }) => { + const { checked, source } = params + if (checked === 'indeterminate') { + return + } + queryClient.setQueryData([SettingProviderQueryKey, video.hash], (oldSetting: DB_History) => { + const newSetting = oldSetting + const { danmaku } = newSetting + danmaku?.forEach((item) => { + if (item.source === source) { + item.selected = checked + } + }) + if (!danmaku) { + return + } + const mergedDanmakuData = mergeDanmaku(danmaku) + + const parsedDanmaku = parseDanmakuData({ + danmuData: mergedDanmakuData, + duration: +danmakuDuration, + }) + + if (!player) { + return + } + player.danmu?.clear() + + player.danmu?.updateComments(parsedDanmaku, true) + setResponsiveSettingsUpdate(player) + + db.history.update(video.hash, { + danmaku, + }) + + return { + ...oldSetting, + newSetting, + } + }) + }, 300) + if (!danmaku) { + return

    暂无弹幕

    + } + return danmaku?.map((item) => { + const danmakuPlatform = danmakuPlatformMap(item) + return ( +
    + handleCheckDanmaku({ checked, source: item.source })} + /> + +
    + ) + }) +}) + +interface PopoverContentLayoutProps extends PropsWithChildren { + title: string +} +export const PopoverContentLayout: FC = ({ children, title }) => { + return ( +
    +

    {title}

    +
    {children}
    +
    + ) +} + +const RematchDanmaku = () => { + const video = useAtomValue(videoAtom) + const player = usePlayerInstance() + return ( + + ) +} + +const ClearDanmakuCache = () => { + const video = useAtomValue(videoAtom) + const { importAnimeViaIPC } = useVideo() + const present = useConfirmationDialog() + if (isWeb) { + return null + } + return ( + + ) +} +
    + + +import { currentMatchedVideoAtom, playerSettingSheetAtom } from '@renderer/atoms/player' +import { jotaiStore } from '@renderer/atoms/store' +import { cn, isWeb } from '@renderer/lib/utils' +import { useAtomValue } from 'jotai' + +import { useVideo } from '../../../loading/hooks' + +const PlayList = () => { + const { animeTitle, episodeTitle } = useAtomValue(currentMatchedVideoAtom) + const { importAnimeViaIPC, video } = useVideo() + + return ( +
      + {isWeb ? ( +
    • + {video?.name} +
    • + ) : ( + video?.playList?.map(({ name, urlWithPrefix }) => { + const playingVideo = name === video.name + const getTitle = () => { + if (playingVideo && animeTitle && episodeTitle) { + return `${animeTitle}-${episodeTitle}` + } + return name + } + return ( +
    • { + if (playingVideo) { + return + } + jotaiStore.set(playerSettingSheetAtom, false) + importAnimeViaIPC({ path: urlWithPrefix }) + }} + > + {getTitle()} +
    • + ) + }) + )} +
    + ) +} + +export default PlayList +
    + + +import SubtitlesOctopus from '@jellyfin/libass-wasm' +import workerUrl from '@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.js?url' +import legacyWorkerUrl from '@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker-legacy.js?url' +import { MARCHEN_PROTOCOL_PREFIX } from '@main/constants/protocol' +import { videoAtom } from '@renderer/atoms/player' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import { tipcClient } from '@renderer/lib/client' +import { isWeb } from '@renderer/lib/utils' +import NotoSansSC from '@renderer/styles/fonts/notoSansSC-medium.woff2?url' +import { useQuery } from '@tanstack/react-query' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useCallback } from 'react' + +import { usePlayerInstance, useSubtitleInstance } from '../../../Context' + +const setIsLoadingEmbeddedSubtitleAtom = atom(false) + +export const useSubtitle = () => { + const { hash, url } = useAtomValue(videoAtom) + const player = usePlayerInstance() + const [subtitlesInstance, setSubtitlesInstance] = useSubtitleInstance() + const setVideo = useSetAtom(videoAtom) + const { toast } = useToast() + const [isLoadingEmbeddedSubtitle, setIsLoadingEmbeddedSubtitle] = useAtom( + setIsLoadingEmbeddedSubtitleAtom, + ) + const { data, isFetching } = useQuery({ + queryKey: ['getAllSubtitlesFromAnime', url], + queryFn: async () => { + const subtitleDetails = await tipcClient?.getSubtitlesIntroFromAnime({ path: url }) + const anime = await db.history.get(hash) + const defaultId = + anime?.subtitles?.defaultId ?? + subtitleDetails?.find( + (subtitle) => subtitle.tags.language === 'zho' || subtitle.tags.language === 'chi', + )?.index ?? + subtitleDetails?.[0]?.index + + // 合并内嵌字幕列表和手动导入字幕列表 + const tags = [ + ...(subtitleDetails?.map((subtitle, index) => ({ + id: subtitle.index, + index, + title: subtitle.tags.title || `未知字幕 - ${index}`, + language: subtitle.tags.language, + })) ?? []), + ...(anime?.subtitles?.tags.filter((tag) => tag.id < -1) ?? []), + ] + return { + tags, + defaultId: defaultId ?? -1, + } + }, + enabled: !!url, + }) + const setSubtitlesOctopus = useCallback( + async (path?: string) => { + if (!player || !path) { + return + } + const completePath = isWeb ? path : `${MARCHEN_PROTOCOL_PREFIX}${path}` + const history = await db.history.get(hash) + if (!subtitlesInstance) { + setSubtitlesInstance( + new SubtitlesOctopus({ + fonts: [NotoSansSC], + fallbackFont: NotoSansSC, + video: player?.media as HTMLVideoElement, + subUrl: completePath, + timeOffset: history?.subtitles?.timeOffset ?? 0, + workerUrl, + legacyWorkerUrl, + }), + ) + return + } + subtitlesInstance?.freeTrack() + subtitlesInstance?.setTrackByUrl(completePath) + }, + [player?.media, setVideo, subtitlesInstance], + ) + + const fetchSubtitleBody = useCallback( + async (params: FetchSubtitleBodyParams) => { + try { + const { id, path, fileName } = params + + // Web 端直接设置字幕路径, 不进行 indexdb 记录 + if (isWeb) { + return setSubtitlesOctopus(path) + } + + const oldHistory = await db.history.get(hash) + + // 手动导入字幕 + if (path || id === undefined) { + let minimumId = oldHistory?.subtitles?.tags.slice().sort((tag1, tag2) => { + return tag1.id - tag2.id + })[0].id + if (minimumId === undefined || minimumId >= -1) { + minimumId = -1 + } + const splitedFileName = fileName.split('.') + const baseTitle = `外部字幕 - ${Math.abs(minimumId)}` + + // 通过文件名获取字幕标题 ex: 动漫名称.scjp.ass + // scjp + const title = + splitedFileName.length >= 3 ? (splitedFileName.at(-2) ?? baseTitle) : baseTitle + + db.history.update(hash, { + subtitles: { + timeOffset: oldHistory?.subtitles?.timeOffset ?? 0, + defaultId: minimumId - 1, + tags: [...(oldHistory?.subtitles?.tags ?? []), { id: minimumId - 1, path, title }], + }, + }) + return setSubtitlesOctopus(path) + } + + const index = data?.tags?.findIndex((subtitle) => subtitle.id === id) ?? -1 + + // 禁用字幕 + if (index === -1) { + subtitlesInstance?.freeTrack() + db.history.update(hash, { + subtitles: { + defaultId: id, + tags: oldHistory?.subtitles?.tags ?? [], + }, + }) + return + } + const existingSubtitle = ( + await db.history.where('hash').equals(hash).first() + )?.subtitles?.tags.find((tag) => tag.id === id) + + // indexdb 已经存在字幕路径 + if (existingSubtitle) { + db.history.update(hash, { + subtitles: { + timeOffset: oldHistory?.subtitles?.timeOffset ?? 0, + defaultId: id, + tags: oldHistory?.subtitles?.tags ?? [], + }, + }) + return setSubtitlesOctopus(existingSubtitle.path) + } + + setIsLoadingEmbeddedSubtitle(true) + // 通过 ipc 获取被选中的动漫内嵌字幕 + const subtitleData = await tipcClient?.getSubtitlesBody({ + path: url, + index, + }) + const subtitlePath = subtitleData?.data + if (!subtitlePath || !data?.tags?.[index]) { + const message = subtitleData?.message ?? '视频内嵌字幕加载失败' + toast({ + title: message + }) + throw new Error(message) + } + + const newTags = [ + ...(oldHistory?.subtitles?.tags ?? []), + { + ...data.tags[index], + path: subtitlePath, + }, + ] + + db.history.update(hash, { + subtitles: { + defaultId: id, + tags: newTags, + }, + }) + await setSubtitlesOctopus(subtitlePath) + } finally { + setIsLoadingEmbeddedSubtitle(false) + } + }, + [data?.tags, url, hash, setSubtitlesOctopus, setIsLoadingEmbeddedSubtitle], + ) + + const initializeSubtitle = useCallback(async () => { + try { + // 优先使用默认字幕 + if (data?.defaultId !== undefined && data?.defaultId !== -1) { + return await fetchSubtitleBody({ id: data.defaultId }) + } + + // 读取文件夹下的字幕文件 + const localSubtitles = await tipcClient?.matchSubtitleFile({ path: url }) + if (!localSubtitles || localSubtitles.length === 0) { + return + } + + const covertedSubtitle = await tipcClient?.coverSubtitleToAss({ + path: localSubtitles[0]?.filePath, + }) + if (!covertedSubtitle) { + return + } + const { fileName, filePath } = covertedSubtitle + await fetchSubtitleBody({ path: filePath, fileName }) + } catch (error) { + console.error(error) + } + }, [data, fetchSubtitleBody, url]) + + return { + subtitlesData: data, + fetchSubtitleBody, + setSubtitlesOctopus, + initializeSubtitle, + subtitlesInstance, + isFetching, + isLoadingEmbeddedSubtitle, + } +} + +type FetchSubtitleBodyParams = ParamsWithId | ParamsWithPath + +type ParamsWithId = { + id: number + path?: undefined + fileName?: undefined +} + +type ParamsWithPath = { + id?: undefined + path: string + fileName: string +} + + + +import { FieldLayout } from '@renderer/components/modules/settings/views/Layout' + +import { SettingContainer } from '../../Container' +import { SubtitleImport } from './SubtitleImport' +import { SubtitleTimeOff } from './SubtitleTimeOff' + +export const Subtitle = () => { + return ( + + + + + + + + + + ) +} + + + +import { playerSettingSheetAtom, videoAtom } from '@renderer/atoms/player' +import { jotaiStore } from '@renderer/atoms/store' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import { tipcClient } from '@renderer/lib/client' +import { isWeb } from '@renderer/lib/utils' +import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import type { ChangeEvent } from 'react' +import { useEffect, useRef } from 'react' + +import { usePlayerInstance } from '../../../Context' +import { useSubtitle } from './hooks' + +export const SubtitleImport = () => { + const player = usePlayerInstance() + const { subtitlesData, fetchSubtitleBody, isLoadingEmbeddedSubtitle } = useSubtitle() + const { toast } = useToast() + const { hash } = useAtomValue(videoAtom) + const fileInputRef = useRef(null) + const { data: defaultValue, isFetching } = useQuery({ + queryKey: ['subtitlesDefaultValue', hash, isLoadingEmbeddedSubtitle], + queryFn: async () => { + const history = await db.history.get(hash) + return history?.subtitles?.defaultId?.toString() ?? '-1' + }, + staleTime: 0, + }) + + useEffect(() => { + if (!isWeb) { + return + } + // 确保不会误触视频暂停事件 + player?.setConfig({ closeVideoClick: true }) + return () => { + player?.setConfig({ closeVideoClick: false }) + } + }, [player]) + + const importSubtitleFromBrowser = async (e: ChangeEvent) => { + const changeEvent = e as unknown as ChangeEvent + const file = changeEvent.target?.files?.[0] + + if (!file) { + return + } + const url = URL.createObjectURL(file) + try { + await fetchSubtitleBody({ path: url, fileName: file.name }) + toast({ + title: '导入字幕成功', + duration: 1500, + }) + jotaiStore.set(playerSettingSheetAtom, false) + } catch { + toast({ + title: '导入字幕失败', + duration: 1500, + }) + } + } + + const importSubtitleFromClient = async () => { + const subtitlePath = await tipcClient?.importSubtitle() + if (!subtitlePath) { + return + } + try { + await fetchSubtitleBody({ path: subtitlePath.filePath, fileName: subtitlePath.fileName }) + toast({ + title: '导入字幕成功', + duration: 1500, + }) + jotaiStore.set(playerSettingSheetAtom, false) + } catch { + toast({ + title: '导入字幕失败', + duration: 1500, + }) + } + } + + if (!defaultValue || isFetching) { + return + } + + return ( + <> + + {isWeb && ( + + )} + + ) +} + + + +import { videoAtom } from '@renderer/atoms/player' +import { SettingSlider } from '@renderer/components/modules/shared/setting/SettingSlider' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import { useAtomValue } from 'jotai' +import { memo } from 'react' + +import { useSubtitleInstance } from '../../../Context' +import { useSettingConfig } from '../../Sheet' + +export const SubtitleTimeOff = memo(() => { + const [subtitleInstance] = useSubtitleInstance() + const setting = useSettingConfig() + const { toast } = useToast() + const { hash } = useAtomValue(videoAtom) + return ( + { + const timeOffset = value[0] ?? 0 + if (!subtitleInstance) { + toast({ + title: '设置失败,字幕未加载', + }) + return + } + // @ts-ignore + subtitleInstance.timeOffset = timeOffset + + const history = await db.history.get(hash) + if (!history?.subtitles) { + return + } + db.history.update(hash, { + subtitles: { + ...history.subtitles, + timeOffset, + }, + }) + }} + /> + ) +}) + + + +import type { FC, PropsWithChildren } from 'react' + +export const SettingContainer: FC = ({ children }) => { + return
    {children}
    +} +
    + + +import { playerSettingSheetAtom, videoAtom } from '@renderer/atoms/player' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@renderer/components/ui/accordion' +import { ScrollArea } from '@renderer/components/ui/scrollArea' +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@renderer/components/ui/sheet' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import type { DB_History } from '@renderer/database/schemas/history' +import { useQuery } from '@tanstack/react-query' +import { useAtom, useAtomValue } from 'jotai' +import { createContext, lazy, use, useEffect } from 'react' + +import { MatchDanmakuDialog } from '../../../shared/MatchDanmakuDialog' +import { Danmaku } from './items/damaku/Danmaku' +import { Subtitle } from './items/subtitle/Subtitle' + +export const SettingSheet = () => { + const [show, setShow] = useAtom(playerSettingSheetAtom) + return ( + <> + { + setShow(open) + }} + > + + + + 设置 + + + {settingSheetList.map((item) => ( + + {item.title} + + + + + ))} + + + + + + + + + ) +} + +const settingSheetList = [ + { + title: '播放列表', + value: 'playList', + component: lazy(() => import('./items/playList/PlayList')), + }, + { + title: '弹幕设置', + value: 'danmaku', + component: Danmaku, + }, + { + title: '字幕设置', + value: 'subtitle', + component: Subtitle, + }, + // { + // title: '音频设置', + // value: 'audio', + // component: Audio, + // }, +] + +const SettingContext = createContext(null) +export const SettingProviderQueryKey = 'SettingProvider' +export const SettingProvider: React.FC = ({ children }) => { + const { hash } = useAtomValue(videoAtom) + const toast = useToast() + + const { data } = useQuery({ + queryKey: [SettingProviderQueryKey, hash], + queryFn: () => db.history.get(hash), + }) + useEffect(() => { + // 确保 toast 不会遮住设置 setting + toast.dismiss() + }, []) + if (!data) { + return + } + return {children} +} + +export const useSettingConfig = () => { + const context = use(SettingContext) + if (!context) { + throw new Error('useSettingConfig must be used within a SettingProvider') + } + return context +} + + + +import type SubtitlesOctopus from '@jellyfin/libass-wasm' +import type { FC, PropsWithChildren } from 'react' +import { createContext, use, useMemo, useState } from 'react' + +import type { PlayerType } from './initialize/hooks' + +interface PlayerContextProps { + subtitlesInstance: [ + SubtitlesOctopus | null, + React.Dispatch>, + ] + playerInstance?: PlayerType | null +} + +const PlayerContext = createContext(null) + +export const PlayerProvider: FC> = ({ + value, + children, +}) => { + const subtitlesInstance = useState(null) + + const contextValue = useMemo(() => { + return { playerInstance: value, subtitlesInstance } + }, [value, subtitlesInstance]) + + return {children} +} + +export const usePlayerInstance = () => { + const context = use(PlayerContext) + if (!context) { + throw new Error('usePlayerInstance must be used within a PlayerProvider') + } + return context.playerInstance +} + +export const useSubtitleInstance = () => { + const context = use(PlayerContext) + if (!context) { + throw new Error('useSubtitleInstance must be used within a PlayerProvider') + } + return context.subtitlesInstance +} + + + +import { isWeb } from '@renderer/lib/utils' +import { m } from 'framer-motion' +import type { FC } from 'react' + +import { PlayerProvider } from './Context' +import { InitializeEvent } from './initialize/Event' +import { useXgPlayer } from './initialize/hooks' +import { InitializeSubtitle } from './initialize/Subtitle' +import { SettingSheet } from './setting/Sheet' + +interface PlayerProps { + url: string +} + +export const HTML5Player: FC = (props) => { + const { playerRef, playerInstance } = useXgPlayer(props.url) + return ( + <> + + + + {!isWeb && } + + + + ) +} + + + +import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' +import type { AppTheme } from '@renderer/hooks/theme' +import { useAppTheme } from '@renderer/hooks/theme' + +export const DarkModeToggle = () => { + const { toggleMode, theme } = useAppTheme() + return ( +
    + toggleMode(value as AppTheme)} + > + + {themes.map((item) => ( + + {item.icon} + {item.name} + + ))} + + +
    + ) +} + +const themes = [ + { + name: '系统', + value: 'system', + icon: , + }, + { + name: '白天', + value: 'cmyk', + icon: , + }, + { + name: '夜间', + value: 'dark', + icon: , + }, +] +
    + + +import { usePlayerSettings } from '@renderer/atoms/settings/player' +import { SettingSelect } from '@renderer/components/modules/shared/setting/SettingSelect' +import { SettingSwitch } from '@renderer/components/modules/shared/setting/SettingSwitch' +import type { FC, PropsWithChildren } from 'react' +import { useMemo } from 'react' + +import { FieldLayout, FieldsCardLayout } from '../Layout' +import { danmakuDurationList, danmakuEndAreaList, danmakuFontSizeList } from './list' + +interface DanmakuSettingProps extends PropsWithChildren { + classNames?: { cardLayout?: string } + onTraditionalToSimplifiedChange?: (value: boolean) => void +} + +export const DanmakuSetting: FC = (props) => { + const { classNames, children, onTraditionalToSimplifiedChange } = props + const [playerSetting, setPlayerSetting] = usePlayerSettings() + const isPlaying = !!classNames?.cardLayout + const CardLayout = useMemo(() => { + return ({ children }) => + isPlaying ? ( +
    {children}
    + ) : ( + {children} + ) + }, [classNames?.cardLayout, isPlaying]) + + return ( + + {!isPlaying && ( + + { + setPlayerSetting((prev) => ({ ...prev, enableTraditionalToSimplified: value })) + onTraditionalToSimplifiedChange?.(value) + }} + /> + + )} + + + setPlayerSetting((prev) => ({ ...prev, danmakuFontSize: value })) + } + /> + + + + setPlayerSetting((prev) => ({ ...prev, danmakuDuration: value })) + } + /> + + + + setPlayerSetting((prev) => ({ ...prev, danmakuEndArea: value })) + } + /> + + {children} + + ) +} +
    + + +import { SettingViewContainer } from '../Layout' +import { DanmakuSetting } from './DanmakuSetting' +import { PlayerSetting } from './PlayerSetting' + +export const PlayerView = () => { + return ( + + + + + ) +} + + + +import { usePlayerSettings } from '@renderer/atoms/settings/player' +import { SettingSelect } from '@renderer/components/modules/shared/setting/SettingSelect' +import { SettingSwitch } from '@renderer/components/modules/shared/setting/SettingSwitch' +import { isWeb } from '@renderer/lib/utils' + +import { FieldLayout, FieldsCardLayout } from '../Layout' +import { PlayerKernelList } from './list' + +export const PlayerSetting = () => { + const [playerSetting, setPlayerSetting] = usePlayerSettings() + + return ( + + {!isWeb && ( + + + setPlayerSetting((prev) => ({ ...prev, playerKernel: value })) + } + /> + + )} + {!isWeb && ( + + { + setPlayerSetting((prev) => ({ ...prev, enableAutomaticEpisodeSwitching: value })) + }} + /> + + )} + + + { + setPlayerSetting((prev) => ({ ...prev, enableMiniProgress: value })) + }} + /> + + + ) +} + + + +import { DividerVertical } from '@renderer/components/ui/divider' +import { ScrollArea } from '@renderer/components/ui/scrollArea' +import { cn } from '@renderer/lib/utils' +import type { FC } from 'react' + +import { setCurrentSetting, useCurrentSetting } from './provider' +import type { SettingTabsModel } from './tabs' +import { settingTabs } from './tabs' + +export const SettingModal = () => { + const { component } = useCurrentSetting() + return ( +
    +
    +
      + {settingTabs.map((tab) => ( + + ))} +
    +
    + +
    + {component} +
    +
    + ) +} + +export const SettingTabItem: FC = (props) => { + const { icon, title } = props + const { title: currentTitle } = useCurrentSetting() + return ( +
  • setCurrentSetting(props)} + > + + {title} +
  • + ) +} + +export const ModalTitle = () => { + return ( +

    + + 设置 +

    + ) +} +
    + + +import type { ReactNode } from 'react' + +import { AboutView } from './views/about/About' +import { GeneralView } from './views/general/General' +import { PlayerView } from './views/player' + +export const settingTabs = [ + { + title: '通用', + icon: 'icon-[mingcute--settings-3-line]', + component: , + }, + { + title: '播放器', + icon: 'icon-[mingcute--play-circle-line]', + component: , + }, + { + title: '关于', + icon: 'icon-[mingcute--information-line]', + component: , + }, +] + +export interface SettingTabsModel { + title: string + icon: string + component: ReactNode +} + + + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select' +import type { FC } from 'react' + +export interface SelectGroup { + label: string + value: string + default?: boolean +} + +interface SettingSelectProps { + placeholder?: string + groups: SelectGroup[] + value: string + onValueChange: (value: string) => void +} + +export const SettingSelect: FC = (props) => { + const { placeholder, groups, value, onValueChange } = props + + return ( + + ) +} + + + +import { Switch } from '@renderer/components/ui/switch' +import type { FC } from 'react' + +interface SettingSwitchProps { + onCheckedChange: (value: boolean) => void + value: boolean +} + +export const SettingSwitch: FC = (props) => { + const { onCheckedChange, value } = props + return +} + + + +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { cn } from '@renderer/lib/utils' +import { ChevronDown } from 'lucide-react' +import * as React from 'react' + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => +AccordionItem.displayName = 'AccordionItem' + +const AccordionTrigger = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + +) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +
    {children}
    +
    +) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } +
    + + +import { createTransition } from './CreateTranstion' + +const FadeTransitionView = createTransition({ + from: { opacity: 0 }, + to: { opacity: 1 }, +}) +export default FadeTransitionView + + + +import type * as TogglePrimitive from '@radix-ui/react-toggle' +import type { ButtonProps } from '@renderer/components/ui/button' +import { Button } from '@renderer/components/ui/button' +import { Toggle } from '@renderer/components/ui/toggle' +import { cn } from '@renderer/lib/utils' +import type { ComponentPropsWithRef, FC, PropsWithChildren } from 'react' + +export const FunctionAreaButton: FC = ({ children, ...props }) => { + return ( + + ) +} + +export const FunctionAreaToggle: FC< + PropsWithChildren & React.ComponentPropsWithoutRef +> = ({ children, ...props }) => { + return ( + + {children} + + ) +} + +export const ButtonWithIcon: FC< + PropsWithChildren & ComponentPropsWithRef<'button'> & { icon: string } +> = ({ children, className, icon, ...props }) => { + return ( + + ) +} + + + +'use client' + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { cn } from '@renderer/lib/utils' +import { Check } from 'lucide-react' +import * as React from 'react' + +const Checkbox = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + +) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } + + + +export * from './Checkbox' + + + +'use client' + +import type { DialogProps } from '@radix-ui/react-dialog' +import { Dialog, DialogContent } from '@radix-ui/react-dialog' +import { cn } from '@renderer/lib/utils' +import { Command as CommandPrimitive } from 'cmdk' +import { Search } from 'lucide-react' +import * as React from 'react' + +const Command = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( +
    + + +
    +) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = ({ + ref, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = 'CommandShortcut' + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} +
    + + +export * from './Command' + + + +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { cn } from '@renderer/lib/utils' +import { X } from 'lucide-react' +import * as React from 'react' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = ({ + ref, + onClosed, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { onClosed?: () => void } & { + ref?: React.RefObject> +}) => ( + + + + {children} + + + Close + + + +) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} + + + +'use client' + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { cn } from '@renderer/lib/utils' +import { Check, ChevronRight, Circle } from 'lucide-react' +import * as React from 'react' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = ({ + ref, + className, + inset, + children, + ...props +}: React.ComponentPropsWithoutRef & { + inset?: boolean +} & { ref?: React.RefObject> }) => ( + + {children} + + +) +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = ({ + ref, + className, + sideOffset = 4, + container, + ...props +}: React.ComponentPropsWithoutRef & { + container?: Element | DocumentFragment | null | undefined +} & { ref?: React.RefObject> }) => ( + + + +) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = ({ + ref, + className, + inset, + ...props +}: React.ComponentPropsWithoutRef & { + inset?: boolean +} & { ref?: React.RefObject> }) => ( + +) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = ({ + ref, + className, + children, + checked, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + + + {children} + +) +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + + + {children} + +) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = ({ + ref, + className, + inset, + ...props +}: React.ComponentPropsWithoutRef & { + inset?: boolean +} & { ref?: React.RefObject> }) => ( + +) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} + + + +export * from './DropdownMenu' + + + +export * from './Label' + + + +'use client' + +import * as LabelPrimitive from '@radix-ui/react-label' +import { cn } from '@renderer/lib/utils' +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' +import * as React from 'react' + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', +) + +const Label = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & + VariantProps & { + ref?: React.RefObject> + }) => +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } + + + +'use client' + +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu' +import { cn } from '@renderer/lib/utils' +import { Check, ChevronRight, Circle } from 'lucide-react' +import * as React from 'react' + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = ({ + ref, + className, + inset, + children, + ...props +}: React.ComponentPropsWithoutRef & { + inset?: boolean +} & { ref?: React.RefObject> }) => ( + + {children} + + +) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref: React.RefObject> +}) => ( + +) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = ({ + ref, + className, + inset, + ...props +}: React.ComponentPropsWithoutRef & { + inset?: boolean +} & { ref?: React.RefObject> }) => ( + +) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = ({ + ref, + className, + children, + checked, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + + + {children} + +) +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + + + {children} + +) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = ({ + ref, + className, + inset, + ...props +}: React.ComponentPropsWithoutRef & { + inset?: boolean +} & { ref?: React.RefObject> }) => ( + +) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = 'ContextMenuShortcut' + +export { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} + + + +import type { motion, Spring, Target, Transition } from 'framer-motion' +import type { ComponentProps } from 'react' + +const enterStyle: Target = { + scale: 1, + opacity: 1, +} + +const initialStyle: Target = { + scale: 0.96, + opacity: 0, +} + +export const microReboundPreset: Spring = { + type: 'spring', + stiffness: 300, + damping: 20, +} + +type ModalMotionConfig = ComponentProps + +export const modalMotionConfig: ModalMotionConfig = { + initial: initialStyle, + animate: enterStyle, + exit: initialStyle, + transition: microReboundPreset as Transition, // 明确类型转换 +} + +export const MODAL_STACK_Z_INDEX = 100 + + + +import { atom } from 'jotai' +import type { FC, RefObject } from 'react' +import { createContext, use } from 'react' + +import type { ModalProps } from './types' + +export type currentModalContextProps = ModalContentPropsInternal & { + ref: RefObject +} + +export const modalIdToPropsMap = {} as Record + +export const CurrentModalContext = createContext(null) + +export const useCurrentModal = () => { + const context = use(CurrentModalContext) + if (!context) { + throw new Error('useCurrentModal must be used within a ModalStackProvider') + } + return context +} + +export type ModalContentComponent = FC + +export type ModalContentPropsInternal = { + dismiss: () => void +} + +export const modalStackAtom = atom([] as (ModalProps & { id: string })[]) + + + +'use client' + +import * as PopoverPrimitive from '@radix-ui/react-popover' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = ({ + ref, + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverContent, PopoverTrigger } + + + +import * as ProgressPrimitive from '@radix-ui/react-progress' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const Progress = ({ + ref, + className, + value, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } + + + +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const ScrollArea = ({ + ref, + className, + classNames, + children, + ...props +}: React.ComponentPropsWithoutRef & { + classNames?: { scrollBar: string } +} & { ref?: React.RefObject> }) => ( + + + {children} + + + + +) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = ({ + ref, + className, + orientation = 'vertical', + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } + + + +import * as SelectPrimitive from '@radix-ui/react-select' +import { cn } from '@renderer/lib/utils' +import { Check, ChevronDown, ChevronUp } from 'lucide-react' +import * as React from 'react' + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = ({ + ref, + className, + children, + container, + position = 'popper', + ...props +}: React.ComponentPropsWithoutRef & { + container?: Element | DocumentFragment | null | undefined +} & { ref?: React.RefObject> }) => ( + + + + + {children} + + + + +) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = ({ + ref, + className, + children, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + + + + {children} + +) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} + + + +export * from './Sheet' + + + +export * from './Slider' + + + +import * as SliderPrimitive from '@radix-ui/react-slider' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const Slider = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + + + + +) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } + + + +'use client' + +import * as SwitchPrimitives from '@radix-ui/react-switch' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const Switch = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + + + +) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } + + + +import * as TabsPrimitive from '@radix-ui/react-tabs' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const Tabs = TabsPrimitive.Root + +const TabsList = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = ({ + ref, + className, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject> +}) => ( + +) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsContent, TabsList, TabsTrigger } + + + +export * from './toaster' +export * from './use-toast' + + + +'use client' + +// Inspired by react-hot-toast library +import * as React from 'react' + +import type { ToastActionElement, ToastProps } from './toast' + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +export type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +// eslint-disable-next-line unused-imports/no-unused-vars +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType['ADD_TOAST'] + toast: ToasterToast + } + | { + type: ActionType['UPDATE_TOAST'] + toast: Partial + } + | { + type: ActionType['DISMISS_TOAST'] + toastId?: ToasterToast['id'] + } + | { + type: ActionType['REMOVE_TOAST'] + toastId?: ToasterToast['id'] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: 'REMOVE_TOAST', + toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': { + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + } + + case 'UPDATE_TOAST': { + return { + ...state, + toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), + } + } + + case 'DISMISS_TOAST': { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + } + } + case 'REMOVE_TOAST': { + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: 'UPDATE_TOAST', + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) + + dispatch({ + type: 'ADD_TOAST', + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index !== -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + } +} + +export { toast, useToast } + + + +export * from './Toggle' + + + +export * from './Tooltip' + + + +.xgplayer-exit-wrapper { + position: absolute; + top: 46px; + width: 60px; + height: 30px; + background-color: #2c2b2d; + opacity: 0.8; + padding: 2px 2px; + gap: 2px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 5px; + color: white; + + .xgplayer-exit { + display: inline-block; + width: 15px; + height: 25px; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23000' d='m12 13.414l5.657 5.657a1 1 0 0 0 1.414-1.414L13.414 12l5.657-5.657a1 1 0 0 0-1.414-1.414L12 10.586L6.343 4.929A1 1 0 0 0 4.93 6.343L10.586 12l-5.657 5.657a1 1 0 1 0 1.414 1.414z'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + } +} + + + +.xgplayer-fullscreen { + display: inline-block; + width: 25px; + height: 38px; + margin-right: 5px; + margin-top: 1px; + color: white; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23000' d='M18.5 5.5H16a1.5 1.5 0 0 1 0-3h3A2.5 2.5 0 0 1 21.5 5v3a1.5 1.5 0 0 1-3 0zM8 5.5H5.5V8a1.5 1.5 0 1 1-3 0V5A2.5 2.5 0 0 1 5 2.5h3a1.5 1.5 0 1 1 0 3m0 13H5.5V16a1.5 1.5 0 0 0-3 0v3A2.5 2.5 0 0 0 5 21.5h3a1.5 1.5 0 0 0 0-3m8 0h2.5V16a1.5 1.5 0 0 1 3 0v3a2.5 2.5 0 0 1-2.5 2.5h-3a1.5 1.5 0 0 1 0-3'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.xgplayer-exit-fullscreen { + display: inline-block; + width: 25px; + height: 38px; + margin-right: 5px; + margin-top: 1px; + color: white; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23000' d='M17.5 6.5H20a1.5 1.5 0 0 1 0 3h-3A2.5 2.5 0 0 1 14.5 7V4a1.5 1.5 0 0 1 3 0zM4 6.5h2.5V4a1.5 1.5 0 1 1 3 0v3A2.5 2.5 0 0 1 7 9.5H4a1.5 1.5 0 1 1 0-3m0 11h2.5V20a1.5 1.5 0 0 0 3 0v-3A2.5 2.5 0 0 0 7 14.5H4a1.5 1.5 0 0 0 0 3m16 0h-2.5V20a1.5 1.5 0 0 1-3 0v-3a2.5 2.5 0 0 1 2.5-2.5h3a1.5 1.5 0 0 1 0 3'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + + + +import './index.css' + +import { handlers, tipcClient } from '@renderer/lib/client' +import { Plugin } from '@suemor/xgplayer' + +export default class fullEntireScreen extends Plugin { + static readonly pluginName = 'fullEntireScreen' + static readonly pluginClassName = { + icon: `xgplayer-plugin-${fullEntireScreen.pluginName}-icon`, + } + + icon: HTMLElement | undefined + private toggleButtonClickListener: () => void + + constructor(args) { + super(args) + + this.icon = this.find(`.${fullEntireScreen.pluginClassName.icon}`) as HTMLDivElement + + this.toggleButtonClickListener = this.toggleButtonClickFunction.bind(this) + } + + static get defaultConfig() { + return { + position: this.POSITIONS.CONTROLS_RIGHT, + isFullscreen: false, + } + } + + afterCreate() { + this.icon?.addEventListener('click', this.toggleButtonClickListener) + + this.updateFullScreenState() + + handlers?.windowAction.listen((action) => { + if (action === 'enter-full-screen' || action === 'leave-full-screen') { + this.updateFullScreenState() + } + }) + } + + private toggleButtonClickFunction() { + if (this.config.isFullscreen) { + tipcClient?.windowAction({ action: 'leave-full-screen' }) + } else { + tipcClient?.windowAction({ action: 'enter-full-screen' }) + } + this.updateFullScreenState() + } + + private async updateFullScreenState() { + const isFullScreen = await tipcClient?.getWindowIsFullScreen() + if (isFullScreen && !this.player?.cssfullscreen) { + this.player.getCssFullscreen() + } + this.config.isFullscreen = isFullScreen ?? false + this.updateIcon() + } + + private updateIcon() { + if (this.icon) { + if (this.config.isFullscreen) { + this.icon.classList.remove('xgplayer-fullscreen') + this.icon.classList.add('xgplayer-exit-fullscreen') + } else { + this.icon.classList.remove('xgplayer-exit-fullscreen') + this.icon.classList.add('xgplayer-fullscreen') + } + } + } + + destroy(): void { + this.icon?.removeEventListener('click', this.toggleButtonClickListener) + this.icon = undefined + } + + render(): string { + return `
    + +
    ` + } +} +
    + + +.xgplayer-nextEpisode { + display: inline-block; + width: 29px; + height: 38px; + margin-left: 12px; + margin-right: 14px; + margin-top: 1px; + color: white; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23000' d='m13.212 5.627l.356.168l.458.224l.553.283l.417.22l.693.38l.503.287l.533.313l.56.34q.143.087.29.179l.575.366l.54.353l.503.34l.466.324l.625.449l.367.27l.607.466l.432.345c.644.525.627 1.57-.043 2.117l-.307.247l-.562.435l-.73.54l-.426.302l-.465.323l-.505.34l-.266.176l-.56.362l-.295.185l-.558.344l-.532.318l-.503.292l-.474.267l-.442.242l-.599.317l-.354.181l-.712.348l-.21.099c-.772.354-1.616-.152-1.715-1.018l-.075-.74l-.068-.834l-.032-.492l-.03-.54l-.026-.583l-.496.355l-.269.189l-.58.396l-.636.42l-.689.44l-.558.343l-.787.467l-.722.41l-.442.242l-.599.317l-.354.181l-.712.348l-.21.099c-.772.354-1.616-.152-1.715-1.018l-.054-.517l-.062-.705l-.061-.88l-.04-.768l-.023-.56l-.023-.906l-.008-.647v-.683l.008-.667l.015-.631l.034-.875l.042-.783l.064-.891l.048-.546l.044-.433l.013-.118c.103-.91 1.006-1.473 1.783-1.114l.356.168l.458.224l.553.283l.417.22l.454.247l.487.274l.255.146l.533.313l.56.34a47 47 0 0 1 1.588 1.021l.574.392l.52.368l.24.173l.027-.592l.03-.544l.052-.72l.068-.772l.045-.427c.103-.91 1.006-1.473 1.783-1.114'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + + + +.xgplayer-previousEpisode { + display: inline-block; + width: 29px; + height: 38px; + margin-left: 10px; + margin-top: 1px; + color: white; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23000' d='M19.788 5.627c.777-.359 1.68.204 1.783 1.114l.027.25l.062.653l.065.845l.044.751l.026.554l.02.593l.016.631l.008.667v.683l-.008.647l-.023.907l-.022.56l-.04.767l-.062.88l-.062.705l-.054.517c-.1.866-.943 1.372-1.714 1.018l-.608-.29l-.315-.157l-.545-.28l-.408-.218l-.442-.242l-.474-.267l-.503-.292l-.532-.318l-.558-.344l-.69-.439l-.635-.42l-.58-.397l-.525-.37l-.24-.174l-.04.86l-.049.755l-.067.835l-.062.62l-.013.12c-.1.866-.943 1.372-1.714 1.018l-.608-.29l-.315-.157l-.545-.28l-.408-.218l-.442-.242l-.722-.41l-.518-.305l-.27-.162l-.557-.344l-.58-.368l-.275-.179l-.523-.348l-.485-.331l-.446-.314l-.594-.43l-.344-.258l-.562-.435l-.308-.247c-.67-.546-.686-1.592-.042-2.117l.432-.345l.607-.465l.367-.271l.407-.294l.684-.479l.503-.34l.54-.353l.575-.366l.573-.353l.277-.166l.533-.313l.503-.286l.47-.26l.436-.234l.757-.39l.589-.287l.225-.105c.777-.359 1.68.204 1.783 1.114l.045.427l.051.558l.052.68l.048.797l.028.592l.492-.353q.26-.185.547-.38l.599-.403a47 47 0 0 1 .992-.638l.573-.353l.277-.166l.533-.313l.503-.286l.47-.26l.436-.234l.757-.39l.589-.287z'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + + + +.xgplayer-setting { + display: inline-block; + width: 25px; + height: 38px; + margin-right: 14px; + margin-top: 1px; + color: white; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23000' d='M14.035 2.809c.37-.266.89-.39 1.401-.203a10 10 0 0 1 2.982 1.725c.417.35.57.861.524 1.313c-.075.753.057 1.48.42 2.106c.32.557.802.997 1.39 1.307l.225.11c.414.187.782.576.875 1.113a10 10 0 0 1 0 3.44c-.083.484-.39.847-.753 1.051l-.122.063c-.69.31-1.254.79-1.616 1.416c-.362.627-.494 1.353-.419 2.106c.045.452-.107.964-.524 1.313a10 10 0 0 1-2.982 1.725a1.51 1.51 0 0 1-1.4-.203C13.42 20.75 12.723 20.5 12 20.5s-1.42.249-2.035.691a1.51 1.51 0 0 1-1.401.203a10 10 0 0 1-2.982-1.725a1.51 1.51 0 0 1-.524-1.313c.075-.753-.058-1.48-.42-2.106a3.4 3.4 0 0 0-1.39-1.307l-.225-.11a1.51 1.51 0 0 1-.875-1.113a10 10 0 0 1 0-3.44c.083-.484.39-.847.753-1.051l.122-.062c.69-.311 1.254-.79 1.616-1.417c.361-.626.494-1.353.419-2.106a1.51 1.51 0 0 1 .524-1.313a10 10 0 0 1 2.982-1.725a1.51 1.51 0 0 1 1.4.203c.615.442 1.312.691 2.036.691s1.42-.249 2.035-.691m.957 1.769c-.866.57-1.887.922-2.992.922s-2.126-.353-2.992-.922A8 8 0 0 0 7.068 5.7c.06 1.033-.145 2.093-.697 3.05c-.553.956-1.368 1.663-2.293 2.128a8 8 0 0 0 0 2.242c.925.465 1.74 1.172 2.293 2.13c.552.955.757 2.015.697 3.048a8 8 0 0 0 1.94 1.123c.866-.57 1.887-.922 2.992-.922s2.126.353 2.992.922a8 8 0 0 0 1.94-1.122c-.06-1.034.145-2.094.697-3.05c.552-.957 1.368-1.664 2.293-2.13a8 8 0 0 0 0-2.24c-.925-.466-1.74-1.173-2.293-2.13c-.552-.956-.757-2.016-.697-3.05a8 8 0 0 0-1.94-1.122ZM12 8a4 4 0 1 1 0 8a4 4 0 0 1 0-8m0 2a2 2 0 1 0 0 4a2 2 0 0 0 0-4'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + + + +/* eslint-disable tailwindcss/no-custom-classname */ +import { cn } from '@renderer/lib/utils' +// import './index.css' +import { renderToString } from 'react-dom/server' + +const SubtitlePopover = () => { + return ( +
    +
    +
    + + +
    + + +
    +
    + ) +} +export const subtitlePopoverToString = renderToString() +
    + + +import type { CommentsModel } from '@renderer/request/models/comment' + +export interface DB_History { + hash: string + path: string + animeId?: number + episodeId?: number + animeTitle?: string + episodeTitle?: string + progress: number + duration: number + cover?: string + thumbnail?: string + danmaku?: DB_Danmaku[] + newBangumi?: boolean + subtitles?: DB_Subtitles + updatedAt: string +} + +interface DB_Subtitles { + defaultId: number + timeOffset?: number + + tags: Array<{ + id: number + path: string + index?: number + title: string + language?: string + }> +} + +export interface DB_Danmaku { + type: 'dandanplay' | 'third-party-auto' | 'third-party-manual' | 'local' + source: string + selected?: boolean + content: CommentsModel +} + + + +import { TABLES } from './constants' + +export const dbSchemaV1 = { + [TABLES.HISTORY]: + '&hash,animeId, episodeId, path, animeTitle, episodeTitle, progress, duration, cover,thumbnail, danmaku, updatedAt', +} + +export const dbSchemaV2 = { + [TABLES.HISTORY]: + '&hash,animeId, episodeId, path, animeTitle, episodeTitle, progress, duration, cover,thumbnail, danmaku, newBangumi, updatedAt', +} + + + +import type { EntityTable } from 'dexie' +import Dexie from 'dexie' + +import { LOCAL_DB_NAME, TABLES } from './constants' +import { dbSchemaV1 } from './db.schema' +import type { DB_History } from './schemas/history' + +class LocalDB extends Dexie { + history: EntityTable + + constructor() { + super(LOCAL_DB_NAME) + this.version(1).stores(dbSchemaV1) + this.version(2) + .stores(dbSchemaV1) + .upgrade(async (trans) => { + const historyTable = trans.table(TABLES.HISTORY) + const allRecords = await historyTable.toArray() + for (const record of allRecords) { + delete record.danmaku + await historyTable.put(record) + } + }) + this.history = this.table(TABLES.HISTORY) + } + + async deleteDatabase(): Promise { + try { + db.close() + await Dexie.delete(db.name) + } catch (error) { + console.error('删除数据库失败:', error) + } + } +} + +export const db = new LocalDB() + + + +import { tipcClient } from '@renderer/lib/client' +import { useTheme } from 'next-themes' +import { useCallback } from 'react' + +export type AppTheme = 'cmyk' | 'dark' | 'system' +export const useAppTheme = () => { + const { setTheme, theme } = useTheme() + const isDarkMode = theme === 'dark' + const toggleMode = useCallback( + (themes: AppTheme) => { + setTheme(themes) + if (window.electron) { + tipcClient?.setTheme(themes) + } + }, + [setTheme], + ) + + return { toggleMode, theme, isDarkMode } +} + + + +import { Button } from '@renderer/components/ui/button' +import { useModalStack } from '@renderer/components/ui/modal' +import { tipcClient } from '@renderer/lib/client' +import { isWeb } from '@renderer/lib/utils' +import { useCallback } from 'react' + +interface showConfirmationBox { + title: string + handleConfirm?: () => void + handleCancel?: () => void +} + +export const useConfirmationDialog = () => { + const { present } = useModalStack() + + return useCallback( + (params: showConfirmationBox) => { + if (isWeb) { + return present({ + id: 'DIALOG', + title: params.title, + overlay: true, + content: ({ dismiss }) => ( +
    + + +
    + ), + }) + } + tipcClient?.confirmationDialog({ title: params.title }).then((result) => { + if (!result) { + return params.handleCancel?.() + } + params.handleConfirm?.() + }) + return + }, + [present], + ) +} +
    + + +import { useCallback, useSyncExternalStore } from 'react' + +export const useNetworkStatus = () => { + return useSyncExternalStore( + useCallback((callback: () => void) => { + window.addEventListener('online', callback) + window.addEventListener('offline', callback) + return () => { + window.removeEventListener('online', callback) + window.removeEventListener('offline', callback) + } + }, []), + () => navigator.onLine, + ) +} + + + +import { scan } from 'react-scan' + +import { isDev } from '../lib/env' +import { initializeDayjs } from './date' +import { initializeSentry } from './sentry' + +export const initializeApp = () => { + initializeDayjs() + initializeSentry() + + if (isDev) { + scan({ + enabled: false, + log: true, // logs render info to console (default: false) + showToolbar: false, + }) + } +} + + + +import type { Converter } from 'opencc-js/core' +import { ConverterFactory } from 'opencc-js/core' +import type { LocaleOption } from 'opencc-js/preset' +import * as Locale from 'opencc-js/preset' + +/** + * A utility class for converting between Traditional and Simplified Chinese. + */ +class ChineseConverter { + private converter: Converter + + /** + * Creates a converter instance for Chinese text. + * @param from - Source locale (default: tw Traditional). + * @param to - Target locale (default: Mainland Simplified). + */ + constructor(from: LocaleOption = Locale.from.tw, to: LocaleOption = Locale.to.cn) { + this.converter = ConverterFactory(from, to) + } + + /** + * Converts the stored text based on the specified locales. + * @param text - The input text to convert. + * @returns The converted text. + */ + public convert(text: string): string { + return this.converter(text) + } + + /** + * Static method to quickly convert tw Traditional Chinese to Mainland Simplified Chinese. + * @param text - The text to convert. + * @returns The converted text. + */ + public static chtToChs(text: string): string { + const converter = ConverterFactory(Locale.to.tw, Locale.to.cn) + return converter(text) + } +} + +export const chineseConverter = new ChineseConverter() + +export function createChineseConverter(from: LocaleOption, to: LocaleOption): ChineseConverter { + return new ChineseConverter(from, to) +} + + + +import { toast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' + +import { tipcClient } from './client' +import { isWeb } from './utils' + +const ns = 'marchen' +export const getStorageNS = (key: string) => `${ns}:${key}` + +export const clearStorage = () => { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(ns)) { + localStorage.removeItem(key) + } + } +} + +export const resetApp = async () => { + if (isWeb) { + localStorage.clear() + await db.deleteDatabase() + toast({ + title: '重置成功', + description: '网页将在 3 秒后重新加载', + }) + setTimeout(() => { + window.location.reload() + }, 3000) + return + } + tipcClient?.windowAction({ action: 'reset' }) +} + + + +import { RouterLayout } from '@renderer/components/layout/root/RouterLayout' + +export default function LastAnime() { + return ( + +
    还在编写中,敬请期待
    +
    + ) +} +
    + + +import type { BangumiDetailResponseModel, BangumiShinResponseModel } from '../models/bangumi' +import { Get } from '../ofetch' + +function getBangumiDetailById(animeId: number) { + return Get(`/bangumi/${animeId}`) +} + +function getBangumiShin() { + return Get(`/bangumi/shin`) +} + +export const bangumi = { + getBangumiDetailById, + getBangumiShin, +} + + + +import type { CommentsModel } from '../models/comment' +import { Get } from '../ofetch' + +export enum Commentkeys { + getDanmu = 'getDanmu', + getExtcomment = 'getExtcomment', +} + +function getDanmu(episodeId: number, params?: { chConvert: number }) { + return Get(`/comment/${episodeId}`, { + withRelated: false, + ...params, + }) +} + +function getExtcomment(params?: { url: string }) { + return Get(`/extcomment`, { + ...params, + }) +} + +export const comment = { + getDanmu, + getExtcomment, + Commentkeys, +} + + + +import type { SearchAnimeModel, SearchAnimeRequestModel } from '../models/search' +import { Get } from '../ofetch' + +export enum Searchkeys { + getSearchEpisodes = 'getSearchEpisodes', +} + +function getSearchEpisodes(data: SearchAnimeRequestModel) { + return Get('search/episodes', data) +} + +export const search = { + getSearchEpisodes, + Searchkeys, +} + + + +import type { ReponseBaseModel } from './base' + +interface TitleModel { + language: string + title: string +} + +interface EpisodeModel { + seasonId: number | null + episodeId: number + episodeTitle: string + episodeNumber: string + lastWatched: string | null + airDate: string +} + +interface RatingDetailsModel { + 弹弹play连载中评分: number + 弹弹play完结后评分: number + Bangumi评分: number + Anidb连载中评分: number + Anidb完结后评分: number + Anidb评论员评分: number +} + +interface RelatedAnimeModel { + animeId: number + bangumiId: string + animeTitle: string + imageUrl: string + searchKeyword: string + isOnAir: boolean + airDay: number + isFavorited: boolean + isRestricted: boolean + rating: number +} + +interface TagModel { + id: number + name: string + count: number +} + +interface OnlineDatabaseModel { + name: string + url: string +} + +interface TrailerModel { + id: number + url: string + title: string + imageUrl: string + date: string +} + +interface BangumiModel { + type: string + typeDescription: string + titles: TitleModel[] + seasons: any[] + episodes: EpisodeModel[] + summary: string + metadata: string[] + bangumiUrl: string + userRating: number + favoriteStatus: any + comment: any + ratingDetails: RatingDetailsModel + relateds: RelatedAnimeModel[] + similars: any[] + tags: TagModel[] + onlineDatabases: OnlineDatabaseModel[] + trailers: TrailerModel[] + animeId: number + bangumiId: string + animeTitle: string + imageUrl: string + searchKeyword: string + isOnAir: boolean + airDay: number + isFavorited: boolean + isRestricted: boolean + rating: number +} + +export interface BangumiDetailResponseModel extends ReponseBaseModel { + bangumi: BangumiModel +} + +export interface BangumiShinResponseModel extends ReponseBaseModel { + bangumiList: Array<{ + animeId: number + bangumiId: string + animeTitle: string + imageUrl: string + searchKeyword: string + isOnAir: boolean + airDay: number + isFavorited: boolean + isRestricted: boolean + rating: number + }> +} + + + +export interface ReponseBaseModel { + /** + * 错误代码,0表示没有发生错误,非0表示有错误,详细信息会包含在errorMessage属性中 + */ + errorCode?: number + + /** + * 接口是否调用成功 + */ + success?: boolean + + /** + * 当发生错误时,说明错误具体原因 + */ + errorMessage?: string +} + + + +export interface CommentsModel { + count: number + comments: CommentModel[] +} + +export interface CommentModel { + cid: number + m: string + p: string +} + + + +@import '@fontsource/manrope/400.css'; +@import '@fontsource/manrope/700.css'; + + + +@import '@suemor/xgplayer/dist/index.min.css'; +@import '@suemor/xgplayer/es/plugins/danmu/index.css'; + +.xgplayer { + cursor: default; +} + + + +node_modules +dist +out +.DS_Store +*.log* +.eslintcache +.env +*.mas.* + + + +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Marchen Player is a local anime video player with danmaku (bullet comments) support. It automatically matches danmaku to imported anime videos. Built with Electron, supporting Web, macOS, Windows, and Linux platforms. + +## Development Commands + +```bash +# Install dependencies (requires pnpm) +corepack enable +pnpm install + +# Development +pnpm dev # Start Electron dev server +pnpm dev:web # Start web-only dev server + +# Building +pnpm build # Full build with typecheck +pnpm build:mac # Build macOS (dmg + zip) +pnpm build:win # Build Windows installer +pnpm build:linux # Build Linux AppImage +pnpm build:web # Build web version + +# Code quality +pnpm typecheck # Run TypeScript type checking +pnpm lint # Run ESLint +pnpm lint:fix # Auto-fix ESLint issues +pnpm format # Format with Prettier + +# Version management +pnpm bump # Bump version (uses nbump) +``` + +## Architecture + +### Process Structure (Electron) + +- **Main Process** (`src/main/`): Node.js backend - window management, file system access, FFmpeg operations +- **Preload** (`src/preload/`): Context bridge exposing `electron`, `api`, and `platform` to renderer +- **Renderer** (`src/renderer/`): React frontend application + +### IPC Communication + +Uses `@egoist/tipc` for type-safe IPC between main and renderer processes: + +- **Main handlers**: `src/main/tipc/` - Define routes (app, player, setting, utils) +- **Renderer client**: `src/renderer/src/lib/client.ts` - `tipcClient` for invoking main process, `handlers` for receiving events +- Routes are combined in `src/main/tipc/index.ts` and exported as `Router` type + +Example usage in renderer: +```typescript +import { tipcClient } from '@renderer/lib/client' +const result = await tipcClient?.getAnimeDetailByPath({ path }) +``` + +### State Management + +- **Jotai atoms** (`src/renderer/src/atoms/`): Global state for player, progress, window, and settings +- **TanStack Query**: Server state and API data caching +- **Dexie** (`src/renderer/src/database/`): IndexedDB wrapper for local persistence (history) + +### Routing + +Hash-based routing with React Router v7. Routes defined in `src/renderer/src/router/router.tsx`. Main pages: Player, History. + +### Path Aliases + +Configured in `electron.vite.config.ts`: +- `@main` → `src/main` +- `@renderer` → `src/renderer/src` +- `@pkg` → `package.json` + +### Custom Protocol + +Uses `marchen://` protocol for local file access. Files are referenced with `MARCHEN_PROTOCOL_PREFIX` + absolute path. + +## Key Dependencies + +- **Video**: `@suemor/xgplayer` (custom xgplayer fork), `danmu.js` +- **Subtitles**: `@jellyfin/libass-wasm` (ASS/SSA rendering) +- **Media processing**: `fluent-ffmpeg` with `@ffmpeg-installer/ffmpeg` +- **UI**: Tailwind CSS, shadcn/ui (Radix), DaisyUI, Framer Motion +- **Icons**: Lucide React, Iconify (mingcute) + +## Code Style + +- ESLint config: `eslint-config-hyoban` +- Prettier: No semicolons, single quotes, 100 char width +- Pre-commit hook runs lint-staged on all staged files + + + +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + + + +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "include": [ + "electron.vite.config.*", + "src/main/**/*", + "src/preload/**/*", + "types/**/*.d.ts", + "src/shared/src/**/*", + "src/renderer/src/lib/calc-file-hash.ts" + ], + "compilerOptions": { + "composite": true, + "types": ["electron-vite/node"], + "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "@pkg": ["./package.json"], + "@renderer/*": ["src/renderer/src/*"], + "@main/*": ["src/main/*"] + } + } +} + + + +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", + "include": [ + "src/renderer/src/env.d.ts", + "src/renderer/src/**/*", + "src/renderer/src/**/*.tsx", + "src/preload/*.d.ts", + "src/shared/src/**/*", + "src/main/lib/*", + "src/main/windows/*", + "src/main/tipc/**/*", + "src/main/modules/**/*", + "src/main/types/**/*", + "src/main/constants", + "types/**/*.d.ts", + "src/env.ts", + "src/main/types/ffmpeg-custom.d.ts" + ], + "compilerOptions": { + "composite": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "target": "ES2022", + "noUnusedLocals": false, + "strict": true, + "baseUrl": ".", + "paths": { + "@renderer/*": ["src/renderer/src/*"], + "@main/*": ["src/main/*"], + "@pkg": ["./package.json"] + }, + } +} + + + +import fs from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import react from '@vitejs/plugin-react' +import copy from 'rollup-plugin-copy' +import { defineConfig } from 'vite' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const packageJson = JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf-8')) + +const ROOT = './src/renderer' + +const vite = () => + defineConfig({ + build: { + outDir: resolve(__dirname, 'out/web'), + target: 'ES2022', + rollupOptions: { + input: { + main: resolve(ROOT, '/index.html'), + }, + plugins: [ + copy({ + targets: [ + { + src: 'node_modules/@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.wasm', + dest: 'out/web/assets', + }, + ], + hook: 'writeBundle', + }), + ], + }, + }, + root: ROOT, + envDir: resolve(__dirname, '.'), + resolve: { + alias: { + '@main': resolve('src/main'), + '@pkg': resolve('./package.json'), + '@renderer': resolve('src/renderer/src'), + }, + }, + base: '/', + server: { + port: 1106, + host: true, + }, + plugins: [react()], + + define: { + APP_NAME: JSON.stringify(packageJson.name), + }, + }) +export default vite + + + +/* eslint-disable no-console */ +import fs from 'node:fs' +import path from 'node:path' + +import fixLinuxPermissions from './fix-linux-permissions.js' + +export default async function cleanDeps(context) { + await fixLinuxPermissions(context) + const { packager, arch, appOutDir } = context + const platform = packager.platform.nodeName + if (platform !== 'darwin') { + return + } + const archMap = { + 1: 'x64', + 3: 'arm64', + } + const currentArch = archMap[arch] + + if (!currentArch) { + return + } + + const unpackedPath = path.resolve( + appOutDir, + 'Marchen.app', + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'node_modules', + ) + if (!fs.existsSync(unpackedPath)) { + return + } + + const ffmpegPath = path.resolve(unpackedPath, '@ffmpeg-installer') + const ffprobePath = path.resolve(unpackedPath, '@ffprobe-installer') + + if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) { + return + } + + const removeUnusedArch = (basePath, unusedArch) => { + const unusedPath = path.resolve(basePath, `darwin-${unusedArch}`) + if (fs.existsSync(unusedPath)) { + fs.rmSync(unusedPath, { recursive: true }) + } + } + + if (currentArch === 'x64') { + removeUnusedArch(ffmpegPath, 'arm64') + removeUnusedArch(ffprobePath, 'arm64') + } else if (currentArch === 'arm64') { + removeUnusedArch(ffmpegPath, 'x64') + removeUnusedArch(ffprobePath, 'x64') + } + console.log('Cleaned unused arch dependencies.') +} + + + +/* eslint-disable no-console */ +import { exec as execCallback } from 'node:child_process' +import { promisify } from 'node:util' + +const exec = promisify(execCallback) + +export default async function installDarWinDeps(context) { + const { packager, arch } = context + const platform = packager.platform.nodeName + if (platform !== 'darwin') { + return + } + + const archMap = { + 1: 'x64', + 3: 'arm64', + } + const currentArch = archMap[arch] + if (!currentArch) { + return + } + + const dependenciesToRemove = { + x64: ['@ffmpeg-installer/darwin-arm64', '@ffprobe-installer/darwin-arm64'], + arm64: ['@ffmpeg-installer/darwin-x64', '@ffprobe-installer/darwin-x64'], + } + + const removeDeps = async (arch) => { + const deps = dependenciesToRemove[arch] + for (const dep of deps) { + try { + await exec(`pnpm list ${dep}`) + await exec(`pnpm remove ${dep}`) + } catch { + console.log(`${dep} not found, skipping...`) + } + } + } + + const addDeps = async (arch) => { + const deps = { + x64: '@ffmpeg-installer/darwin-x64@^4.1.0 @ffprobe-installer/darwin-x64@^5.1.0 -D', + arm64: '@ffmpeg-installer/darwin-arm64@^4.1.5 @ffprobe-installer/darwin-arm64@^5.0.1 -D', + } + await exec(`pnpm add ${deps[arch]}`) + } + + await removeDeps(currentArch) + await addDeps(currentArch) + + console.log('Successfully installed dependencies') +} + + + +import { isLinux } from '@main/lib/env' +import { app } from 'electron' + +/** + * 在 Linux 系统上尝试启用硬件解码 (第三版尝试 - 聚焦 X11 + 关键特性) + * 注意:这些标志需要在 app 'ready' 事件触发之前应用。 + */ +export const enableHardwareDecodingOnLinux = () => { + if (!isLinux) return + + // --- 关键标志 --- + + // 1. 不再强制 Wayland,允许默认使用 X11 (或使用 hint) + // 因为 Wayland 导致了 GPU 进程无法启动。 + // app.commandLine.appendSwitch('ozone-platform', 'x11'); // 可以明确指定 x11 + app.commandLine.appendSwitch('ozone-platform-hint', 'auto') // 或者让其自动选择(通常是 x11) + // **注意**: 移除了 app.commandLine.appendSwitch('ozone-platform', 'wayland'); + + // 2. 恢复设置渲染后端为 ANGLE on Vulkan + // 因为这在 X11 环境下之前是能成功初始化 GPU 进程的。 + app.commandLine.appendSwitch('use-gl', 'angle') + app.commandLine.appendSwitch('use-angle', 'vulkan') // <--- 恢复为 vulkan + + // 3. 启用特性 (Features) - 加入 UseMultiPlaneFormatForHardwareVideo + const features = [ + 'VaapiVideoDecoder', // 启用 VA-API 解码器 + 'UseMultiPlaneFormatForHardwareVideo', // **重要**: 加入此特性,尝试修复帧池问题 + 'VaapiIgnoreDriverChecks', // 忽略驱动兼容性检查 + 'Vulkan', // 启用 Vulkan + 'VulkanFromANGLE', // 允许 ANGLE 使用 Vulkan + 'DefaultANGLEVulkan', // 默认 ANGLE 后端为 Vulkan + 'CanvasOopRasterization', + ] + const featuresString = features.join(',') + app.commandLine.appendSwitch('enable-features', featuresString) +} + + + +import fs from 'node:fs' + +import { subtitlesPath } from '@main/constants/app' +import { getMainWindow } from '@main/windows/main' +import { app } from 'electron' + +export const clearAllData = async () => { + const win = getMainWindow() + if (!win) return + const ses = win.webContents.session + + try { + await ses.clearCache() + + await ses.clearStorageData({ + storages: [ + 'websql', + 'filesystem', + 'indexdb', + 'localstorage', + 'shadercache', + 'websql', + 'serviceworkers', + 'cookies', + ], + }) + app.clearRecentDocuments() + app.setLoginItemSettings({ + openAtLogin: false, + }) + if (fs.existsSync(subtitlesPath())) { + fs.rmSync(subtitlesPath(), { recursive: true }) + } + win.reload() + } catch (error: any) { + console.error('Failed to clear data:', error) + } +} + + + +export type RendererHandlers = { + showSetting: (tab?: string) => void + importAnime: (params?: { path: string }) => void + getReleaseNotes: (text: string) => void + updateProgress: (params: { progress: number; status: 'downloading' | 'installing' }) => void + windowAction: ( + action: 'enter-full-screen' | 'leave-full-screen' | 'maximize' | 'unmaximize', + ) => void +} + + + +import { getRendererHandlers as getAppRendererHandlers } from '@egoist/tipc/main' +import { clearAllData } from '@main/lib/cleaner' +import { dialog } from 'electron' + +import type { RendererHandlers } from '../tipc/renderer-handlers' +import { getMainWindow } from './main' + +export const getRendererHandlers = () => { + const mainWindow = getMainWindow() + if (!mainWindow) { + return + } + return getAppRendererHandlers(mainWindow.webContents) +} + +export const createSettingWindow = (tab?: string) => { + const handlers = getRendererHandlers() + handlers?.showSetting.send(tab) +} + +export const importAnime = () => { + const handlers = getRendererHandlers() + handlers?.importAnime.send() +} + +export const clearData = async () => { + const win = getMainWindow() + if (!win) { + return + } + + const result = await dialog.showMessageBox({ + type: 'warning', + message: '这个行为会清除 APP 全部数据,包括历史记录和设置,确定要继续吗?', + buttons: ['取消', '确定'], + }) + if (!result.response) { + return + } + + return clearAllData() +} + +export const updateProgress = (params: { + progress: number + status: 'downloading' | 'installing' +}) => { + const handlers = getRendererHandlers() + handlers?.updateProgress.send({ progress: params.progress, status: params.status }) +} + + + +import { tipcClient } from '@renderer/lib/client' +import * as Sentry from '@sentry/react' +import { useEffect } from 'react' +import { useRouteError } from 'react-router' + +import { Button } from '../ui/button' + +export default function ErrorView() { + const error = useRouteError() as { statusText: string; message: string } + console.error(error) + + useEffect(() => { + Sentry.captureException(error) + }, [error]) + return ( +
    +

    糟糕发生错误了😭

    +

    + 错误信息: {error?.statusText ?? error?.message} +

    + +
    + ) +} +
    + + +import { useAppSettings } from '@renderer/atoms/settings/app' +import Show from '@renderer/components/common/Show' +import { DownloadClient } from '@renderer/components/layout/sidebar' +import { useToast } from '@renderer/components/ui/toast' +import { appLog } from '@renderer/lib/log' +import { cn, isChromiumBased, isWeb, isWindows } from '@renderer/lib/utils' +import { useEffect } from 'react' + +import { Titlebar } from './WindowsTitlebar' + +export const Prepare = () => { + const [_, setAppSettings] = useAppSettings() + useEffect(() => { + const doneTime = Math.trunc(performance.now()) + + appLog('App is ready', `${doneTime}ms`) + + if (!isWeb) { + setAppSettings((old) => ({ + ...old, + firstOpen: false, + })) + } + }, []) + + if (isWeb) { + return + } + + return ( +
    + + + +
    + ) +} + +const PrepareForWeb = () => { + const [appSettings, setAppSettings] = useAppSettings() + const { toast } = useToast() + const { firstOpen } = appSettings + + useEffect(() => { + appLog( + 'Download the Marchen Player client from github', + 'https://github.com/marchen-dev/marchen-player/releases/latest', + ) + if (!firstOpen) { + return + } + const clear = setTimeout(() => { + const description = `${!isChromiumBased() ? '当前浏览器不支持 MKV 容器格式,' : ''}推荐下载客户端版本获得完整的体验` + toast({ + title: '下载客户端', + description, + duration: 10000, + action: , + }) + setAppSettings((old) => ({ + ...old, + firstOpen: false, + })) + }, 1000) + + return () => clearTimeout(clear) + }, []) + + return null +} +
    + + +import type { SelectGroup } from '@renderer/components/modules/shared/setting/SettingSelect' + +export const danmakuFontSizeList = [ + { + label: '80%', + value: '22', + }, + { + label: '90%', + value: '24', + }, + { + label: '100%', + value: '26', + default: true, + }, + { + label: '110%', + value: '28', + }, + { + label: '120%', + value: '30', + }, + { + label: '130%', + value: '32', + }, + { + label: '140%', + value: '34', + }, +] satisfies SelectGroup[] + +export const danmakuDurationList = [ + { + label: '极慢', + value: '17000', + }, + { + label: '较慢', + value: '19000', + }, + { + label: '适中', + value: '15000', + default: true, + }, + { + label: '较快', + value: '13000', + }, + { + label: '极快', + value: '10000', + }, +] satisfies SelectGroup[] + +export const danmakuEndAreaList = [ + { + label: '10%', + value: '0.1', + }, + { + label: '25%', + value: '0.25', + default: true, + }, + { + label: '50%', + value: '0.5', + }, + { + label: '75%', + value: '0.75', + }, + { + label: '100%', + value: '1', + }, +] satisfies SelectGroup[] + +export const PlayerKernelList = [ + { + label: 'HTML5', + value: 'html5', + default: true, + }, + { + label: 'FFmpeg(实验)', + value: 'ffmpeg', + }, +] + + + +import { cn } from '@renderer/lib/utils' +import type { FC, PropsWithChildren, ReactNode } from 'react' + +export const SettingViewContainer: FC = ({ children }) => { + return
    {children}
    +} + +interface FieldsCardLayoutProps extends PropsWithChildren { + title?: ReactNode + className?: string +} +export const FieldsCardLayout: FC = ({ children, title, className }) => { + return ( +
    +

    {title}

    + {children} +
    + ) +} + +interface FieldLayoutProps extends PropsWithChildren { + title?: ReactNode + className?: string +} + +export const FieldLayout = ({ + ref, + children, + title, + className, +}: FieldLayoutProps & { ref?: React.RefObject }) => { + return ( +
    + {title} + {children} +
    + ) +} +
    + + +import type { MatchedVideoType } from '@renderer/atoms/player' +import { currentMatchedVideoAtom } from '@renderer/atoms/player' +import { db } from '@renderer/database/db' +import { tipcClient } from '@renderer/lib/client' +import { apiClient } from '@renderer/request' +import { RouteName, useCurrentRoute } from '@renderer/router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' + +import { showMatchAnimeDialogAtom } from '../core/html5-player/loading/dialog/hooks' +import { MatchAnimeDialog } from '../core/html5-player/loading/dialog/MatchAnimeDialog' +import { saveToHistory } from '../core/html5-player/loading/hooks' + +export const MatchDanmakuDialog = () => { + const { hash } = useAtomValue(showMatchAnimeDialogAtom) + const setCurrentMatchedVideoAtom = useSetAtom(currentMatchedVideoAtom) + const queryClient = useQueryClient() + const routes = useCurrentRoute() + const { data: matchData } = useQuery({ + queryKey: [apiClient.match.Matchkeys.postVideoEpisodeId, hash], + queryFn: async () => { + const historyData = await db.history.get({ hash }) + if (!historyData?.path) { + return + } + const animeDetail = await tipcClient?.getAnimeDetailByPath({ path: historyData.path }) + if (!animeDetail || animeDetail.ok !== 1) { + return + } + const { fileHash, fileSize, fileName } = animeDetail + if (!fileHash || !fileSize || !fileName) { + return + } + + return apiClient.match.postVideoEpisodeId({ fileSize, fileHash, fileName }) + }, + // 仅在历史记录页面才需要重新获取匹配数据 + enabled: !!hash && routes?.path === RouteName.HISTORY, + }) + const handleUpdateHistory = async (params?: MatchedVideoType) => { + const old = await db.history.get({ hash }) + if (!old || !hash) { + return + } + + const { path, subtitles } = old + if (!params) { + return + } + const { episodeId, episodeTitle, animeId, animeTitle } = params + await saveToHistory({ + path, + subtitles, + hash, + ...params, + }) + + // 如果之前之前播放过动漫,就更新匹配数据 + queryClient.setQueryData([apiClient.match.Matchkeys.postVideoEpisodeId, hash], { + isMatched: true, + matches: [{ episodeTitle, episodeId, animeId, animeTitle }], + }) + + setCurrentMatchedVideoAtom(params) + } + return +} + + + +import * as Dialog from '@radix-ui/react-dialog' +import { useEventCallback } from '@renderer/hooks/use-event-callback' +import { useIsUnMounted } from '@renderer/hooks/use-is-unmounted' +import { nextFrame, stopPropagation } from '@renderer/lib/dom' +import { cn } from '@renderer/lib/utils' +import type { AnimationDefinition } from 'framer-motion' +import { m, useAnimationControls, useDragControls } from 'framer-motion' +import { useAtomValue, useSetAtom } from 'jotai' +import { selectAtom } from 'jotai/utils' +import type { + FC, + ForwardedRef, + PointerEventHandler, + PropsWithChildren, + SyntheticEvent, +} from 'react' +import { createElement, Fragment, memo, useCallback, useEffect, useMemo, useRef } from 'react' + +import { ButtonWithIcon } from '../../button' +import { Divider } from '../../divider' +import { MODAL_STACK_Z_INDEX, modalMotionConfig } from './constants' +import type { currentModalContextProps, ModalContentPropsInternal } from './Context' +import { CurrentModalContext, modalStackAtom } from './Context' +import type { ModalProps } from './types' + +interface ModalInternalProps extends PropsWithChildren { + item: ModalProps & { id: string } + index: number + isTop: boolean + onClose?: (open: boolean) => void + ref?: ForwardedRef +} + +export const ModalInternal: FC = memo(function Modal({ ref, ...props }) { + const { item, index, isTop, onClose: onPropsClose, children } = props + const setStack = useSetAtom(modalStackAtom) + const close = useEventCallback(() => { + setStack((p) => p.filter((stack) => stack.id !== item.id)) + onPropsClose?.(false) + }) + + const currentIsClosing = useAtomValue( + useMemo( + () => + selectAtom(modalStackAtom, (atomValue) => atomValue.every((modal) => modal.id !== item.id)), + [item.id], + ), + ) + useEffect(() => { + if (currentIsClosing && document.body && document.body.style) { + document.body.style.pointerEvents = 'auto' + } + }, [currentIsClosing]) + + const onClose = useCallback( + (open: boolean) => { + if (!open) { + close() + } + }, + [close], + ) + + const { + wrapper: Wrapper = Fragment, + CustomModalComponent, + clickOutsideToDismiss, + classNames, + title, + max, + content, + } = item + + const zIndexStyle = useMemo(() => ({ zIndex: MODAL_STACK_Z_INDEX + index + 1 }), [index]) + + const dismiss = useCallback( + (e: SyntheticEvent) => { + stopPropagation(e) + close() + }, + [close], + ) + + const isUnmounted = useIsUnMounted() + const animateController = useAnimationControls() + const dragController = useDragControls() + const handleDrag: PointerEventHandler = useCallback( + (e) => { + dragController.start(e) + }, + [dragController], + ) + + const isSelectingRef = useRef(false) + const handleSelectStart = useCallback(() => { + isSelectingRef.current = true + }, []) + + useEffect(() => { + nextFrame(() => { + animateController.start(modalMotionConfig.animate as AnimationDefinition) + }) + }, [animateController]) + + const noticeModal = useCallback(() => { + animateController + .start({ + scale: 1.05, + transition: { + duration: 0.06, + }, + }) + .then(() => { + if (isUnmounted.current) return + animateController.start({ + scale: 1, + }) + }) + }, [animateController]) + + useEffect(() => { + if (isTop) return + animateController.start({ + scale: 0.96, + y: 10, + }) + return () => { + try { + animateController.stop() + animateController.start({ + scale: 1, + y: 0, + }) + } catch { + /* empty */ + } + } + }, [isTop]) + + const ModalProps: ModalContentPropsInternal = useMemo( + () => ({ + dismiss: () => close(), + }), + [close], + ) + + const edgeElementRef = useRef(null) + + const modalContentRef = useRef(null) + + const modalContextProps = useMemo( + () => ({ + ...ModalProps, + ref: modalContentRef, + }), + [ModalProps], + ) + const finalChildren = ( + + {children ?? createElement(content, ModalProps)} + + ) + if (CustomModalComponent) { + return ( + + + + +
    + {title} +
    + {finalChildren} +
    +
    +
    +
    +
    +
    + ) + } + + return ( + + + + + + + + + + ) +}) + +export default ModalInternal +
    + + +'use client' + +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import { cn } from '@renderer/lib/utils' +import * as React from 'react' + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = ({ + ref, + className, + sideOffset = 4, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject | null> +}) => ( + + + +) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } + + + +import { version } from '@pkg' +import { API_URL, isDev, SENTRY_DSN } from '@renderer/lib/env' +import * as Sentry from '@sentry/react' +import { useEffect } from 'react' +import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router' + +export const initializeSentry = () => { + if (isDev) { + return + } + Sentry.init({ + dsn: SENTRY_DSN, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + Sentry.replayIntegration({ + // NOTE: This will disable built-in masking. Only use this if your site has no sensitive data, or if you've already set up other options for masking or blocking relevant data, such as 'ignore', 'block', 'mask' and 'maskFn'. + maskAllText: false, + blockAllMedia: false, + }), + Sentry.httpClientIntegration(), + Sentry.captureConsoleIntegration({ + levels: ['error'], + }), + ], + // Tracing + tracesSampleRate: 1, // Capture 100% of the transactions + // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled + tracePropagationTargets: ['localhost', API_URL], + // Session Replay + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysOnErrorSampleRate: 1, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. + }) + Sentry.setTag('app_version', version) +} + + + +import type { ClassValue } from 'clsx' +import { clsx } from 'clsx' +import { memoize } from 'lodash-es' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export type OS = 'macOS' | 'iOS' | 'Windows' | 'Android' | 'Linux' | '' +export const getOS = memoize((): OS => { + if (window.platform) { + switch (window.platform) { + case 'darwin': { + return 'macOS' + } + case 'win32': { + return 'Windows' + } + case 'linux': { + return 'Linux' + } + } + } + + const { userAgent } = window.navigator, + { platform } = window.navigator, + macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'], + windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'], + iosPlatforms = ['iPhone', 'iPad', 'iPod'] + let os = '' + + if (macosPlatforms.includes(platform)) { + os = 'macOS' + } else if (iosPlatforms.includes(platform)) { + os = 'iOS' + } else if (windowsPlatforms.includes(platform)) { + os = 'Windows' + } else if (/Android/.test(userAgent)) { + os = 'Android' + } else if (!os && /Linux/.test(platform)) { + os = 'Linux' + } + + return os as OS +}) + +export const isMac = getOS() === 'macOS' && window.electron +export const isWindows = getOS() === 'Windows' && window.electron +export const isWeb = !window.electron + +export const checkIsVideoType = (videoName: string) => { + const videoSuffix = videoName?.split('.').pop()?.toLowerCase() + return videoSuffix === 'mp4' || videoSuffix === 'mkv' +} + +export const isChromiumBased = (): boolean => { + const userAgent = navigator.userAgent.toLowerCase() + return userAgent.includes('chrome') || userAgent.includes('chromium') +} + + + +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { FFmpegPlayer } from '@renderer/components/modules/core/ffmpeg-player/FFmpegPlayer' +import { HTML5Player } from '@renderer/components/modules/core/html5-player/HTML5Player' +import { useVideo } from '@renderer/components/modules/core/html5-player/loading/hooks' +import { VideoProvider } from '@renderer/components/modules/core/html5-player/loading/PlayerProvider' +import { cn, isWeb } from '@renderer/lib/utils' +import { AnimatePresence, m } from 'framer-motion' +import type { FC } from 'react' +import { useCallback, useMemo, useRef } from 'react' + +export default function VideoPlayer() { + const { importAnimeViaIPC, importAnimeViaDragging, video } = useVideo() + const fileInputRef = useRef(null) + const { playerKernel } = usePlayerSettingsValue() + const { url } = video + const manualImport = useCallback(() => { + if (isWeb) { + return fileInputRef.current?.click() + } + importAnimeViaIPC() + }, [importAnimeViaIPC]) + + const content = useMemo(() => { + if (!url) { + return + } + switch (playerKernel) { + case 'html5': { + return + } + case 'ffmpeg': { + return + } + + default: { + return + } + } + }, [url, manualImport]) + + return ( + +
    e.preventDefault()} + className={cn('flex size-full items-center justify-center')} + > + {content} + {!url && ( + + )} +
    +
    + ) +} + +const DragTips: FC<{ onClick: () => void }> = ({ onClick }) => ( + + +

    点击或拖拽动漫到此处播放

    +
    +) +
    + + +import { jotaiStore } from '@renderer/atoms/store' +import { ModalStackProvider } from '@renderer/components/ui/modal' +import { Toaster } from '@renderer/components/ui/toast' +import { isDev } from '@renderer/lib/env' +import queryClient from '@renderer/lib/query-client' +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { domMax, LazyMotion } from 'framer-motion' +import { Provider as JotaiProvider } from 'jotai' +import { ThemeProvider } from 'next-themes' +import type { FC, JSX, PropsWithChildren } from 'react' + +import { ProviderComposer } from './ProviderComposer' + +const contexts: JSX.Element[] = [ + , + , + , + // @ts-ignore + , + , +] +export const RootProviders: FC = ({ children }) => ( + + {children} + + + {isDev && } + +) + + + +import { toast } from '@renderer/components/ui/toast' +import { API_URL } from '@renderer/lib/env' +import { ofetch } from 'ofetch' + +const apiFetch = ofetch.create({ + baseURL: API_URL, + timeout: 10000, + onResponseError: (error) => { + switch (error.response.status) { + case 403: { + toast({ + title: '403 Forbidden', + description: `接口请求被拒绝, 请联系开发者更新相关秘钥 - ${error.response.url}`, + }) + break + } + case 404: { + break + } + default: { + toast({ + title: '接口请求失败', + description: error.request.toString(), + }) + } + } + }, + // onResponse: (response) => { + // const responseData = response.response._data + // if (responseData?.success === false) { + // toast({ + // description: responseData?.errorMessage, + // }) + // } + // }, +}) + +export const Get = (url: string, params?: object): Promise => + apiFetch(url, { query: params }) + +export const Post = (url: string, data?: object): Promise => + apiFetch(url, { method: 'POST', body: data }) + +export const Delete = (url: string, params?: object): Promise => + apiFetch(url, { method: 'DELETE', query: params }) + + + +import fs from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import react from '@vitejs/plugin-react' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import copy from 'rollup-plugin-copy' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const packageJson = JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf-8')) + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + '@main': resolve('src/main'), + '@renderer': resolve('src/renderer/src'), + '@pkg': resolve('./package.json'), + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + }, + renderer: { + resolve: { + alias: { + '@renderer': resolve('src/renderer/src'), + '@main': resolve('src/main'), + '@pkg': resolve('./package.json'), + }, + }, + plugins: [react()], + build: { + rollupOptions: { + plugins: [ + copy({ + targets: [ + { + src: 'node_modules/@jellyfin/libass-wasm/dist/js/subtitles-octopus-worker.wasm', + dest: 'out/renderer/assets', + }, + ], + hook: 'writeBundle', + }) as any, + ], + }, + }, + define: { + APP_NAME: JSON.stringify(packageJson.name), + }, + server: { + host: '0.0.0.0', + }, + }, +}) + + + +name: Deploy + +on: + push: + tags: + - v*.*.* + +jobs: + build: + name: Lint, Typecheck, Build, and Deploy + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + env: + VITE_API_URL: ${{ secrets.VITE_API_URL }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + lfs: true + + - name: Checkout LFS objects + run: git lfs checkout + + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Lint and Typecheck + run: | + pnpm run typecheck + pnpm run lint + + - name: Build project + run: pnpm run build:web + + - name: Archive production artifacts + run: tar -cvf build.tar -C ./out/web . + + - name: Deploy to remote server + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + run: | + echo "${{ secrets.DEPLOY_KEY }}" > deploy_key + chmod 600 deploy_key + scp -i deploy_key -o StrictHostKeyChecking=no build.tar $DEPLOY_USER@$DEPLOY_HOST:/opt/1panel/www/sites/marchen-play.suemor.com/index/ + ssh -i deploy_key -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_HOST "cd /opt/1panel/www/sites/marchen-play.suemor.com/index/ && tar -xvf build.tar && rm build.tar" + + + +import type { ReadStream } from 'node:fs' +import fs, { createReadStream, statSync } from 'node:fs' +import path from 'node:path' + +import { MARCHEN_PROTOCOL } from '@main/constants/protocol' + +import { isWindows } from './env' +import { fromFilename } from './mime-utils' + +export const handleCustomProtocol = (filePath: string, request: Request) => { + const extName = path.extname(filePath).toLowerCase() + switch (extName) { + case '.mp4': + case '.mkv': { + return handleVideoProtocol(filePath, request) + } + case '.ass': + case '.ssa': { + return handleSubtitleProtocol(filePath) + } + } + return new Response('Not Found', { status: 404 }) +} + +const handleSubtitleProtocol = (filePath: string) => { + const content = fs.readFileSync(filePath, 'utf-8') + return new Response(content, { + headers: { + 'Content-Type': 'text/plain', + }, + }) +} + +const handleVideoProtocol = (filePath: string, request: Request) => { + const makeUnsupportedRangeResponse = () => { + return new Response('unsupported range', { + status: 416, + }) + } + + const rangeHeader = request.headers.get('Range') + if (!rangeHeader?.startsWith('bytes=')) { + return makeUnsupportedRangeResponse() + } + + const stat = statSync(filePath) + + const startByte = Number(rangeHeader.match(/(\d+)-/)?.[1] || '0') + const endByte = Number(rangeHeader.match(/-(\d+)/)?.[1] || `${stat.size - 1}`) + + if (endByte > stat.size || startByte < 0) { + return makeUnsupportedRangeResponse() + } + const resultStream = createReadStream(filePath, { start: startByte, end: endByte }) + const headers = new Headers([ + ['Accept-Ranges', 'bytes'], + ['Content-Type', fromFilename(filePath) || 'video/mp4'], + ['Content-Length', `${endByte + 1 - startByte}`], + ['Content-Range', `bytes ${startByte}-${endByte}/${stat.size}`], + ]) + + return new Response(nodeStreamToWeb(resultStream), { headers, status: 206 }) +} + +const nodeStreamToWeb = (resultStream: ReadStream) => { + resultStream.pause() + + let closed = false + + return new ReadableStream( + { + start: (controller) => { + resultStream.on('data', (chunk) => { + if (closed) { + return + } + + if (Buffer.isBuffer(chunk)) { + controller.enqueue(new Uint8Array(chunk)) + } else { + controller.enqueue(chunk) + } + + if (controller.desiredSize !== null && controller.desiredSize <= 0) { + resultStream.pause() + } + }) + + resultStream.on('error', (error) => { + controller.error(error) + }) + + resultStream.on('end', () => { + if (!closed) { + closed = true + controller.close() + } + }) + }, + pull: (_controller) => { + if (closed) { + return + } + + resultStream.resume() + }, + cancel: () => { + if (!closed) { + closed = true + resultStream.close() + } + }, + }, + { highWaterMark: resultStream.readableHighWaterMark }, + ) +} + +export const getFilePathFromProtocolURL = (protocolUrl: string): string => { + if (!protocolUrl?.startsWith(MARCHEN_PROTOCOL)) { + return path.normalize(protocolUrl) + } + let filePath = '' + if (isWindows) { + filePath = decodeURIComponent(protocolUrl.slice(`${MARCHEN_PROTOCOL}://`.length)) + + // 优先判断是否为自定义盘符路径(如 z/code/... -> Z:\code\...) + if (/^[a-z]\/.+/i.test(filePath)) { + const driveLetter = filePath[0].toUpperCase() // 提取盘符并转换为大写 + filePath = `${driveLetter}:\\${filePath.slice(2).replaceAll('/', '\\')}` // 构造 Windows 格式路径 + } + // 判断是否为网络路径(主机名或 IP 地址) + else if ( + /^[a-z0-9][a-z0-9-]*(?:\/|$)/i.test(filePath) || + /^\d+\.\d+\.\d+\.\d+/.test(filePath) + ) { + filePath = `\\\\${filePath.replaceAll('/', '\\')}` // 转换为 UNC 路径格式 + } + // 如果路径以盘符开头,直接规范化 + else if (/^[a-z]:/i.test(filePath)) { + filePath = path.win32.normalize(filePath) + } + // 其他情况可能是相对路径,直接返回规范化结果 + else { + filePath = path.win32.normalize(filePath) + } + } else { + filePath = decodeURIComponent(protocolUrl.slice(`${MARCHEN_PROTOCOL}:/`.length)) + // 非 Windows 系统,直接返回路径 + filePath = path.normalize(filePath) + } + + return filePath +} + + + +import { getFilePathFromProtocolURL } from '@main/lib/protocols' +import { parseReleaseNotes } from '@main/lib/utils' +import { getMainWindow } from '@main/windows/main' +import { clearData } from '@main/windows/setting' +import { version } from '@pkg' +import { app, BrowserWindow, dialog } from 'electron' +import Logger from 'electron-log' +import updater from 'electron-updater' + +import { t } from './_instance' + +export const appRoute = { + windowAction: t.procedure + .input<{ + action: + | 'close' + | 'minimize' + | 'maximum' + | 'restart' + | 'reset' + | 'laungh-at-login' + | 'enter-full-screen' + | 'leave-full-screen' + | 'switch-full-screen' + | 'hidden-title-bar' + | 'show-title-bar' + | 'hidden-title-bar' + checked?: boolean + }>() + .action(async ({ context, input }) => { + const webcontent = context.sender + + const window = BrowserWindow.fromWebContents(webcontent) + if (!window) return + + switch (input.action) { + case 'close': { + window.close() + break + } + case 'minimize': { + window.minimize() + break + } + case 'maximum': { + if (window.isMaximized()) { + window.unmaximize() + } else { + window.maximize() + } + break + } + case 'restart': { + getMainWindow()?.reload() + break + } + case 'reset': { + clearData() + break + } + case 'laungh-at-login': { + app.setLoginItemSettings({ + openAtLogin: input.checked, + }) + break + } + case 'switch-full-screen': { + if (window.isFullScreen()) { + window.setFullScreen(false) + } else { + window.setFullScreen(true) + } + break + } + case 'enter-full-screen': { + window.setFullScreen(true) + break + } + case 'leave-full-screen': { + window.setFullScreen(false) + break + } + case 'hidden-title-bar': { + window?.setWindowButtonVisibility(false) + break + } + case 'show-title-bar': { + window?.setWindowButtonVisibility(true) + break + } + } + }), + checkUpdate: t.procedure.action(async () => { + try { + const updateCheckResult = await updater.autoUpdater.checkForUpdates() + if (updateCheckResult?.updateInfo.version === version) { + return dialog.showMessageBox({ + type: 'info', + message: '当前已是最新版本', + }) + } + + const releaseNotes = updateCheckResult?.updateInfo.releaseNotes + + const releaseContent = parseReleaseNotes(releaseNotes) + + dialog.showMessageBox({ + type: 'info', + detail: releaseContent, + message: '发现新版本,正在下载更新...', + }) + return releaseContent + } catch (error) { + Logger.error(['检查更新失败', error]) + return dialog.showMessageBox({ + type: 'warning', + detail: '请确保网络可以正常访问 Github', + message: '检查更新失败', + }) + } + }), + installUpdate: t.procedure.action(async () => { + updater.autoUpdater.quitAndInstall() + }), + confirmationDialog: t.procedure.input<{ title: string }>().action(async ({ input }) => { + const result = await dialog.showMessageBox({ + type: 'warning', + message: input.title, + buttons: ['取消', '确认'], + }) + return !!result.response + }), + addRecentDocument: t.procedure.input<{ path: string }>().action(async ({ input }) => { + const filePath = getFilePathFromProtocolURL(input.path) + app.addRecentDocument(filePath) + }), +} + + + +import { join } from 'node:path' + +import { is } from '@electron-toolkit/utils' +import { quickLaunchViaVideo } from '@main/lib/utils' +import { app, BrowserWindow, shell } from 'electron' + +import { getIconPath } from '../lib/icon' +import { getRendererHandlers } from './setting' + +const { platform } = process + +const isDev = process.env.NODE_ENV === 'development' + +const windows = { + mainWindow: null as BrowserWindow | null, +} + +globalThis['windows'] = windows +export default function createWindow() { + // Create the browser window. + const baseWindowsConfig: Electron.BrowserWindowConstructorOptions = { + width: 1200, + height: 900, + minWidth: 800, // 设置最小宽度 + minHeight: 650, // 设置最小高度 + show: false, + autoHideMenuBar: true, + webPreferences: { + preload: join(__dirname, '../preload/index.mjs'), + sandbox: false, + }, + } + switch (platform) { + case 'darwin': { + Object.assign(baseWindowsConfig, { + trafficLightPosition: { + x: 18, + y: 18, + }, + titleBarStyle: 'hiddenInset', + } as Electron.BrowserWindowConstructorOptions) + break + } + case 'win32': { + Object.assign(baseWindowsConfig, { + titleBarStyle: 'hidden', + backgroundMaterial: 'mica', + icon: getIconPath(), + } as Electron.BrowserWindowConstructorOptions) + break + } + default: { + Object.assign(baseWindowsConfig, { + icon: getIconPath(), + } as Electron.BrowserWindowConstructorOptions) + } + } + + windows.mainWindow = new BrowserWindow(baseWindowsConfig) + + const { mainWindow } = windows + mainWindow.webContents.userAgent = `MarchenPlayer/${app.getVersion()}` + initializeListeningEvent(mainWindow) + + // HMR for renderer base on electron-vite cli. + // Load the remote URL for development or the local html file for production. + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + return mainWindow +} + +export const getMainWindow = () => windows.mainWindow + +const initializeListeningEvent = (mainWindow: BrowserWindow) => { + mainWindow.on('ready-to-show', () => { + isDev ? mainWindow.showInactive() : mainWindow.show() + + // 当软件未运行时的情况下,通过视频快速启动 + quickLaunchViaVideo() + }) + + mainWindow.on('enter-full-screen', () => { + getRendererHandlers()?.windowAction.send('enter-full-screen') + }) + + mainWindow.on('leave-full-screen', () => { + getRendererHandlers()?.windowAction.send('leave-full-screen') + }) + + mainWindow.on('maximize', () => { + getRendererHandlers()?.windowAction.send('maximize') + }) + + mainWindow.on('unmaximize', () => { + getRendererHandlers()?.windowAction.send('unmaximize') + }) + + mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) +} + + + +import { + danmakuDurationList, + danmakuEndAreaList, + danmakuFontSizeList, + PlayerKernelList, +} from '@renderer/components/modules/settings/views/player/list' +import type { SelectGroup } from '@renderer/components/modules/shared/setting/SettingSelect' +import { useAtom, useAtomValue } from 'jotai' + +import { createSettingATom } from './helper' + +const getSelectedDefaultValue = (list: SelectGroup[]) => { + return list.find((item) => item.default)?.value +} + +const createPlayerDefaultSettings = () => { + return { + enableTraditionalToSimplified: false, + enableAutomaticEpisodeSwitching: false, + enableMiniProgress: true, + playerKernel: getSelectedDefaultValue(PlayerKernelList) ?? 'html5', + danmakuFontSize: getSelectedDefaultValue(danmakuFontSizeList) ?? '26', + danmakuDuration: getSelectedDefaultValue(danmakuDurationList) ?? '15000', + danmakuEndArea: getSelectedDefaultValue(danmakuEndAreaList)!, + } +} + +const atom = createSettingATom('player', createPlayerDefaultSettings) + +export const usePlayerSettings = () => useAtom(atom) +export const usePlayerSettingsValue = () => useAtomValue(atom) + + + +import { version } from '@pkg' +import { Logo } from '@renderer/components/icons/Logo' +import { Button } from '@renderer/components/ui/button' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import { useConfirmationDialog } from '@renderer/hooks/use-dialog' +import { tipcClient } from '@renderer/lib/client' +import { resetApp } from '@renderer/lib/ns' +import { cn, isWeb } from '@renderer/lib/utils' +import { useMutation } from '@tanstack/react-query' +import { useCallback } from 'react' + +import { FieldsCardLayout, SettingViewContainer } from '../Layout' + +export const AboutView = () => { + const { toast } = useToast() + const showConfirmationDialog = useConfirmationDialog() + + const { mutate, isPending } = useMutation({ + mutationFn: async () => tipcClient?.checkUpdate(), + }) + + const handleClearDanmakuCache = useCallback(async () => { + try { + await db.transaction('rw', db.history, async () => { + const allEntries = await db.history.toArray() + for (const entry of allEntries) { + await db.history.update(entry.hash, { danmaku: undefined }) + } + }) + toast({ + title: '清除弹幕缓存成功', + }) + } catch (error) { + console.error('Failed to clear danmaku field:', error) + toast({ + title: '清除弹幕缓存失败', + variant: 'destructive', + }) + } + }, [toast]) + return ( + + +
    +
    + +
    +

    Marchen

    +
    +

    当前版本: {version}

    +

    Copyright @ {new Date().getFullYear()} Suemor

    +
    +
    +
    + {!isWeb && ( + + )} +
    +
    + + +
    + {SocialMediaList.map((item) => ( + + ))} +
    +
    + + +
    + + + +
    +
    +
    + ) +} + +const SocialMediaList = [ + { + icon: 'icon-[mingcute--github-fill]', + name: 'Github', + link: 'https://github.com/marchen-dev/marchen-player', + }, + { + icon: 'icon-[mingcute--social-x-fill]', + name: 'X', + link: 'https://x.com/Suemor233', + }, + { + icon: 'icon-[mingcute--mail-fill]', + name: 'Email', + link: 'mailto:suemor233@outlook.com', + }, + { + icon: 'icon-[mingcute--telegram-fill]', + name: 'Telegram', + link: 'https://t.me/Suemor', + }, +] +
    + + +import { useAppSettings } from '@renderer/atoms/settings/app' +import { SettingSwitch } from '@renderer/components/modules/shared/setting/SettingSwitch' +import { tipcClient } from '@renderer/lib/client' +import { isWeb } from '@renderer/lib/utils' + +import { FieldLayout, FieldsCardLayout, SettingViewContainer } from '../Layout' +import { DarkModeToggle } from './DarkMode' + +export const GeneralView = () => { + const [appSettings, setAppSettings] = useAppSettings() + return ( + + {!isWeb && ( + + + { + await tipcClient?.windowAction({ action: 'laungh-at-login', checked }) + setAppSettings((prev) => ({ ...prev, launchAtLogin: checked })) + }} + /> + + + )} + + + + + + + {!isWeb && ( + + + setAppSettings((prev) => ({ ...prev, showPoster: checked })) + } + /> + + )} + + + ) +} + + + +import { useAppSettings, useAppSettingsValue } from '@renderer/atoms/settings/app' +import { RouterLayout } from '@renderer/components/layout/root/RouterLayout' +import { showMatchAnimeDialog } from '@renderer/components/modules/core/html5-player/loading/dialog/hooks' +import { MatchDanmakuDialog } from '@renderer/components/modules/shared/MatchDanmakuDialog' +import { Badge } from '@renderer/components/ui/badge' +import { FunctionAreaButton, FunctionAreaToggle } from '@renderer/components/ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@renderer/components/ui/menu' +import { ScrollArea } from '@renderer/components/ui/scrollArea' +import { useToast } from '@renderer/components/ui/toast' +import { db } from '@renderer/database/db' +import type { DB_History } from '@renderer/database/schemas/history' +import { useConfirmationDialog } from '@renderer/hooks/use-dialog' +import { relativeTimeToNow } from '@renderer/initialize/date' +import { cn, isWeb } from '@renderer/lib/utils' +import { RouteName } from '@renderer/router' +import { useLiveQuery } from 'dexie-react-hooks' +import type { Variants } from 'framer-motion' +import { m } from 'framer-motion' +import type { FC } from 'react' +import { memo, useMemo, useState } from 'react' +import { useNavigate } from 'react-router' + +export default function History() { + const historyData = useLiveQuery(() => + db.history.orderBy('updatedAt').limit(30).reverse().toArray(), + ) + const appSettings = useAppSettingsValue() + const showPoster = appSettings.showPoster || isWeb + return ( + }> + + {historyData?.length !== 0 ? ( +
      + {historyData?.map((item) => ( + + ))} +
    + ) : ( +
    + +

    没有内容

    +
    + )} +
    + +
    + ) +} + +interface HistoryItemProps extends DB_History { + showPoster: boolean +} + +const hoverVariant: Variants = { + icon: { + opacity: 1, + }, + img: { + opacity: 0.8, + scale: 1.05, + }, +} + +const HistoryItem: FC = memo((props) => { + const { + cover, + animeTitle, + episodeTitle, + progress, + duration, + episodeId, + thumbnail, + showPoster, + updatedAt, + hash, + } = props + const navigation = useNavigate() + const { toast } = useToast() + + const percentage = useMemo(() => { + const percentage = progress / duration + return Number.isFinite(percentage) && !Number.isNaN(percentage) + ? Math.round(percentage * 100) + : 0 + }, [progress, duration]) + + const playAnime = () => { + if (isWeb) { + return toast({ + title: '请使用客户端播放', + description: 'WEB 版本暂时不支持续播功能', + duration: 5000, + }) + } + return navigation(RouteName.PLAYER, { state: { episodeId, hash } }) + } + return ( + + +
  • + + + {!isWeb && ( + + )} +
    + +
    +

    + {animeTitle} +

    +
    + {episodeTitle || '暂无弹幕库'} +
    + {relativeTimeToNow(updatedAt)} +
    +
    +
    +
  • +
    + + 继续观看 + + { + showMatchAnimeDialog(true, hash) + }} + > + 重新匹配弹幕库 + + db.history.update(hash, { danmaku: undefined })}> + 清除弹幕缓存 + + + db.history.delete(hash)}> + 删除 + + +
    + ) +}) + +const FunctionArea = memo(() => { + const [appSettings, setAppSettings] = useAppSettings() + const present = useConfirmationDialog() + + return ( +
    + {!isWeb && ( + + setAppSettings((old) => ({ + ...old, + showPoster: value, + })) + } + > + + + )} + + present({ + title: '是否删除历史记录?', + handleConfirm: () => { + db.history.clear() + }, + }) + } + > + + +
    + ) +}) + +interface HistoryImageProps { + src?: string +} + +const HistoryImage: FC = (props) => { + const { src } = props + const [imgError, setImgError] = useState(false) + return ( + + {!src || imgError ? ( +
    + +
    + ) : ( + { + setImgError(true) + }} + /> + )} +
    + ) +} +
    + + +import { updateProgressAtom } from '@renderer/atoms/progress' +import type { useAppSettingsValue } from '@renderer/atoms/settings/app' +import { appSettingAtom } from '@renderer/atoms/settings/app' +import { jotaiStore } from '@renderer/atoms/store' +import { WindowState, windowStateAtom } from '@renderer/atoms/window' +import { useVideo } from '@renderer/components/modules/core/html5-player/loading/hooks' +import { useSettingModal } from '@renderer/components/modules/settings/hooks' +import { settingTabs } from '@renderer/components/modules/settings/tabs' +import { toast } from '@renderer/components/ui/toast/use-toast' +import { handlers } from '@renderer/lib/client' +import { getStorageNS } from '@renderer/lib/ns' +import { RouteName } from '@renderer/router' +import { useEffect } from 'react' +import { useNavigate } from 'react-router' + +export const TipcListener = () => { + const showModal = useSettingModal() + const { importAnimeViaIPC } = useVideo() + const navigation = useNavigate() + useEffect(() => { + const unlisten = [ + handlers?.showSetting.listen(() => { + // 防止关闭窗口过程中,再次打开窗口,导致窗口无法打开 + const timeoutId = setTimeout(() => { + showModal() + }, 10) + return () => clearTimeout(timeoutId) + }), + + handlers?.showSetting.listen((tab) => { + const showTab = settingTabs.find((settingTab) => settingTab.title === tab) + + return showModal({ settingTabsModel: showTab }) + }), + + handlers?.importAnime.listen((params) => { + navigation(RouteName.PLAYER) + importAnimeViaIPC({ path: params?.path }) + }), + handlers?.updateProgress.listen((params) => { + jotaiStore.set(updateProgressAtom, { progress: params.progress, status: params.status }) + }), + handlers?.getReleaseNotes.listen((text) => { + try { + const appDataString = localStorage.getItem(getStorageNS('app')) + const appData = appDataString + ? (JSON.parse(appDataString) as ReturnType) + : null + + if (appData?.showUpdateNote) { + toast({ + title: '更新成功 🎉', + description: ( +
    + {text.split('\n').map((line) => ( +

    {line}

    + ))} +
    + ), + duration: 10000, + }) + jotaiStore.set(appSettingAtom, { ...appData, showUpdateNote: false }) + } + } catch (error) { + console.error(error) + } + }), + handlers?.windowAction.listen((action) => { + switch (action) { + case 'maximize': { + jotaiStore.set(windowStateAtom, WindowState.MAXIMIZED) + break + } + case 'unmaximize': { + jotaiStore.set(windowStateAtom, WindowState.NORMAL) + break + } + } + }), + ] + + return () => { + unlisten?.forEach((fn) => fn?.()) + } + }, [showModal]) + return null +} +
    + + +name: Build/release Electron app + +on: + push: + tags: + - v*.*.* + +jobs: + release: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [20.x] + + env: + VITE_API_URL: ${{ secrets.VITE_API_URL }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + lfs: true + + - name: Checkout LFS objects + run: git lfs checkout + + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install + + - name: Install snapcraft + if: matrix.os == 'ubuntu-latest' + run: sudo snap install snapcraft --classic + + - name: Build for Linux + if: matrix.os == 'ubuntu-latest' + run: pnpm run build:linux + + - name: Build for macOS + if: matrix.os == 'macos-latest' + run: pnpm run build:mac + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_BUNDLE_ID: ${{ secrets.APPLE_APP_BUNDLE_ID }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + + - name: Build for Windows + if: matrix.os == 'windows-latest' + run: pnpm run build:win + + - name: Generate Changelog + if: github.event_name == 'push' + run: npx changelogithub@13.13.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Release + uses: softprops/action-gh-release@v2.2.1 + with: + draft: false + prerelease: true + files: | + dist/*.exe + dist/*.zip + dist/*.dmg + dist/*.AppImage + dist/*.snap + dist/*.deb + dist/*.rpm + dist/*.tar.gz + dist/latest*.yml + + + +import fs from 'node:fs' +import path from 'node:path' + +import ffmpegPath from '@ffmpeg-installer/ffmpeg' +import ffprobePath from '@ffprobe-installer/ffprobe' +import { createStorageFolder, screenshotsPath, subtitlesPath } from '@main/constants/app' +import ffmpeg from 'fluent-ffmpeg' + +ffmpeg.setFfmpegPath(ffmpegPath.path.replace('app.asar', 'app.asar.unpacked')) +ffmpeg.setFfprobePath(ffprobePath.path.replace('app.asar', 'app.asar.unpacked')) + +export default class FFmpeg { + ffmpeg: ffmpeg.FfmpegCommand + + constructor(inputPath: string) { + createStorageFolder() + this.ffmpeg = ffmpeg(inputPath) + } + + grabFrame = (time: string): Promise => { + const fileName = `${Date.now()}.jpeg` + const fullPath = path.join(screenshotsPath(), fileName) + return new Promise((resolve, reject) => { + if (!fs.existsSync(screenshotsPath())) { + fs.mkdirSync(screenshotsPath(), { recursive: true }) + } + this.ffmpeg + .screenshots({ + timestamps: [time], + filename: fileName, + folder: screenshotsPath(), + size: '640x360', // 可根据需要调整尺寸 + }) + .on('end', () => { + try { + const data = fs.readFileSync(fullPath) + const base64Image = `data:image/jpeg;base64,${data.toString('base64')}` + fs.unlinkSync(fullPath) + resolve(base64Image) + } catch (error) { + reject(error) + } + }) + .on('error', (err) => { + reject(err?.message) + }) + }) + } + + coverToAssSubtitle = (): Promise<{ fileName: string; filePath: string }> => { + const fileName = `${Date.now()}.ass` + const outPutPath = path.join(subtitlesPath(), fileName) + return new Promise((resolve, reject) => { + this.ffmpeg + .outputOptions('-c:s ass') + .save(outPutPath) + .on('end', () => { + resolve({ filePath: outPutPath, fileName }) + }) + .on('error', (err) => { + reject(err) + }) + }) + } + + getSubtitlesIntroFromAnime = (): Promise => { + return new Promise((resolve) => { + ffmpeg.ffprobe(this.ffmpeg._inputs[0].source, (err, metadata) => { + if (err) { + resolve([]) + } + const subtitleStreams = metadata.streams.filter( + (stream) => stream.codec_type === 'subtitle', + ) + if (subtitleStreams.length === 0) { + resolve([]) + } + + return resolve(subtitleStreams) + }) + }) + } + + extractSubtitles = async (index: number): Promise => { + if (!fs.existsSync(subtitlesPath())) { + fs.mkdirSync(subtitlesPath(), { recursive: true }) + } + + const fileName = `${Date.now()}-${index}.ass` + const outputPath = path.join(subtitlesPath(), fileName) + return new Promise((resolve, reject) => { + this.ffmpeg + .clone() // Ensure a new instance for each command + .outputOptions(['-map', `0:s:${index}`, '-c:s', 'ass']) + .save(outputPath) + .on('end', () => { + resolve(outputPath) + }) + .on('error', async (ffmpegError) => { + ffmpeg.ffprobe(this.ffmpeg._inputs[0].source, (err, metadata) => { + if (err) { + return reject(err) + } + + const subtitleStream = metadata.streams + .filter((stream) => stream.codec_type === 'subtitle') + .at(index) + + if (!subtitleStream) { + return reject(new Error('解析内嵌字幕发生错误')) + } + // 检查是否为 PGS 字幕 + if ( + subtitleStream.codec_name?.toLowerCase()?.includes('pgs') || + subtitleStream.codec_tag_string?.toLowerCase()?.includes('pgs') + ) { + return reject(new Error('不支持加载「位图字幕」')) + } + reject(ffmpegError) + }) + }) + }) + } +} + + + +import fs from 'node:fs' +import path from 'node:path' + +import { MARCHEN_PROTOCOL_PREFIX } from '@main/constants/protocol' +import { parseBilibiliDanmaku } from '@main/lib/danmaku' +import FFmpeg from '@main/lib/ffmpeg' +import { getFilePathFromProtocolURL } from '@main/lib/protocols' +import { coverSubtitleToAss } from '@main/lib/utils' +import { showFileSelectionDialog } from '@main/modules/showDialog' +import { calculateFileHashByBuffer } from '@renderer/lib/calc-file-hash' +import { dialog } from 'electron' +import naturalCompare from 'string-natural-compare' + +import { t } from './_instance' + +let isDialogOpen = false + +export const playerRoute = { + showWarningDialog: t.procedure + .input<{ title: string; content: string }>() + .action(async ({ input }) => + dialog.showMessageBoxSync({ + message: input.title, + detail: input.content, + type: 'warning', + }), + ), + getAnimeDetailByPath: t.procedure.input<{ path: string }>().action(async ({ input }) => { + try { + let animePath = input.path + if (animePath.startsWith(MARCHEN_PROTOCOL_PREFIX)) { + animePath = getFilePathFromProtocolURL(animePath) + } + if (!animePath || !fs.existsSync(animePath)) { + return { + ok: 0, + message: '视频文件可能被移动,无法继续播放', + } + } + const stats = fs.statSync(animePath) + const fileName = path.basename(animePath) + const fileSize = stats.size + + const bufferSize = Math.min(fileSize, 16 * 1024 * 1024) + const buffer = Buffer.alloc(bufferSize) + const fd = fs.openSync(animePath, 'r') + fs.readSync(fd, buffer, 0, bufferSize, 0) + fs.closeSync(fd) + + const fileHash = await calculateFileHashByBuffer(buffer) + return { + ok: 1, + fileSize, + fileName, + fileHash, + filePath: `${MARCHEN_PROTOCOL_PREFIX}${animePath}`, + } + } catch { + return { + ok: 0, + message: '获取视频信息失败', + } + } + }), + grabFrame: t.procedure.input<{ path: string; time: string }>().action(async ({ input }) => { + let filePath = input.path + if (filePath.startsWith(MARCHEN_PROTOCOL_PREFIX)) { + filePath = getFilePathFromProtocolURL(filePath) + } + const ffmpeg = new FFmpeg(filePath) + const base64Image = (await ffmpeg.grabFrame(input.time)) as string + return base64Image + }), + importAnime: t.procedure.action(async () => { + // 确保不重复打开对话框 + if (isDialogOpen) { + return + } + + isDialogOpen = true + + try { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: '视频文件', extensions: ['mp4', 'mkv'] }], + }) + + if (result.canceled) { + return + } + + const selectedFilePath = result.filePaths[0] + const selectedFileExtname = path.extname(selectedFilePath) + + if (selectedFileExtname !== '.mp4' && selectedFileExtname !== '.mkv') { + return + } + + return selectedFilePath + } finally { + isDialogOpen = false + } + }), + getAnimeInSamePath: t.procedure.input<{ path: string }>().action(async ({ input }) => { + let selectedFilePath = input.path + if (selectedFilePath.startsWith(MARCHEN_PROTOCOL_PREFIX)) { + selectedFilePath = getFilePathFromProtocolURL(selectedFilePath) + } + const selectedFileExtname = path.extname(selectedFilePath) + if (selectedFileExtname !== '.mp4' && selectedFileExtname !== '.mkv') { + return [] + } + + const selectedFileDirname = path.dirname(selectedFilePath) + + // 读取路径下所有同后缀的文件 + const fileNameWithSameSuffix = fs + .readdirSync(selectedFileDirname) + .filter((file) => path.extname(file).toLowerCase() === selectedFileExtname) + + const filePathWithSameSuffix = fileNameWithSameSuffix.map((fileName) => + path.join(selectedFileDirname, fileName), + ) + // 按文件名自然排序 + filePathWithSameSuffix.sort(naturalCompare) + + const playList = filePathWithSameSuffix.map((filePath) => ({ + urlWithPrefix: `${MARCHEN_PROTOCOL_PREFIX}${filePath}`, + name: path.basename(filePath), + })) + + return playList + }), + importSubtitle: t.procedure.action(async () => { + const filePath = await showFileSelectionDialog({ + filters: [{ name: '字幕文件', extensions: ['srt', 'ass', 'ssa', 'vtt'] }], + }) + if (!filePath) { + return + } + return coverSubtitleToAss(filePath) + }), + getSubtitlesIntroFromAnime: t.procedure.input<{ path: string }>().action(async ({ input }) => { + const ffmpeg = new FFmpeg(getFilePathFromProtocolURL(input.path)) + const subtitles = await ffmpeg.getSubtitlesIntroFromAnime() + return subtitles + }), + getSubtitlesBody: t.procedure + .input<{ path: string; index: number }>() + .action(async ({ input }) => { + try { + const ffmpeg = new FFmpeg(getFilePathFromProtocolURL(input.path)) + const data = await ffmpeg.extractSubtitles(input.index) + return { + ok: 1, + data, + } + } catch (error: any) { + return { + ok: 0, + message: error?.message || '', + } + } + }), + matchSubtitleFile: t.procedure.input<{ path: string }>().action(async ({ input }) => { + const filePath = getFilePathFromProtocolURL(input.path) + if (!fs.existsSync(filePath)) { + return + } + const filePrefix = path.basename(filePath).split('.')[0] + const directoryPath = path.dirname(filePath) + + const matchedFiles = fs + .readdirSync(path.dirname(filePath)) + .filter((file) => file.startsWith(filePrefix) && file !== path.basename(filePath)) + .map((file) => ({ + fileName: file, + filePath: path.join(directoryPath, file), + })) + + return matchedFiles + }), + immportDanmakuFile: t.procedure.action(async () => { + // 确保不重复打开对话框 + if (isDialogOpen) { + return + } + + isDialogOpen = true + try { + const filePath = await showFileSelectionDialog({ + filters: [{ name: '弹幕文件', extensions: ['xml', 'json'] }], + }) + if (!filePath) { + return + } + const extName = path.extname(filePath).toLowerCase() + if (extName !== '.xml' && extName !== '.json') { + return { + ok: 0, + message: '请选择正确的弹幕文件', + } + } + const fileData = fs.readFileSync(filePath, 'utf-8') + return { + ok: 1, + data: { + danmaku: await parseBilibiliDanmaku({ + fileData, + type: extName, + }), + source: filePath, + }, + } + } catch { + return { + ok: 0, + message: '解析弹幕文件失败', + } + } finally { + isDialogOpen = false + } + }), +} + + + +# Marchen Player + +Marchen Player 是本地视频弹幕播放器,拖入动漫视频即可匹配对应的弹幕。 + +采用 Electron 开发,支持 **Web, macOS, Windows, Linux** 四个版本,目前主要适配 **macOS** 版本。 + +[在线体验](https://marchen-play.suemor.com) | [下载客户端](https://github.com/marchen-dev/marchen-player/releases/latest) + +## ✨ 特征 + +- [x] 导入动漫自动匹配弹幕 +- [x] 支持设置弹幕字体大小、持续时间、显示区域 +- [x] 支持手动添加第三方弹幕网址 +- [x] 支持导入本地 XML 和 JSON 弹幕文件 +- [x] 支持对不同平台的弹幕进行单独的开关 +- [x] 支持弹幕缓存,加快弹幕加载速度 +- [x] 支持弹幕繁体转简体 +- [x] 自动安装更新,无需手动下载安装 +- [x] 跨平台,支持 macOS Windows Linux Web 版本 +- [x] 支持白天夜间模式,可以跟随系统自动切换 +- [x] 支持解析视频内嵌字幕和导入本地字幕 +- [x] 支持修改匹配的弹幕库 +- [x] 还算不错的 UI 设计 +- [x] 播放记录界面可以显示播放进度和对应的画面 + +## 👀 截图 + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/CleanShot%202024-11-21%20at%2019.38.37%402x.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/CleanShot%202024-11-21%20at%2019.41.34%402x.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/202501061557157.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/202501061604943.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/202501061604942.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/CleanShot%202024-11-21%20at%2019.40.33%402x.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/CleanShot%202024-11-21%20at%2019.39.05%402x.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/CleanShot%202024-11-21%20at%2019.39.09%402x.png) + +![](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/202501292219389.png) + +## 🔧 开发 + +```bash +$ corepack enable + +$ git clone https://github.com/marchen-dev/marchen-player.git + +$ pnpm install + +$ cp .env.example .env + +$ pnpm dev +``` + +## 📎 技术栈 + +- Electron +- React +- TypeScript +- Tailwind CSS +- Jotai +- shadcn/ui +- TanStack Query +- Framer motion +- xgplayer + +## ❤️ 致谢 & 许可 + +- [弹弹play](https://www.dandanplay.com) +- [xgplayer](https://github.com/bytedance/xgplayer) + +[![AGPLv3 License](https://img.shields.io/badge/License-AGPLv3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) + + + +# CHANGELOG + +# [0.1.0](https://github.com/marchen-dev/marchen-player/compare/v0.1.0-alpha.0...v0.1.0) (2025-08-30) + + +### Bug Fixes + +* ci ([4611825](https://github.com/marchen-dev/marchen-player/commit/4611825767a4d220d05b519aaffb9ee207d53115)) +* danmaku 404 causing the video to fail to load ([728f761](https://github.com/marchen-dev/marchen-player/commit/728f7613aed69ba418c4927c60f2e1062841fbfe)) +* loading flicker issue ([a0e0410](https://github.com/marchen-dev/marchen-player/commit/a0e04105952e6aa0cc4742a3a81b3a6ab125f109)) +* reduce pop-up notifications ([b0f11d8](https://github.com/marchen-dev/marchen-player/commit/b0f11d8957b42768b308f43bb96e6461170b158e)) + + +### Features + +* add release notes ([d57f4f8](https://github.com/marchen-dev/marchen-player/commit/d57f4f8ff619b59263e9c5c3baf19c319ac6abd3)) + + + +# [0.1.0-alpha.0](https://github.com/marchen-dev/marchen-player/compare/v0.0.4...v0.1.0-alpha.0) (2025-04-05) + + +### Features + +* add release note ([5702432](https://github.com/marchen-dev/marchen-player/commit/570243294d231eae6c7618606e514882a0f7bffe)) +* enable hardware decoding on linux ([82d08ca](https://github.com/marchen-dev/marchen-player/commit/82d08caccf82e847174fb666e5e3977c67200259)) + + + +## [0.0.4](https://github.com/marchen-dev/marchen-player/compare/v0.0.3...v0.0.4) (2025-03-26) + + +### Bug Fixes + +* ci ([4a5da4d](https://github.com/marchen-dev/marchen-player/commit/4a5da4d20abd2607cd1cb940f60ad2fdda1b4714)) +* insufficient permissions for ffprobe in linux ([12ef620](https://github.com/marchen-dev/marchen-player/commit/12ef620852294621a8091fef228b7bc4ef59f6d4)) + + +### Features + +* add release note ([852dc5c](https://github.com/marchen-dev/marchen-player/commit/852dc5c73778080aecbd112260a8127e6f3d0a9b)) +* enable hardware decoding on linux ([b8cfbdc](https://github.com/marchen-dev/marchen-player/commit/b8cfbdc8fa8f0aafc87238edf94f02f77223e242)) + + + +## [0.0.3](https://github.com/marchen-dev/marchen-player/compare/v0.0.2...v0.0.3) (2025-03-16) + + +### Bug Fixes + +* ci ([f1d1b7a](https://github.com/marchen-dev/marchen-player/commit/f1d1b7a8ea238e9a7813c84cfaa165acfe00e7ac)) +* subtitles disappearing when resizing the window ([eb1c6cf](https://github.com/marchen-dev/marchen-player/commit/eb1c6cf51ca290051c595dac6af2a3bdce9ffd82)) + + +### Features + +* add pnpm onlyBuiltDependencies ([dc3cba6](https://github.com/marchen-dev/marchen-player/commit/dc3cba62c886b9476a93fb9c1af6341db3e2cc06)) +* add subtitle parsing error prompt ([1f7d199](https://github.com/marchen-dev/marchen-player/commit/1f7d199956a89c981929f3eccc5332ff052e54ab)) + + + +## [0.0.2](https://github.com/marchen-dev/marchen-player/compare/v0.0.1...v0.0.2) (2025-02-26) + + +### Bug Fixes + +* add fallback subtitle font ([ee47ffb](https://github.com/marchen-dev/marchen-player/commit/ee47ffbfda09756852aca996a529240942b5bd28)) +* embedded subtitles were loaded multiple times ([74709a2](https://github.com/marchen-dev/marchen-player/commit/74709a25d26c620c7e5593ce4c6bc104d7180273)) + + +### Features + +* web version adds reset webpage ([7e00d20](https://github.com/marchen-dev/marchen-player/commit/7e00d20673106768c395d9d4bb4329c27ecd6a1d)) + + + +## [0.0.1](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.8...v0.0.1) (2025-02-20) + + +### Bug Fixes + +* network path beginning in English cannot be recognized ([967e5a0](https://github.com/marchen-dev/marchen-player/commit/967e5a0273b03684609fdfe3ef10d0c4983a2381)) + + +### Features + +* support converting traditional chinese danmakus to simplified chinese ([0327f34](https://github.com/marchen-dev/marchen-player/commit/0327f34620e7d13867003fa3fcd23ffb852c213d)) +* support parsing json format danmaku files ([90f6aba](https://github.com/marchen-dev/marchen-player/commit/90f6abac7be7430ff81469d8f55ff53b73facbad)) + + + +## [0.0.1-beta.8](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.7...v0.0.1-beta.8) (2025-02-16) + + +### Bug Fixes + +* sentry initialization ([44112c7](https://github.com/marchen-dev/marchen-player/commit/44112c7a5f834d8dd8ec0f210acb76b7427485a6)) +* windows network path is not recognized ([a60854d](https://github.com/marchen-dev/marchen-player/commit/a60854d29a50c5b9ee0d88d3a5d5a8f03b6e64dd)) + + +### Features + +* add sentry's react router integration ([06680ce](https://github.com/marchen-dev/marchen-player/commit/06680ce5c6cd5e8ee0c3b34d9668c89d093572e3)) + + + +## [0.0.1-beta.7](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.6...v0.0.1-beta.7) (2025-02-11) + + +### Bug Fixes + +* unable to open video settings and subtitle matching failed ([4836f98](https://github.com/marchen-dev/marchen-player/commit/4836f9825525768c277418b42ed85d18dc770712)) +* unable to uncheck the source of dandanplay ([5477b4a](https://github.com/marchen-dev/marchen-player/commit/5477b4a95e38d6eadd1eec5a6f769cdf3d471c39)) + + +### Features + +* add loading subtitle prompt in the video sheet ([0ee5657](https://github.com/marchen-dev/marchen-player/commit/0ee565746429fcb9970fe20dd79b2a90bed50025)) +* add update error message ([fade5d4](https://github.com/marchen-dev/marchen-player/commit/fade5d4a02951ada3746218e19f9ef87fa09bf55)) +* allow to enable or disable mini progress ([bdbc818](https://github.com/marchen-dev/marchen-player/commit/bdbc818b930e769bdd17dbfd36835b472f762b36)) + + + +## [0.0.1-beta.6](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.5...v0.0.1-beta.6) (2025-02-08) + + +### Bug Fixes + +* load danmaku error ([849e6d3](https://github.com/marchen-dev/marchen-player/commit/849e6d348dd4f4d3f20c5a9dfa1ab1d7723f0ce5)) +* react query enable key ([31da782](https://github.com/marchen-dev/marchen-player/commit/31da78215ccd522477166d9310938e71954691db)) + + +### Features + +* add a button to clear danmaku cache ([8f8ee26](https://github.com/marchen-dev/marchen-player/commit/8f8ee260dcea4db17f745fba187ab0ba4e5ba94e)) +* add launch at login setting ([914938e](https://github.com/marchen-dev/marchen-player/commit/914938e67eb806b400bdd6a1eddf76f1460f9832)) +* add show post setting ([4c9e561](https://github.com/marchen-dev/marchen-player/commit/4c9e5611a63afc5662e1283bb55acc504c964d1e)) +* support danmaku caching ([de3af5e](https://github.com/marchen-dev/marchen-player/commit/de3af5e9f2c2265ed575d0c71f2f3eadf7ca0acc)) + + + +## [0.0.1-beta.5](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.4...v0.0.1-beta.5) (2025-01-27) + + +### Bug Fixes + +* repeated danmaku problem ([b949f69](https://github.com/marchen-dev/marchen-player/commit/b949f694c403f84ae7a952acb83bf3143e5b5d32)) +* setting dialog error ([9207258](https://github.com/marchen-dev/marchen-player/commit/92072582f0c908977cb026f0cb2709d0dc19df80)) + + +### Features + +* add release notes ([d264049](https://github.com/marchen-dev/marchen-player/commit/d2640491a0569178e4131ab243abd62ec532d710)) +* add request error message ([874635a](https://github.com/marchen-dev/marchen-player/commit/874635af2526f4178205996e54799ce1681143a5)) +* remove electron unhandled ([a225e55](https://github.com/marchen-dev/marchen-player/commit/a225e55f00ec0e6c3a728f3b0b4cd0cc7f61d69c)) +* use next theme ([f9f6c9c](https://github.com/marchen-dev/marchen-player/commit/f9f6c9c318b185ffc940184c50839e5c4621472f)) + + + +## [0.0.1-beta.4](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.3...v0.0.1-beta.4) (2025-01-16) + + +### Bug Fixes + +* history records lost ([1106edb](https://github.com/marchen-dev/marchen-player/commit/1106edb46978c15527145fc70b076ebc90dbd6c5)) +* playlist title cannot be displayed ([fca55ae](https://github.com/marchen-dev/marchen-player/commit/fca55ae90d65770b6d93e65a55169ea8050c701e)) + + +### Features + +* add ci secrets ([c8eeeb5](https://github.com/marchen-dev/marchen-player/commit/c8eeeb54343af08881b55569a794ccddad64771b)) +* add social media button ([1f90654](https://github.com/marchen-dev/marchen-player/commit/1f9065442566519f4f89e64412ec5595a4c56b28)) +* optimize icon click effect ([2cf1398](https://github.com/marchen-dev/marchen-player/commit/2cf139859967f94a2541b28c27bc46c4253c2c11)) +* support automatically mount subtitle files in the folder ([5c066e1](https://github.com/marchen-dev/marchen-player/commit/5c066e193a88bdf34a08fc18501c41edc56b2ef5)) +* support clear danmaku cache ([ce97d2c](https://github.com/marchen-dev/marchen-player/commit/ce97d2c38f2a9e574aae251fe3fd27e0e8ada1a4)) + + + +## [0.0.1-beta.3](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.2...v0.0.1-beta.3) (2025-01-08) + + +### Bug Fixes + +* adjust windows titlebar when playing ([57ef400](https://github.com/marchen-dev/marchen-player/commit/57ef400779da4f6edc04be7692f255e3b6684c11)) +* manual danmaku cannot be displayed ([4c9d039](https://github.com/marchen-dev/marchen-player/commit/4c9d039fedb2b9873f305afd675dae10dc9b3e36)) + + +### Features + +* add auto play next setting ([bd68742](https://github.com/marchen-dev/marchen-player/commit/bd687426f7d2820cb25a51cd086536f4381ee810)) +* add user-select none ([dde17c7](https://github.com/marchen-dev/marchen-player/commit/dde17c700c1580f9e29676a59d2511f7303586d9)) +* support rematch danmaku ([310f717](https://github.com/marchen-dev/marchen-player/commit/310f717f2e1883c384b472a2265ddd979bfb034f)) + + + +## [0.0.1-beta.2](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.1...v0.0.1-beta.2) (2024-12-27) + + +### Bug Fixes + +* ci ([4534b6a](https://github.com/marchen-dev/marchen-player/commit/4534b6a3bb5e3009f3d057e972c0f419dab5fff9)) +* ci ([dd4d4ba](https://github.com/marchen-dev/marchen-player/commit/dd4d4bac5a5a22cc9c65231ca1ed5aeef827419f)) +* subtitle loading not displaying ([b1020fa](https://github.com/marchen-dev/marchen-player/commit/b1020faa1276c5e85cff7d2ac010a8300daffe38)) + + +### Features + +* add bug template for github issues ([a4aad7b](https://github.com/marchen-dev/marchen-player/commit/a4aad7b8ba6f7a5d7db3347034cc9a79992177bc)) +* add import button ([9729c66](https://github.com/marchen-dev/marchen-player/commit/9729c66439aecb4c952f49ae68e8bae1ca5fac75)) +* can save the last danmaku settings when loading animation ([0057e61](https://github.com/marchen-dev/marchen-player/commit/0057e6159800d64057063f239efedb8c1c06a990)) +* import danmaku from local XML file ([be7c623](https://github.com/marchen-dev/marchen-player/commit/be7c623b3ec54483e557450cafa80d6b176af5b0)) +* Import danmaku through address ([a7ed89b](https://github.com/marchen-dev/marchen-player/commit/a7ed89bf831a89cc484d89cdcbf56c13ab54c142)) +* Import danmaku through address ([b6e6476](https://github.com/marchen-dev/marchen-player/commit/b6e6476b92df7ea3b569244c113851be5e1b6ae3)) +* load multiple platform danmaku ([b5fe8b4](https://github.com/marchen-dev/marchen-player/commit/b5fe8b46e69d647de49b9406690d7c919d827b63)) +* support dock to display recently played files ([5fbf397](https://github.com/marchen-dev/marchen-player/commit/5fbf39752985601f6bdabb5fabd77e7ac0153fb8)) +* support switching danmaku on different platforms ([cdb3e24](https://github.com/marchen-dev/marchen-player/commit/cdb3e2410adbcf94871e5b25165c543288424028)) + + +### Performance Improvements + +* optimize the loading of danmaku process ([554681c](https://github.com/marchen-dev/marchen-player/commit/554681c59d374bcbe3e0a6917da2ba59e89bc2e7)) +* optimize the loading of danmaku process ([13427a9](https://github.com/marchen-dev/marchen-player/commit/13427a9d8799a6f53a62123df1bb6aff1f2083ec)) + + + +## [0.0.1-beta.1](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-beta.0...v0.0.1-beta.1) (2024-12-10) + + +### Bug Fixes + +* ci ([cd9473b](https://github.com/marchen-dev/marchen-player/commit/cd9473be23061930891ca3cc5e3c745c6346dbf6)) +* select cannot expand ([3bdace1](https://github.com/marchen-dev/marchen-player/commit/3bdace11bfb2e8f6795ca1121b5633c8706219db)) + + +### Features + +* support subtitle time offset ([e6062da](https://github.com/marchen-dev/marchen-player/commit/e6062dab9e85e4a9a2c149f7482537abc0a3b478)) + + + +## [0.0.1-beta.0](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.11...v0.0.1-beta.0) (2024-12-08) + + +### Bug Fixes + +* danmaku offset when continuing to play ([6ba001c](https://github.com/marchen-dev/marchen-player/commit/6ba001cb0368d1298caa6cdc486aa110c8ce0e23)) +* player settings content is too long to display ([81ba16c](https://github.com/marchen-dev/marchen-player/commit/81ba16ccd530975134fbc8342cd59d81dc5079aa)) + + +### Features + +* adapt to react router v7 ([649d4f5](https://github.com/marchen-dev/marchen-player/commit/649d4f532e9d4e7d43004914b0c271f8b9aa5e4f)) +* adapt to react19 ([6346694](https://github.com/marchen-dev/marchen-player/commit/63466949b66bf207e17ab92b174dc7087a901a60)) +* add loading and exiting video animation ([6a1eb38](https://github.com/marchen-dev/marchen-player/commit/6a1eb382da83efbd09f2230fed04afeaa3cca706)) +* Integrate sentry ([7faf61b](https://github.com/marchen-dev/marchen-player/commit/7faf61b0bf36585c6d5f81d60047c6c73b0a9603)) + + + +## [0.0.1-alpha.11](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.10...v0.0.1-alpha.11) (2024-12-05) + + +### Bug Fixes + +* clicking too quickly when importing anime would pop up multiple file selection boxes ([d513152](https://github.com/marchen-dev/marchen-player/commit/d513152197dd5ab42984df1dabc297cf7176c1d9)) +* settings button is missing ([b98c61b](https://github.com/marchen-dev/marchen-player/commit/b98c61b3d0aadf239da4b46e45bdffb775537d8c)) +* unable to switch danmaku during playback ([c8fbd8f](https://github.com/marchen-dev/marchen-player/commit/c8fbd8fb790700321b873310a671f46a4345da81)) + + +### Features + +* add a new exit button in the upper left corner ([d56514d](https://github.com/marchen-dev/marchen-player/commit/d56514d3b7c4e8d3c2d01f32d09c8b004a2a3de5)) +* add playlist ([758b55f](https://github.com/marchen-dev/marchen-player/commit/758b55f25714426300072439cc1a3b7e4ca5b952)) +* support rematch danmaku when playing anime ([3f23532](https://github.com/marchen-dev/marchen-player/commit/3f23532e055f72b9fdcf02ecf210abd4ce095be0)) + + + +## [0.0.1-alpha.10](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.9...v0.0.1-alpha.10) (2024-12-04) + + +### Bug Fixes + +* video cannot be paused ([35476c6](https://github.com/marchen-dev/marchen-player/commit/35476c6bc8f55d0222b5caa846b3e5d429ffd0f2)) + + +### Features + +* release note ([4485662](https://github.com/marchen-dev/marchen-player/commit/44856629e8690c163f6ffb111fde37e5ad1195ea)) + + + +## [0.0.1-alpha.9](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.8...v0.0.1-alpha.9) (2024-12-04) + + +### Bug Fixes + +* ci ([17ff1a7](https://github.com/marchen-dev/marchen-player/commit/17ff1a770c804fddfd38e25ce335cffda43066af)) +* toast cannot be displayed in full screen of the web version ([117ff7b](https://github.com/marchen-dev/marchen-player/commit/117ff7b3a1b1289d338c6a333a4ae465e404f776)) + + +### Features + +* limit single instance ([dcb0728](https://github.com/marchen-dev/marchen-player/commit/dcb07280d89228453ceb20a8f015e2f6300e1fb4)) +* limit the number of history records ([71354de](https://github.com/marchen-dev/marchen-player/commit/71354de8029be9b1fd4a072363dfe534d2140251)) +* support file association ([aafa367](https://github.com/marchen-dev/marchen-player/commit/aafa3672adf7187ee40a0c0e0da2b50ec2c930be)) +* use system full screen to replace browser full screen ([ad0c639](https://github.com/marchen-dev/marchen-player/commit/ad0c639707880cb03db3c5d69829c7d6f783e038)) + + + +## [0.0.1-alpha.8](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.7...v0.0.1-alpha.8) (2024-12-01) + + +### Bug Fixes + +* after dragging and dropping the video, it is not possible to switch episodes. ([bba47f5](https://github.com/marchen-dev/marchen-player/commit/bba47f55b42089431d151c81d6b7976c794cd288)) +* cover height is lost within the history page ([3a39962](https://github.com/marchen-dev/marchen-player/commit/3a3996283231bdb816941b68b168fd83bcd2cbd4)) +* release note error problem ([2b77515](https://github.com/marchen-dev/marchen-player/commit/2b7751513971da1ee23c008acaafb5f11e9d5c94)) + + +### Features + +* danmaku support dynamic switching between simplified and traditional Chinese ([6d92595](https://github.com/marchen-dev/marchen-player/commit/6d92595a4d4627ec85bae766702524ebf2cf5ed2)) +* display release note after the update is complete. ([71b2c90](https://github.com/marchen-dev/marchen-player/commit/71b2c9046742633ad61eacde96b7ba67a41b135d)) +* support manual rematching of historical records with danmaku ([150f5dc](https://github.com/marchen-dev/marchen-player/commit/150f5dc2dbd82ad576732051336e90f8c571d887)) +* supports automatic and manual episode switching ([56f5dd9](https://github.com/marchen-dev/marchen-player/commit/56f5dd921ffa1827b5cb3b8f563487ae39c03956)) + + + +## [0.0.1-alpha.7](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.6...v0.0.1-alpha.7) (2024-11-24) + + +### Bug Fixes + +* unable to update automatically ([b0f40c5](https://github.com/marchen-dev/marchen-player/commit/b0f40c5a2277f0b2538e796457f2797dabc21d0d)) + + +### Features + +* release notes ([665c938](https://github.com/marchen-dev/marchen-player/commit/665c93880f8dc55f866d5c76b29e50e1a8637991)) +* support manual update retrieval ([439ed65](https://github.com/marchen-dev/marchen-player/commit/439ed6593bca18b4fdb93d934c1965989a96aca3)) +* update progress ([7e9de12](https://github.com/marchen-dev/marchen-player/commit/7e9de12ba21cda0e0d4970724da46d16fc4df4f1)) + + + +## [0.0.1-alpha.6](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.5...v0.0.1-alpha.6) (2024-11-22) + + +### Bug Fixes + +* install deps when using windows ([bb49953](https://github.com/marchen-dev/marchen-player/commit/bb49953111227c97d25428b7ac18f51a991e31ac)) +* deps download ([2ac3727](https://github.com/marchen-dev/marchen-player/commit/2ac372786cd69e608ef534c468e14ff2f392eaff)) +* deps install ([b7478ad](https://github.com/marchen-dev/marchen-player/commit/b7478adabca1fdeb9c6b99e458456c1ddb8e0b61)) + + +### Features + +* clear unused ffmpeg dependencies during build ([47e8f8c](https://github.com/marchen-dev/marchen-player/commit/47e8f8c304b22f13fb24b0a8fb1f37bf19697a2c)) + + + +## [0.0.1-alpha.5](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.4...v0.0.1-alpha.5) (2024-11-21) + + +### Bug Fixes + +* add strict mode ([915acb1](https://github.com/marchen-dev/marchen-player/commit/915acb11770cbce13b74a051e80f65e6701ecaff)) +* ci ([6839f5a](https://github.com/marchen-dev/marchen-player/commit/6839f5ac619035b24f432c2480774873c9ae6790)) +* display issue with selecting subtitles interface ([73cb224](https://github.com/marchen-dev/marchen-player/commit/73cb2241c03fa3bfe228f9ebdf1f47e5b88d9f84)) +* libass font file import issue ([f3d65bf](https://github.com/marchen-dev/marchen-player/commit/f3d65bfaaef53e5e0fb58bdbf94435108cfeea52)) +* multiple subtitle issues ([b4378a1](https://github.com/marchen-dev/marchen-player/commit/b4378a14089798ff6d4522e07e8433d6ef8dc264)) +* subtitle can't import ([2e50458](https://github.com/marchen-dev/marchen-player/commit/2e50458f755ffd4beab44054d875ddf97cd6e321)) +* subtitle import problem ([99c337e](https://github.com/marchen-dev/marchen-player/commit/99c337e08d257f95277065f3a94af77302dcd390)) +* subtitle overlap ([0dbc76b](https://github.com/marchen-dev/marchen-player/commit/0dbc76b9b817deca783d501f533b5bd8919a2153)) +* subtitles cannot be displayed during initialization ([5bc3a66](https://github.com/marchen-dev/marchen-player/commit/5bc3a66d74c760387650bf26157ce4f622b0e9e4)) + + +### Features + +* add import subtitle UI ([bb8ed4b](https://github.com/marchen-dev/marchen-player/commit/bb8ed4b8d5b2146a86966d3368b24767e7f00e88)) +* app signature ([f49007a](https://github.com/marchen-dev/marchen-player/commit/f49007a3def92e10c96c0fda6143679f682ad1b7)) +* auto updater config ([36ed7d8](https://github.com/marchen-dev/marchen-player/commit/36ed7d8673fd6807b9e1b6b37ac7d6c9100210f4)) +* error handling page ([65d3bd1](https://github.com/marchen-dev/marchen-player/commit/65d3bd15a3e92170699a3314aa2c59dd03df824f)) +* extract subtitles from mkv ([529c8ad](https://github.com/marchen-dev/marchen-player/commit/529c8ad9c25f0a1fce019846bff0b7ddf88a3429)) +* optimize select hover effect ([08e0153](https://github.com/marchen-dev/marchen-player/commit/08e0153f58ddca4a05f7fa9026e272ce2f841259)) +* player setting sheet ([99e939d](https://github.com/marchen-dev/marchen-player/commit/99e939d93cd4fb0a67283bb70707833103f63f67)) +* set damaku when the animation is playing ([e3ad012](https://github.com/marchen-dev/marchen-player/commit/e3ad012ea92ae204b9736b658a8427e7b81345f6)) +* split player settings accordion ([4a9ccc9](https://github.com/marchen-dev/marchen-player/commit/4a9ccc99ee51cd145db3816f1bef32c0976c7346)) +* Support automatic recognition of embedded subtitles for playback ([4c185fd](https://github.com/marchen-dev/marchen-player/commit/4c185fd79ba67865ed4985b0f10c3b3f30f84cf1)) +* support importing embedded subtitles in videos ([ebb8cc6](https://github.com/marchen-dev/marchen-player/commit/ebb8cc6225a7089293b56855ae5e27cb34833c95)) +* support manual subtitle import ([a4bf6b5](https://github.com/marchen-dev/marchen-player/commit/a4bf6b5cb5fbdd7863da9062269ec4aee6887202)) +* use showMessage Box sync ([6286e15](https://github.com/marchen-dev/marchen-player/commit/6286e1540138e667f3b3eab781dffaefa942b68f)) + + + +## [0.0.1-alpha.4](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.3...v0.0.1-alpha.4) (2024-11-05) + + +### Bug Fixes + +* path issue of ffmpeg under Windows ([7c782b5](https://github.com/marchen-dev/marchen-player/commit/7c782b59dd4fd7a86769535e3a8893be8ff6bd89)) + + +### Features + +* control the display area of the danmaku ([be7a4ea](https://github.com/marchen-dev/marchen-player/commit/be7a4eafa9ded59a8a16a40a83fb795048b6e65f)) +* relative time in anime history ([58f4905](https://github.com/marchen-dev/marchen-player/commit/58f49052af1b9cc0debd9bd064c32142b44e605e)) +* remove noto sans sc ([2da6a4c](https://github.com/marchen-dev/marchen-player/commit/2da6a4cbe9f29d2502fd32049e4ac57204d5c90e)) +* support ass subtitle import ([4835ddf](https://github.com/marchen-dev/marchen-player/commit/4835ddfc294f7683e068fedf2d8643ef0df77c30)) +* support srt and vtt subtitle import ([8e92fa3](https://github.com/marchen-dev/marchen-player/commit/8e92fa307803cbdc79f19a71866828d2c1b68399)) + + +### Performance Improvements + +* adjust the order of player icons ([26754ae](https://github.com/marchen-dev/marchen-player/commit/26754aec14e0d06bd861ae5df84a91e7e48efdd5)) +* optimize the client import subtitle button ([12c2038](https://github.com/marchen-dev/marchen-player/commit/12c203877f10c1d8b8d993e836fc99f84548f137)) + + + +## [0.0.1-alpha.3](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.2...v0.0.1-alpha.3) (2024-10-29) + + +### Bug Fixes + +* anime cannot be played ([ffab23d](https://github.com/marchen-dev/marchen-player/commit/ffab23df2869e5fad32f790f4cdbc7d670da0296)) +* when switching tabs, the timeline cannot disappear ([60ada24](https://github.com/marchen-dev/marchen-player/commit/60ada24a35473f80cd1b3aede9b6c359d4190d3b)) + + +### Features + +* adapt thumbnail layout ([e11eea9](https://github.com/marchen-dev/marchen-player/commit/e11eea9d73b5d0ef47ef661199fce60030250b95)) +* adapt web version dialog ([52c3110](https://github.com/marchen-dev/marchen-player/commit/52c311087e923f0a7b5eb1eadc844cba68a2e9d9)) +* clear historical data ([9535e94](https://github.com/marchen-dev/marchen-player/commit/9535e94796d92af651b44565b76733308e6283dd)) +* page transition animation ([72d5ca0](https://github.com/marchen-dev/marchen-player/commit/72d5ca0d09c2cba093a1767a1c122a7ad07b9137)) +* react query devtools ([5636c6e](https://github.com/marchen-dev/marchen-player/commit/5636c6eb45a6484698965a2cac296d7934bcab44)) +* simplified and traditional chinese danmaku dynamic switching ([10237f7](https://github.com/marchen-dev/marchen-player/commit/10237f79d07e815b5a39b916bf38456c1b0ab854)) +* switch thumbnail and poster ([a73a25f](https://github.com/marchen-dev/marchen-player/commit/a73a25f02854a3fe931bfe9001ba7f8f5c6c8bc2)) + + +### Performance Improvements + +* expand router layout padding ([1c86690](https://github.com/marchen-dev/marchen-player/commit/1c866900ce5e326c20e4b0d0123990f1c84c5b06)) +* packaged toast ([4c21203](https://github.com/marchen-dev/marchen-player/commit/4c21203eb23fa58899c2b5c1718246b16d02ca7d)) + + + +## [0.0.1-alpha.2](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.1...v0.0.1-alpha.2) (2024-10-25) + + +### Bug Fixes + +* adjust api url in env example ([3f7082c](https://github.com/marchen-dev/marchen-player/commit/3f7082cb5b80c851b088364e986ec8671aeedd9e)) +* can't load anime ([4576c6c](https://github.com/marchen-dev/marchen-player/commit/4576c6c73b1771956e15996c589f2cc4a61b9401)) +* **deps:** update dependency tailwind-merge to v2.5.3 ([#30](https://github.com/marchen-dev/marchen-player/issues/30)) ([3836806](https://github.com/marchen-dev/marchen-player/commit/3836806561a19eedeb7307fb21ee1462ac1f8db8)) +* **deps:** update dependency tailwind-merge to v2.5.3 ([#40](https://github.com/marchen-dev/marchen-player/issues/40)) ([d9740bb](https://github.com/marchen-dev/marchen-player/commit/d9740bbb5bdd976cefc9728933f4b86e73f218d3)) +* **deps:** update radix-ui-primitives monorepo ([#31](https://github.com/marchen-dev/marchen-player/issues/31)) ([24b9710](https://github.com/marchen-dev/marchen-player/commit/24b97108e7afb9122c8c2c37d4cddba088db0f33)) +* history progress cannot be displayed ([146f3b5](https://github.com/marchen-dev/marchen-player/commit/146f3b517d8547774d1f70c65b5f789af88c5007)) +* windows can't load anime ([be57b5b](https://github.com/marchen-dev/marchen-player/commit/be57b5b37aae94af4b93d9babfd250194bd55924)) + + +### Features + +* custom marchen protocol ([0495ca1](https://github.com/marchen-dev/marchen-player/commit/0495ca11c85427a663d66fcc28bbe9d53617cac7)) +* history page add anime cover preview ([d6b4b88](https://github.com/marchen-dev/marchen-player/commit/d6b4b887b44569ea41576ed917d7881be484952a)) +* history record percentage ([07f65f4](https://github.com/marchen-dev/marchen-player/commit/07f65f4b6045bbdc6cadc7efac05350b815335c8)) +* history records sorted by update time ([f3731d0](https://github.com/marchen-dev/marchen-player/commit/f3731d071164c3649af436e228f0471abb7229ad)) +* network check ([3c8d994](https://github.com/marchen-dev/marchen-player/commit/3c8d99439adcc7c24871eeeddf2a62a1a00744f3)) +* play animes through history records ([948639a](https://github.com/marchen-dev/marchen-player/commit/948639a92be994f7d22846f1c777b49781abef79)) +* update renovate extends ([d7fb5c2](https://github.com/marchen-dev/marchen-player/commit/d7fb5c2051bab1ea508857ac12bc55e0e1b16e1a)) + + + +## [0.0.1-alpha.1](https://github.com/marchen-dev/marchen-player/compare/v0.0.1-alpha.0...v0.0.1-alpha.1) (2024-10-22) + + +### Bug Fixes + +* adjust modal shadow ([b67b72f](https://github.com/marchen-dev/marchen-player/commit/b67b72f0db95058c003257916c21ddd2a6a9135f)) +* ci ([39f91e0](https://github.com/marchen-dev/marchen-player/commit/39f91e00acf13a3afc59e957f29a259b60a7bbf0)) +* ci ([63aa818](https://github.com/marchen-dev/marchen-player/commit/63aa8184569b875cd85ba16fe4ce58e15bea587d)) +* **deps:** update dependency ofetch to v1.4.1 ([337abbc](https://github.com/marchen-dev/marchen-player/commit/337abbcf578f565c4125ecbb3696bc1437a32c47)) +* rename logo.tsx to Logo.tsx ([de9fe44](https://github.com/marchen-dev/marchen-player/commit/de9fe44eb76ec208cff77e4a7860328bd1d9bad2)) + + +### Features + +* about setting ([9804191](https://github.com/marchen-dev/marchen-player/commit/98041913c76e289725b143edbb98decd5caf5efc)) +* allow video playback without loading comments ([6267ff6](https://github.com/marchen-dev/marchen-player/commit/6267ff66f39c4ef4c34350333ca0ea981e45d7b6)) +* atom with storage ([ab46e45](https://github.com/marchen-dev/marchen-player/commit/ab46e453b94eeb75b03a0f37cc4c14ce5c98803e)) +* danmaku text shadow ([64bdb74](https://github.com/marchen-dev/marchen-player/commit/64bdb74b6e2b4e323bb01ef6901a175659a14176)) +* header show icon ([e8b30a9](https://github.com/marchen-dev/marchen-player/commit/e8b30a9feca4387b068b31b5e7a07dd9908b5482)) +* imperative modal ([22797a7](https://github.com/marchen-dev/marchen-player/commit/22797a7251000e3313629828e3e875f3324a9b74)) +* initial indexdb ([4e3c2cd](https://github.com/marchen-dev/marchen-player/commit/4e3c2cd4723c231b8f343f8e2216020e76f5502e)) +* manual search for danmaku comments ([96e787e](https://github.com/marchen-dev/marchen-player/commit/96e787e144ec46bfa5a10cf9b16a38e1c04af05c)) +* minimum width and height of the window ([f83cd54](https://github.com/marchen-dev/marchen-player/commit/f83cd54476c8d56f747f5deb8a3a60b879014bbb)) +* optimize display of the logo in dark mode ([7db3cd3](https://github.com/marchen-dev/marchen-player/commit/7db3cd3836ac385f9c770c4cc63c0b15b08a3501)) +* partial status bar menu configuration ([fea6598](https://github.com/marchen-dev/marchen-player/commit/fea6598770af0d8732255ee8b536068c40a50980)) +* reduce danmaku text shadow ([2abb6ab](https://github.com/marchen-dev/marchen-player/commit/2abb6ab92b1661eeb0b0a4545340c9a5d13dc391)) +* setting window ([f9eb03e](https://github.com/marchen-dev/marchen-player/commit/f9eb03e6bed7fdb834db792973997ca38fba9a58)) + + +### Performance Improvements + +* player prvoider ([8006a58](https://github.com/marchen-dev/marchen-player/commit/8006a58c0d5796aa6ab19dcdae5394f6c255e1c8)) + + + +## [0.0.1-alpha.0](https://github.com/marchen-dev/marchen-player/compare/0c3019d8ef56374c1a9ebbe76beb0bd40f60135a...v0.0.1-alpha.0) (2024-10-12) + + +### Bug Fixes + +* turn off switching to electron app on every file change ([5faf6fe](https://github.com/marchen-dev/marchen-player/commit/5faf6fec7d726275e6b2c6b1f939b33c4558cf89)) +* ci ([18c7ebe](https://github.com/marchen-dev/marchen-player/commit/18c7ebe03715dd732ba88ecaa70beb6c70418cf7)) +* ci ([3a35f7c](https://github.com/marchen-dev/marchen-player/commit/3a35f7c5473107c9b0d6cfcde3bccfd502ac22dc)) +* ci ([56553d5](https://github.com/marchen-dev/marchen-player/commit/56553d50113981933cb87f220e2f622c1d45cad6)) +* ci ([3f7a296](https://github.com/marchen-dev/marchen-player/commit/3f7a296cc3879eb587db983c18bdd8a02cc2f919)) +* ci ([6bfa8cf](https://github.com/marchen-dev/marchen-player/commit/6bfa8cfa957d1f30f496fd9fe12120030f2ffeec)) +* ci ([1efb09c](https://github.com/marchen-dev/marchen-player/commit/1efb09ce7b5a82dc8d2cd5710fb4c0b2ea9f5581)) +* ci ([4bdbd88](https://github.com/marchen-dev/marchen-player/commit/4bdbd88fae59c8fa1dcbe45a48e98dfc3ef10465)) +* ci ([01f08c4](https://github.com/marchen-dev/marchen-player/commit/01f08c43c32e56d012a18dc364c948b1994eafd4)) +* ci ([2caa7ff](https://github.com/marchen-dev/marchen-player/commit/2caa7ff9f1b0b6a5db8b4a886a508eb2a17dd550)) +* ci release ([77c0cfd](https://github.com/marchen-dev/marchen-player/commit/77c0cfdb992610067ea2dc56225ffa5f533e79fa)) +* **deps:** update dependency @tanstack/react-query to v5.51.24 ([#12](https://github.com/marchen-dev/marchen-player/issues/12)) ([8ab541c](https://github.com/marchen-dev/marchen-player/commit/8ab541c382565a1c522f783752f771d12dbd313c)) +* **deps:** update dependency @tanstack/react-query to v5.55.2 ([#16](https://github.com/marchen-dev/marchen-player/issues/16)) ([48cfa31](https://github.com/marchen-dev/marchen-player/commit/48cfa318c5615d57b6a75ab2034c2efcfbb1fb3b)) +* **deps:** update dependency @tanstack/react-query to v5.55.4 ([#31](https://github.com/marchen-dev/marchen-player/issues/31)) ([3b8308d](https://github.com/marchen-dev/marchen-player/commit/3b8308d919737c04a31b94e2efb354576db97c7e)) +* **deps:** update dependency @tanstack/react-query to v5.56.2 ([#37](https://github.com/marchen-dev/marchen-player/issues/37)) ([2e8e207](https://github.com/marchen-dev/marchen-player/commit/2e8e207ff2441dedb66b1680343a685276fc8cf0)) +* **deps:** update dependency @tanstack/react-query to v5.59.8 ([#24](https://github.com/marchen-dev/marchen-player/issues/24)) ([da84b3a](https://github.com/marchen-dev/marchen-player/commit/da84b3aae9605657a7866efe543b7dc3552a8995)) +* **deps:** update dependency electron-updater to v6.3.4 ([#28](https://github.com/marchen-dev/marchen-player/issues/28)) ([9ecd0df](https://github.com/marchen-dev/marchen-player/commit/9ecd0dfe995083c21f4edb90ca26b3c2895a78d5)) +* **deps:** update dependency electron-updater to v6.3.9 ([#19](https://github.com/marchen-dev/marchen-player/issues/19)) ([8018ade](https://github.com/marchen-dev/marchen-player/commit/8018ade85bd247a12344036eea58a01e4c73c80f)) +* **deps:** update dependency jotai to v2.10.0 ([#9](https://github.com/marchen-dev/marchen-player/issues/9)) ([3354c81](https://github.com/marchen-dev/marchen-player/commit/3354c815ca07255ac90f0b70d85d7095fe9b5ea4)) +* **deps:** update dependency lucide-react to ^0.439.0 ([#23](https://github.com/marchen-dev/marchen-player/issues/23)) ([3e926bf](https://github.com/marchen-dev/marchen-player/commit/3e926bfea3c459e7e2b7971a03143a0e392bb210)) +* **deps:** update dependency lucide-react to ^0.441.0 ([#40](https://github.com/marchen-dev/marchen-player/issues/40)) ([39d4be4](https://github.com/marchen-dev/marchen-player/commit/39d4be4f4dab73c4c7e2543e3329e39f4f4daf9d)) +* **deps:** update dependency lucide-react to ^0.451.0 ([#11](https://github.com/marchen-dev/marchen-player/issues/11)) ([da7efdd](https://github.com/marchen-dev/marchen-player/commit/da7efddbae95bf256aed298dcfd5ae807a9351d9)) +* **deps:** update dependency ofetch to v1.4.0 ([#53](https://github.com/marchen-dev/marchen-player/issues/53)) ([2cb0130](https://github.com/marchen-dev/marchen-player/commit/2cb01301a19d781af6dea75dc6e9eaa7e522cec5)) +* **deps:** update dependency ofetch to v1.4.1 ([#20](https://github.com/marchen-dev/marchen-player/issues/20)) ([8421519](https://github.com/marchen-dev/marchen-player/commit/842151959abfff15ed8ec4149e52d995b6d9682b)) +* **deps:** update dependency react-router-dom to v6.26.1 ([#11](https://github.com/marchen-dev/marchen-player/issues/11)) ([9b93c4c](https://github.com/marchen-dev/marchen-player/commit/9b93c4c2595e4fc86a492f2750093a37e12c6310)) +* **deps:** update dependency react-router-dom to v6.26.2 ([#33](https://github.com/marchen-dev/marchen-player/issues/33)) ([4f8f58f](https://github.com/marchen-dev/marchen-player/commit/4f8f58f394b7f073f112c00478b6a36b1a7e99f4)) +* **deps:** update dependency tailwind-merge to v2.5.3 ([#21](https://github.com/marchen-dev/marchen-player/issues/21)) ([3fb6aa1](https://github.com/marchen-dev/marchen-player/commit/3fb6aa18d27e054309c58066a574f7a0cbef5116)) +* **deps:** update fontsource monorepo to v5.1.0 ([#41](https://github.com/marchen-dev/marchen-player/issues/41)) ([fac529b](https://github.com/marchen-dev/marchen-player/commit/fac529bc5adb8c4c5ab5eb278a11c8e9a1981cb4)) +* **deps:** update radix-ui-primitives monorepo ([beebeeb](https://github.com/marchen-dev/marchen-player/commit/beebeeb9eef4ac84c02ceb5c38a287b9d482e8e1)) +* esilint ([56768c0](https://github.com/marchen-dev/marchen-player/commit/56768c03950116e6d3e592a955fec8f5b5634c7c)) +* Importing the same hash video cannot match the danmu ([995b7c2](https://github.com/marchen-dev/marchen-player/commit/995b7c23ed9997f59280724f156cac2c352baeb1)) +* npm user name ([cbbe9ba](https://github.com/marchen-dev/marchen-player/commit/cbbe9baa1a8a31fce53bc077feffa8548083d32d)) +* pnpm lock ([b173938](https://github.com/marchen-dev/marchen-player/commit/b17393802e1ad70cc2f70feae6357a4064f700d3)) +* pnpm lock file ([b9eb86d](https://github.com/marchen-dev/marchen-player/commit/b9eb86d83e7749f32cdfad2a1c91b329918f4bb4)) +* reduce icon gap ([f133569](https://github.com/marchen-dev/marchen-player/commit/f13356989d23a2553c418fa65c92cfd2aa360984)) +* refetch after maximum ([13ed564](https://github.com/marchen-dev/marchen-player/commit/13ed564ffdcffe1229196822068d172911f92d40)) +* ua ([d059dff](https://github.com/marchen-dev/marchen-player/commit/d059dff3b78ff20151b2ffe058033156c5d1cb4e)) +* windows executableName ([70d6f73](https://github.com/marchen-dev/marchen-player/commit/70d6f732984b8ba20e6cf2fcdeaf5281ba4965ce)) + + +### Features + +* add renovate.json ([#1](https://github.com/marchen-dev/marchen-player/issues/1)) ([ea76ce1](https://github.com/marchen-dev/marchen-player/commit/ea76ce15ef6ecdfa2dd8a0c5eb66606df882c226)) +* app icon ([9c40e44](https://github.com/marchen-dev/marchen-player/commit/9c40e44296ef3d746684198d6d84d7a02fb44a05)) +* artplayer config ([48004a9](https://github.com/marchen-dev/marchen-player/commit/48004a9cf975303215a7d139f7c32f4af471cb8b)) +* artplayer deps ([67e6edc](https://github.com/marchen-dev/marchen-player/commit/67e6edc4867a4800794028e935f8d4cca4229d93)) +* auto update app ([0ff764a](https://github.com/marchen-dev/marchen-player/commit/0ff764a7cf796c47e7d9efb124ad6f11e2ce46bb)) +* automatic deployment ([eebe136](https://github.com/marchen-dev/marchen-player/commit/eebe13673dded71c9084927ec1091195ce334858)) +* bump version ([5a305e1](https://github.com/marchen-dev/marchen-player/commit/5a305e138a218e1195915c2268b1b3e306b8f843)) +* click to add new video ([68fc945](https://github.com/marchen-dev/marchen-player/commit/68fc945e0dcb4802c04001b86ac55d46054e2fa0)) +* click to component ([fe12f4d](https://github.com/marchen-dev/marchen-player/commit/fe12f4da897e77b06f957f7f90dbec85c36050f1)) +* custom font and dark mode ([494ad2a](https://github.com/marchen-dev/marchen-player/commit/494ad2ac316238774fa15b85f314d37990d1dee7)) +* daisyui ([9870db0](https://github.com/marchen-dev/marchen-player/commit/9870db0280903d9401a9ac33988de57d6e309206)) +* danmu.js ([0cf9f5c](https://github.com/marchen-dev/marchen-player/commit/0cf9f5c9b4fa95111cfac84979b91e283cc8fd27)) +* dark mode ([4f9a447](https://github.com/marchen-dev/marchen-player/commit/4f9a447e2ead5ed69ec00fe24f04ecf163063e9e)) +* destroy player when switching videos ([c3e7060](https://github.com/marchen-dev/marchen-player/commit/c3e7060f1dd417bc499a8ceb8002918686749def)) +* display the specific anime name and the number of danmu when playing anime ([edaf033](https://github.com/marchen-dev/marchen-player/commit/edaf03301f46358f139a3f72db0d2dc05f448616)) +* Dock with DanDanPlay interface ([e469fe8](https://github.com/marchen-dev/marchen-player/commit/e469fe8232ae7a2feb9014e22c67944d40a78e04)) +* drag to play the video ([4eee322](https://github.com/marchen-dev/marchen-player/commit/4eee322e8f1c7a5ba53568d4da2bbcc98321e0fd)) +* electron builder ([14acb5f](https://github.com/marchen-dev/marchen-player/commit/14acb5f74e6c58e6854357609c8c8ed5d286bb46)) +* eslint config ([0c3019d](https://github.com/marchen-dev/marchen-player/commit/0c3019d8ef56374c1a9ebbe76beb0bd40f60135a)) +* init loading timeline ([ce2a47e](https://github.com/marchen-dev/marchen-player/commit/ce2a47eeb4f68c81613ad9f45a9c58f0cdce6f13)) +* loading progress ([e4c8cf8](https://github.com/marchen-dev/marchen-player/commit/e4c8cf871884ea56d3366ab71452180864f17649)) +* logo font ([fa986e6](https://github.com/marchen-dev/marchen-player/commit/fa986e6afcb2a0efc8d0d13bacce04b7d4fb4875)) +* match toast ([5d29864](https://github.com/marchen-dev/marchen-player/commit/5d2986461e8db329a918034240571a21021d371e)) +* maximum button ([070c363](https://github.com/marchen-dev/marchen-player/commit/070c36328d292572ab43e0cf3200430b3ea4e31d)) +* MIT LICENSE ([7fa502d](https://github.com/marchen-dev/marchen-player/commit/7fa502df45f238f4ab29e64c43b3c42c793252c4)) +* narrow the import click range ([22a8a13](https://github.com/marchen-dev/marchen-player/commit/22a8a13373950012903e9aa9d64571700c784428)) +* prettier ([6fd58fd](https://github.com/marchen-dev/marchen-player/commit/6fd58fdb6a400b9d40caaaf3aab3aad1530eb2da)) +* routes ([fd989a0](https://github.com/marchen-dev/marchen-player/commit/fd989a02efdf68d0e47c2c1fa8ed71f4d8a74ed1)) +* routing switch ([93b76fb](https://github.com/marchen-dev/marchen-player/commit/93b76fbda12c1412199831a652dcb167b7157e78)) +* shadcn toast ([91c1006](https://github.com/marchen-dev/marchen-player/commit/91c1006b4ad8a35cb4251ef72be3a495619792a6)) +* show tool ([f9fe0cc](https://github.com/marchen-dev/marchen-player/commit/f9fe0cc8d1fb8b85a70593b6f94068e5de3fa881)) +* sidebar layout ([d56032a](https://github.com/marchen-dev/marchen-player/commit/d56032ac7e39f68fcab37b812755406a941af0d4)) +* tailwind config ([128ef48](https://github.com/marchen-dev/marchen-player/commit/128ef48afc6ed36bb74c5dfe49cdbf63e8f34032)) +* tipc ([e32ffeb](https://github.com/marchen-dev/marchen-player/commit/e32ffeb8f704aba6acc00c7c085465d2f7e8325d)) +* title bar for windows ([b049f34](https://github.com/marchen-dev/marchen-player/commit/b049f346f611c116d12572526aae2381a4761808)) +* vite host ([d39b0e4](https://github.com/marchen-dev/marchen-player/commit/d39b0e452bac8d013a84b8d8ee5348094a44e67d)) +* vite server export 0.0.0.0 ([3dd9a78](https://github.com/marchen-dev/marchen-player/commit/3dd9a7834f6463daa9fa0ead8f16d92f12f8624b)) +* web toast ([195e97c](https://github.com/marchen-dev/marchen-player/commit/195e97c141e7dd7712b5f8ed1f541cffeb7cb03d)) +* website icon ([fbb793a](https://github.com/marchen-dev/marchen-player/commit/fbb793a08fc50b398daabe68bda7a908109d0b14)) +* when there is no exact match, manually select the anime ([a427ade](https://github.com/marchen-dev/marchen-player/commit/a427ade585d53b740852ca87b3c292a7fa24911c)) +* windows titlebar ([b3ed388](https://github.com/marchen-dev/marchen-player/commit/b3ed38830d2d69db2f5bb2e0a5160e9aba8bd8a2)) +* xgplayer ([a4e4364](https://github.com/marchen-dev/marchen-player/commit/a4e43649fb873bc12e822f0fa7814b733a6c417e)) + + +### Performance Improvements + +* Improve hash computation speed ([a402d19](https://github.com/marchen-dev/marchen-player/commit/a402d19eda56bdfc02fc95f914dfdf53824e3c30)) + + +### Reverts + +* xgplayer ([69a86fd](https://github.com/marchen-dev/marchen-player/commit/69a86fdb2bf620714a72da49d9e25ccc10ce663f)) + + + +appId: com.suemor.Marchen +productName: Marchen +directories: + buildResources: build +files: + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' + - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' +asarUnpack: + - resources/** +win: + executableName: Marchen + fileAssociations: + - ext: ['mp4', 'mkv'] + name: Video Files + role: Editor +nsis: + artifactName: ${productName}-${version}-setup.${ext} + shortcutName: ${productName} + uninstallDisplayName: ${productName} + createDesktopShortcut: always + allowToChangeInstallationDirectory: true + oneClick: false +mac: + entitlementsInherit: build/entitlements.mac.plist + extendInfo: + - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. + - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + notarize: false + target: + - target: dmg + arch: + - x64 + - arm64 + - target: zip + arch: + - x64 + - arm64 + fileAssociations: + - ext: ['mp4', 'mkv'] + name: Video Files + role: Editor +dmg: + artifactName: ${productName}-${version}-${arch}.${ext} +linux: + target: + - target: AppImage + arch: + - arm64 + - x64 + maintainer: github.com/suemor233 + category: Utility +appImage: + artifactName: ${productName}-${version}-${arch}.${ext} +npmRebuild: false +publish: + provider: github + owner: marchen-dev + repo: marchen-player +# publish: +# provider: generic +# url: http://localhost:3000 +beforePack: scripts/install-darwin-deps.js +afterPack: scripts/after-pack.js +afterSign: scripts/notarize.js +releaseInfo: + releaseNotes: | + - 修复弹幕加载失败导致视频无法播放问题 + + + +{ + "name": "Marchen", + "type": "module", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.11.0", + "description": "Marchen player", + "author": "Suemor", + "license": "AGPL-3.0", + "homepage": "https://github.com/marchen-dev/marchen-player", + "repository": { + "url": "https://github.com/marchen-dev/marchen-player", + "type": "git" + }, + "main": "./out/main/index.js", + "scripts": { + "build": "npm run typecheck && electron-vite build", + "build:linux": "electron-vite build && electron-builder --linux --publish never", + "build:mac": "electron-vite build && electron-builder --mac --publish never", + "build:unpack": "npm run build && electron-builder --dir", + "build:web": "rm -rf out/web && vite build", + "build:win": "npm run build && electron-builder --win --publish never", + "bump": "vv", + "dev": "electron-vite dev", + "dev:web": "vite", + "format": "prettier --write .", + "lint": "eslint", + "lint:fix": "eslint --fix", + "postinstall": "electron-builder install-app-deps", + "prepare": "pnpm exec simple-git-hooks", + "start": "electron-vite preview", + "typecheck": "npm run typecheck:node && npm run typecheck:web", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", + "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false" + }, + "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@ffprobe-installer/ffprobe": "^2.1.2" + }, + "devDependencies": { + "@egoist/tipc": "^0.3.2", + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/tsconfig": "^1.0.1", + "@electron-toolkit/utils": "^4.0.0", + "@electron/notarize": "^2.5.0", + "@fontsource/manrope": "^5.0.21", + "@iconify-json/mingcute": "^1.1.19", + "@iconify/tailwind": "^1.1.2", + "@jellyfin/libass-wasm": "^4.2.3", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.2", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.3", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.8", + "@sentry/react": "^8.42.0", + "@suemor/xgplayer": "^3.0.21", + "@tanstack/react-query": "^5.62.9", + "@tanstack/react-query-devtools": "^5.62.9", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/lodash-es": "4.17.12", + "@types/node": "^22.0.0", + "@types/opencc-js": "^1.0.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/string-natural-compare": "^3.0.4", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "class-variance-authority": "^0.7.0", + "click-to-react-component": "^1.1.0", + "clsx": "^2.1.1", + "cmdk": "^1.0.4", + "daisyui": "^4.12.10", + "danmu.js": "^1.1.13", + "dayjs": "^1.11.13", + "dexie": "^4.0.8", + "dexie-react-hooks": "^1.1.7", + "dotenv": "^16.4.5", + "electron": "^35.0.0", + "electron-builder": "^25.0.0", + "electron-log": "^5.2.0", + "electron-updater": "^6.3.9", + "electron-vite": "^3.0.0", + "eslint": "^9.13.0", + "eslint-config-hyoban": "^3.1.11", + "fluent-ffmpeg": "^2.1.3", + "framer-motion": "^12.0.0-alpha.2", + "jotai": "^2.9.3", + "lint-staged": "^15.2.8", + "lodash-es": "^4.17.21", + "lowdb": "^7.0.1", + "lucide-react": "^0.511.0", + "nbump": "^2.0.4", + "next-themes": "^0.4.4", + "node-machine-id": "^1.1.12", + "ofetch": "^1.3.4", + "opencc-js": "^1.0.5", + "postcss": "^8.4.41", + "prettier": "^3.3.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.0.2", + "react-scan": "^0.3.0", + "rollup-plugin-copy": "^3.5.0", + "simple-git-hooks": "^2.11.1", + "spark-md5": "^3.0.2", + "string-natural-compare": "^3.0.1", + "tailwind-merge": "^2.5.2", + "tailwindcss": "^3.4.9", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.5.2", + "vite": "^6.0.0", + "xml2js": "^0.6.2", + "zod": "^3.24.1" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@ffmpeg-installer/darwin-arm64", + "@ffprobe-installer/darwin-arm64", + "@swc/core", + "core-js", + "electron", + "es5-ext", + "esbuild", + "simple-git-hooks" + ] + }, + "simple-git-hooks": { + "pre-commit": "npx lint-staged" + }, + "lint-staged": { + "*": "eslint --fix" + }, + "bump": { + "before": [ + "git pull --rebase" + ], + "commit_message": "chore(release): release v${NEW_VERSION}", + "changelog": true + } +} + + + diff --git a/src/main/index.ts b/src/main/index.ts index 9bb706a8..c107aaa8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { electronApp, optimizer } from '@electron-toolkit/utils' import { name } from '@pkg' -import { app, BrowserWindow, protocol } from 'electron' +import { app, BrowserWindow, protocol, session } from 'electron' import { MARCHEN_PROTOCOL } from './constants/protocol' import { initializeApp } from './initialize' @@ -25,6 +25,16 @@ function bootstrap() { return handleCustomProtocol(filePath, request) }) + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Cross-Origin-Opener-Policy': ['same-origin'], + 'Cross-Origin-Embedder-Policy': ['require-corp'], + }, + }) + }) + createWindow() if (app.dock && isDev) { diff --git a/src/main/initialize/native-addon.ts b/src/main/initialize/native-addon.ts new file mode 100644 index 00000000..31d9906d --- /dev/null +++ b/src/main/initialize/native-addon.ts @@ -0,0 +1,86 @@ +import { createRequire } from 'node:module' + +const _require = createRequire(import.meta.url) + +const loadNativeAddon = () => { + try { + return _require('../../native/build/Release/marchen_decoder.node') + } catch (error) { + console.error('Failed to load native addon:', error) + } +} + +const native = loadNativeAddon() + +export interface OpenOptions { + forceSoftwareDecode?: boolean +} + +export interface FrameInfo { + /** 显示时间戳 (秒) */ + pts: number + /** 帧宽度 */ + width: number + /** 帧高度 */ + height: number +} + +export interface VideoMetadata { + /** 视频宽度 (像素) */ + width: number + /** 视频高度 (像素) */ + height: number + /** 视频时长 (秒) */ + duration: number + /** 帧率 */ + frameRate: number + /** 编解码器名称 */ + codecName: string + /** 是否启用硬件加速 */ + hwAccel: boolean + /** 硬件加速设备名称 */ + hwDevice: string + /** 需要的 buffer 大小 (bytes) */ + bufferSize: number +} + +export class MarchenDecoder { + public native: any + private isOpen = false + private metadata: VideoMetadata | null = null + + constructor() { + this.native = new native.MarchenDecoder() + } + + open(filePath: string, options?: OpenOptions) { + if (this.isOpen) { + this.close() + } + this.metadata = this.native.open(filePath, options || {}) + this.isOpen = true + return this.metadata! + } + + setVideoBuffer(buffer: Uint8Array | Uint8ClampedArray): boolean { + if (!this.isOpen) { + throw new Error('Decoder is not open') + } + return this.native.setVideoBuffer(buffer) + } + + close() { + if (this.isOpen) { + this.native.close() + this.isOpen = false + this.metadata = null + } + } + + decodeFrame(): FrameInfo | null { + if (!this.isOpen) { + throw new Error('Decoder not open') + } + return this.native.decodeFrame() + } +} diff --git a/src/main/tipc/player.ts b/src/main/tipc/player.ts index 6c533f36..f55ac2cb 100644 --- a/src/main/tipc/player.ts +++ b/src/main/tipc/player.ts @@ -126,6 +126,7 @@ export const playerRoute = { const playList = filePathWithSameSuffix.map((filePath) => ({ urlWithPrefix: `${MARCHEN_PROTOCOL_PREFIX}${filePath}`, + path: filePath, name: path.basename(filePath), })) diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index a286f720..541fce38 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -28,6 +28,8 @@ export default function createWindow() { webPreferences: { preload: join(__dirname, '../preload/index.mjs'), sandbox: false, + nodeIntegration: false, + contextIsolation: true, }, } switch (platform) { diff --git a/src/preload/index.ts b/src/preload/index.ts index ae6388a6..b4c28a34 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,31 +1,116 @@ +// src/preload/index.ts import { electronAPI } from '@electron-toolkit/preload' +import { MarchenDecoder } from '@main/initialize/native-addon' import { contextBridge, webUtils } from 'electron' -// Custom APIs for renderer +interface DecoderInstance { + decoder: MarchenDecoder + sharedBuffer: SharedArrayBuffer | null + frameBuffer: Uint8ClampedArray | null + metadata: any +} + +const decoderInstances = new Map() +let nextId = 0 + +const decoderAPI = { + create(): number { + const id = nextId++ + decoderInstances.set(id, { + decoder: new MarchenDecoder(), + sharedBuffer: null, + frameBuffer: null, + metadata: null, + }) + return id + }, + + open(id: number, filePath: string, options?: { forceSoftwareDecode?: boolean }) { + const instance = decoderInstances.get(id) + if (!instance) throw new Error(`Decoder ${id} not found`) + + const metadata = instance.decoder.open(filePath, options) + instance.metadata = metadata + + // 创建 SharedArrayBuffer + instance.sharedBuffer = new SharedArrayBuffer(metadata.bufferSize) + instance.frameBuffer = new Uint8ClampedArray(instance.sharedBuffer) + instance.decoder.setVideoBuffer(instance.frameBuffer) + + // 通过 window.postMessage 发送 SharedArrayBuffer + // 这个在 preload 中可以直接访问 window + setTimeout(() => { + window.postMessage({ + type: 'decoder-shared-buffer', + decoderId: id, + buffer: instance.sharedBuffer, + }, '*') + }, 0) + + return { + width: metadata.width, + height: metadata.height, + duration: metadata.duration, + frameRate: metadata.frameRate, + codecName: metadata.codecName, + hwAccel: metadata.hwAccel, + hwDevice: metadata.hwDevice, + bufferSize: metadata.bufferSize, + } + }, + + decodeFrame(id: number): { pts: number; width: number; height: number } | null { + const instance = decoderInstances.get(id) + if (!instance) throw new Error(`Decoder ${id} not found`) + if (!instance.frameBuffer) throw new Error('Buffer not initialized') + + const frameInfo = instance.decoder.decodeFrame() + if (!frameInfo) { + return null + } + + return { + pts: frameInfo.pts, + width: frameInfo.width, + height: frameInfo.height, + } + }, + + seek(id: number, timestamp: number): boolean { + const instance = decoderInstances.get(id) + if (!instance) throw new Error(`Decoder ${id} not found`) + return instance.decoder.native.seek(timestamp) + }, + + close(id: number): void { + const instance = decoderInstances.get(id) + if (instance) { + instance.decoder.close() + decoderInstances.delete(id) + } + }, + + getHwAccelInfo(id: number) { + const instance = decoderInstances.get(id) + if (!instance) throw new Error(`Decoder ${id} not found`) + return instance.decoder.native.getHwAccelInfo() + }, +} + const api = { showFilePath(file: File) { - // It's best not to expose the full file path to the web content if - // possible. const path = webUtils.getPathForFile(file) return path }, } -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. + if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('platform', process.platform) + contextBridge.exposeInMainWorld('decoder', decoderAPI) } catch (error) { console.error(error) } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI - // @ts-ignore (define in dts) - window.api = api - // @ts-ignore (define in dts) - window.platform = process.platform -} +} \ No newline at end of file diff --git a/src/renderer/src/atoms/player.ts b/src/renderer/src/atoms/player.ts index d145f8f8..fde87fd8 100644 --- a/src/renderer/src/atoms/player.ts +++ b/src/renderer/src/atoms/player.ts @@ -5,12 +5,14 @@ import { jotaiStore } from './store' export const videoAtom = atomWithReset<{ url: string + path: string hash: string size: number name: string playList: { urlWithPrefix: string; name: string }[] }>({ url: '', + path: '', hash: '', size: 0, name: '', diff --git a/src/renderer/src/atoms/settings/player.ts b/src/renderer/src/atoms/settings/player.ts index ee872f56..f8897221 100644 --- a/src/renderer/src/atoms/settings/player.ts +++ b/src/renderer/src/atoms/settings/player.ts @@ -2,6 +2,7 @@ import { danmakuDurationList, danmakuEndAreaList, danmakuFontSizeList, + PlayerKernelList, } from '@renderer/components/modules/settings/views/player/list' import type { SelectGroup } from '@renderer/components/modules/shared/setting/SettingSelect' import { useAtom, useAtomValue } from 'jotai' @@ -17,6 +18,7 @@ const createPlayerDefaultSettings = () => { enableTraditionalToSimplified: false, enableAutomaticEpisodeSwitching: false, enableMiniProgress: true, + playerKernel: getSelectedDefaultValue(PlayerKernelList) ?? 'html5', danmakuFontSize: getSelectedDefaultValue(danmakuFontSizeList) ?? '26', danmakuDuration: getSelectedDefaultValue(danmakuDurationList) ?? '15000', danmakuEndArea: getSelectedDefaultValue(danmakuEndAreaList)!, diff --git a/src/renderer/src/components/modules/core/ffmpeg-player/FFmpegPlayer.tsx b/src/renderer/src/components/modules/core/ffmpeg-player/FFmpegPlayer.tsx new file mode 100644 index 00000000..a6a46188 --- /dev/null +++ b/src/renderer/src/components/modules/core/ffmpeg-player/FFmpegPlayer.tsx @@ -0,0 +1,181 @@ +// renderer/components/FFmpegPlayer.tsx +import type { WrappedAudioBuffer, WrappedCanvas } from 'mediabunny' +import { ALL_FORMATS, AudioBufferSink, CanvasSink, Input, UrlSource } from 'mediabunny' +import type { FC } from 'react' +import { useEffect, useRef } from 'react' + +interface PlayerProps { + src: string +} + +export const FFmpegPlayer: FC = ({ src }) => { + const canvasRef = useRef(null) + const audioCtxRef = useRef(null) + const totalDurationRef = useRef(0) + const gainNodeRef = useRef(null) + + const audioStartTimeRef = useRef(0) + const playbackTimeAtStartRef = useRef(0) + + const videoIteratorRef = useRef | null>(null) + const audioIteratorRef = useRef | null>(null) + + const nextFrameRef = useRef(null) + const playerRef = useRef(false) + + const queuedAudioNodesRef = useRef>(new Set()) + + useEffect(() => { + playVideo() + }, [src]) + + const getPlaybackTime = () => { + if (playerRef.current && audioCtxRef.current) { + return ( + audioCtxRef.current.currentTime - audioStartTimeRef.current + playbackTimeAtStartRef.current + ) + } + return playbackTimeAtStartRef.current + } + + const playVideo = async () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const input = new Input({ + formats: ALL_FORMATS, + source: new UrlSource(src), + }) + + const videoTrack = await input.getPrimaryVideoTrack() + const audioTrack = await input.getPrimaryAudioTrack() + + if (!videoTrack) { + return + } + + if (!(await videoTrack.canDecode())) return + + canvas.width = videoTrack.displayWidth + canvas.height = videoTrack.displayHeight + + totalDurationRef.current = await input.computeDuration() + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext + const audioCtx = new AudioContextClass({ + sampleRate: audioTrack?.sampleRate, + }) + audioCtxRef.current = audioCtx + + const gainNode = audioCtx.createGain() + gainNode.connect(audioCtx.destination) + gainNode.gain.value = 1 + gainNodeRef.current = gainNode + + const videoSink = new CanvasSink(videoTrack, { poolSize: 2 }) + const audioSink = + audioTrack && (await audioTrack.canDecode()) ? new AudioBufferSink(audioTrack) : null + + videoIteratorRef.current = videoSink.canvases(0) + const firstFrame = (await videoIteratorRef.current.next()).value ?? null + nextFrameRef.current = (await videoIteratorRef.current.next()).value ?? null + if (firstFrame) { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(firstFrame.canvas, 0, 0) + } + + if (audioCtx.state === 'suspended') { + await audioCtx.resume() + } + playerRef.current = true + audioStartTimeRef.current = audioCtx.currentTime + if (audioSink) { + audioIteratorRef.current = audioSink.buffers(0) + runAudioIterator() + } + + const render = async () => { + if (!playerRef.current) return + + const playbackTime = getPlaybackTime() + + if (playbackTime >= totalDurationRef.current) { + playerRef.current = false + return + } + + if (nextFrameRef.current && nextFrameRef.current.timestamp <= playbackTime) { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(nextFrameRef.current.canvas, 0, 0) + nextFrameRef.current = null + updateNextFrame(playbackTime, ctx, canvas) + } + requestAnimationFrame(render) + } + + render() + } + + const runAudioIterator = async () => { + const audioCtx = audioCtxRef.current! + const gainNode = gainNodeRef.current! + const iterator = audioIteratorRef.current! + + for await (const { buffer, timestamp } of iterator) { + if (!playerRef.current) break + + const node = audioCtx.createBufferSource() + node.buffer = buffer + node.connect(gainNode) + const startTimeStamp = audioStartTimeRef.current + timestamp - playbackTimeAtStartRef.current + if (startTimeStamp >= audioCtx.currentTime) { + node.start(startTimeStamp) + } else { + const offset = audioCtx.currentTime - startTimeStamp + if (offset < buffer.duration) { + node.start(audioCtx.currentTime, offset) + } + } + + queuedAudioNodesRef.current.add(node) + node.onended = () => { + queuedAudioNodesRef.current.delete(node) + } + + if (timestamp - getPlaybackTime() >= 1) { + await new Promise((resolve) => { + const id = setInterval(() => { + if (timestamp - getPlaybackTime() < 1) { + clearInterval(id) + resolve() + } + }, 100) + }) + } + } + } + + const updateNextFrame = async ( + playbackTime: number, + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + ) => { + while (true) { + const result = await videoIteratorRef.current?.next() + const newFrame = result?.value ?? null + if (!newFrame) { + break + } + if (newFrame.timestamp <= playbackTime) { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(newFrame.canvas, 0, 0) + } else { + nextFrameRef.current = newFrame + break + } + } + } + + return +} diff --git a/src/renderer/src/components/modules/core/ffmpeg-player/mp4-demuxer.ts b/src/renderer/src/components/modules/core/ffmpeg-player/mp4-demuxer.ts new file mode 100644 index 00000000..ba5af2d2 --- /dev/null +++ b/src/renderer/src/components/modules/core/ffmpeg-player/mp4-demuxer.ts @@ -0,0 +1,263 @@ +/** + * MP4 Demuxer - 使用 mp4box.js 解封装 + * + * 这是最简单的方案:用现成的 mp4box.js 做解封装, + * 然后用 WebCodecs 做解码 + */ + +import type { ISOFile, Movie, Sample, Track } from 'mp4box' +import { createFile, DataStream, Endianness, MP4BoxBuffer } from 'mp4box' + +export interface VideoTrackInfo { + id: number + codec: string + codedWidth: number + codedHeight: number + displayWidth: number + displayHeight: number + description?: Uint8Array // avcC/hvcC box for decoder config + frameRate: number + duration: number +} + +export interface AudioTrackInfo { + id: number + codec: string + sampleRate: number + numberOfChannels: number + description?: Uint8Array + duration: number +} + +export interface DemuxedSample { + type: 'video' | 'audio' + timestamp: number // 微秒 + duration: number // 微秒 + data: Uint8Array + isKeyFrame: boolean +} + +export class MP4Demuxer { + private mp4File: ISOFile + private videoTrack: VideoTrackInfo | null = null + private audioTrack: AudioTrackInfo | null = null + + private onConfig?: (video: VideoTrackInfo | null, audio: AudioTrackInfo | null) => void + private onSample?: (sample: DemuxedSample) => void + private onError?: (error: Error) => void + + constructor() { + this.mp4File = createFile() + this.setupCallbacks() + } + + private setupCallbacks() { + // 解析到 moov box 后触发,获取视频信息 + this.mp4File.onReady = (info: Movie) => { + + // 提取视频轨道信息 + const videoTrack = info.tracks.find((t) => t.type === 'video') + if (videoTrack?.video) { + this.videoTrack = { + id: videoTrack.id, + codec: videoTrack.codec, + codedWidth: videoTrack.video.width, + codedHeight: videoTrack.video.height, + displayWidth: videoTrack.video.width, + displayHeight: videoTrack.video.height, + frameRate: videoTrack.nb_samples / (videoTrack.duration / videoTrack.timescale), + duration: videoTrack.duration / videoTrack.timescale, + description: this.getCodecDescription(videoTrack), + } + + // 设置提取视频 samples - 每次只提取 100 个 + this.mp4File.setExtractionOptions(videoTrack.id, 'video', { + nbSamples: 100, + }) + } + + // 提取音频轨道信息 + const audioTrack = info.tracks.find((t) => t.type === 'audio') + if (audioTrack?.audio) { + this.audioTrack = { + id: audioTrack.id, + codec: audioTrack.codec, + sampleRate: audioTrack.audio.sample_rate, + numberOfChannels: audioTrack.audio.channel_count, + duration: audioTrack.duration / audioTrack.timescale, + description: this.getCodecDescription(audioTrack), + } + + this.mp4File.setExtractionOptions(audioTrack.id, 'audio', { + nbSamples: Infinity, + }) + } + + this.onConfig?.(this.videoTrack, this.audioTrack) + + // 开始提取 samples + this.mp4File.start() + } + + // 提取到 samples 后触发 + this.mp4File.onSamples = (_trackId: number, user: unknown, samples: Sample[]) => { + const isVideo = user === 'video' + + for (const sample of samples) { + const demuxedSample: DemuxedSample = { + type: isVideo ? 'video' : 'audio', + timestamp: (sample.cts * 1_000_000) / sample.timescale, // 转换为微秒 + duration: (sample.duration * 1_000_000) / sample.timescale, + data: new Uint8Array(sample.data!), + isKeyFrame: sample.is_sync, + } + + this.onSample?.(demuxedSample) + } + } + + this.mp4File.onError = (module: string, message: string) => { + this.onError?.(new Error(`${module}: ${message}`)) + } + } + + /** + * 获取 codec description (avcC/hvcC box) + * WebCodecs 需要这个来初始化解码器 + */ + private getCodecDescription(track: Track): Uint8Array | undefined { + const trak = this.mp4File.getTrackById(track.id) + if (!trak) return undefined + + // 遍历 stsd 找到 avcC/hvcC/esds 等 box + + for (const entry of (trak as any).mdia.minf.stbl.stsd.entries) { + // H.264 + if (entry.avcC) { + const stream = new DataStream(undefined, 0, Endianness.BIG_ENDIAN) + entry.avcC.write(stream) + return new Uint8Array(stream.buffer, 8) // 跳过 box header + } + // H.265 + if (entry.hvcC) { + const stream = new DataStream(undefined, 0, Endianness.BIG_ENDIAN) + entry.hvcC.write(stream) + return new Uint8Array(stream.buffer, 8) + } + // VP9 + if (entry.vpcC) { + const stream = new DataStream(undefined, 0, Endianness.BIG_ENDIAN) + entry.vpcC.write(stream) + return new Uint8Array(stream.buffer, 8) + } + // AV1 + if (entry.av1C) { + const stream = new DataStream(undefined, 0, Endianness.BIG_ENDIAN) + entry.av1C.write(stream) + return new Uint8Array(stream.buffer, 8) + } + // AAC + if (entry.esds) { + const stream = new DataStream(undefined, 0, Endianness.BIG_ENDIAN) + entry.esds.write(stream) + return new Uint8Array(stream.buffer, 8) + } + } + + return undefined + } + + /** + * 设置回调 + */ + onConfigReady(callback: (video: VideoTrackInfo | null, audio: AudioTrackInfo | null) => void) { + this.onConfig = callback + } + + onSampleReady(callback: (sample: DemuxedSample) => void) { + this.onSample = callback + } + + onErrorOccurred(callback: (error: Error) => void) { + this.onError = callback + } + + /** + * 追加数据 + */ + appendBuffer(buffer: ArrayBuffer, offset = 0) { + const mp4Buffer = MP4BoxBuffer.fromArrayBuffer(buffer, offset) + this.mp4File.appendBuffer(mp4Buffer) + } + + /** + * 从 File 加载 + */ + async loadFromFile(file: File): Promise { + const reader = file.stream().getReader() + let offset = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + + this.appendBuffer(value.buffer as ArrayBuffer, offset) + offset += value.byteLength + } + + this.mp4File.flush() + } + + /** + * 从 URL 加载 + */ + async loadFromUrl(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`) + } + + const reader = response.body!.getReader() + let offset = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + + this.appendBuffer(value.buffer as ArrayBuffer, offset) + offset += value.byteLength + } + + this.mp4File.flush() + } + + /** + * Seek 到指定时间 + */ + seek(timeInSeconds: number) { + const info = this.mp4File.seek(timeInSeconds, true) + return info + } + + /** + * 暂停提取 samples + */ + pause() { + this.mp4File.stop() + } + + /** + * 恢复提取 samples + */ + resume() { + this.mp4File.start() + } + + /** + * 释放资源 + */ + destroy() { + this.mp4File.stop() + this.mp4File.flush() + } +} diff --git a/src/renderer/src/components/modules/core/ffmpeg-player/player.ts b/src/renderer/src/components/modules/core/ffmpeg-player/player.ts new file mode 100644 index 00000000..bd4d84df --- /dev/null +++ b/src/renderer/src/components/modules/core/ffmpeg-player/player.ts @@ -0,0 +1,491 @@ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +/** + * WebCodecs Player + * + * 简化版播放器,整合: + * - MP4 解封装 (mp4box.js) + * - WebCodecs 解码 + * - Canvas 渲染 + */ + +import type { AudioTrackInfo,VideoTrackInfo } from './mp4-demuxer' +import {MP4Demuxer } from './mp4-demuxer' +import type { DecodedFrame } from './video-decoder' +import { WebCodecsVideoDecoder } from './video-decoder' +import type { WebGLRenderer } from './video-renderer' +import { Canvas2DRenderer, createRenderer } from './video-renderer' + +export interface PlayerOptions { + container: HTMLElement + autoPlay?: boolean + loop?: boolean + muted?: boolean +} + +export interface PlayerState { + duration: number + currentTime: number + isPlaying: boolean + isBuffering: boolean + videoInfo: VideoTrackInfo | null + audioInfo: AudioTrackInfo | null +} + +type PlayerEventType = + | 'ready' + | 'play' + | 'pause' + | 'ended' + | 'timeupdate' + | 'error' + | 'seeking' + | 'seeked' + +export class WebCodecsPlayer { + private container: HTMLElement + private canvas: HTMLCanvasElement + private options: PlayerOptions + + // 核心组件 + private demuxer: MP4Demuxer + private videoDecoder: WebCodecsVideoDecoder | null = null + private renderer: Canvas2DRenderer | WebGLRenderer | null = null + + // 状态 + private state: PlayerState = { + duration: 0, + currentTime: 0, + isPlaying: false, + isBuffering: false, + videoInfo: null, + audioInfo: null, + } + + // 帧缓冲队列 + private frameQueue: DecodedFrame[] = [] + private maxFrameQueueSize = 120 // 约 4 秒缓冲 + private isPaused = false // 解码暂停标志 + + // 播放控制 + private playbackStartTime = 0 + private firstFrameTimestamp = -1 // 第一帧的时间戳,用于同步 + private lastFrameTime = 0 + private animationFrameId: number | null = null + + // 事件监听 + + private eventListeners = new Map>() + + // 性能统计 + private stats = { + decodedFrames: 0, + droppedFrames: 0, + fps: 0, + lastFpsTime: 0, + frameCount: 0, + } + + constructor(options: PlayerOptions) { + this.options = options + this.container = options.container + + // 创建 canvas + this.canvas = document.createElement('canvas') + this.canvas.style.width = '100%' + this.canvas.style.height = '100%' + this.canvas.style.backgroundColor = '#000' + this.container.append(this.canvas) + + // 创建 demuxer + this.demuxer = new MP4Demuxer() + this.setupDemuxer() + } + + private setupDemuxer() { + // 当解析到视频信息时 + this.demuxer.onConfigReady(async (video, audio) => { + this.state.videoInfo = video + this.state.audioInfo = audio + + if (video) { + this.state.duration = video.duration + + // 设置 canvas 尺寸 + this.canvas.width = video.codedWidth + this.canvas.height = video.codedHeight + + // 创建渲染器 + this.renderer = createRenderer({ + canvas: this.canvas, + width: video.codedWidth, + height: video.codedHeight, + preferWebGL: false, // 先用 Canvas 2D,更简单 + }) + + // 创建解码器 + this.videoDecoder = new WebCodecsVideoDecoder() + const success = await this.videoDecoder.configure({ + codec: video.codec, + codedWidth: video.codedWidth, + codedHeight: video.codedHeight, + description: video.description, + hardwareAcceleration: 'prefer-hardware', + }) + + if (!success) { + this.emit('error', new Error(`Failed to configure decoder for codec: ${video.codec}`)) + return + } + + // 设置解码回调 + this.videoDecoder.setFrameCallback((frame) => { + this.handleDecodedFrame(frame) + }) + + this.videoDecoder.setErrorCallback((error) => { + this.emit('error', error) + }) + + this.emit('ready', this.state) + + // 自动播放 + if (this.options.autoPlay) { + this.play() + } + } + }) + + // 当解析到 sample 时 + this.demuxer.onSampleReady((sample) => { + if (sample.type === 'video' && this.videoDecoder) { + this.stats.decodedFrames++ + this.videoDecoder.decode(sample.data, sample.timestamp, sample.isKeyFrame) + } + }) + + // 错误处理 + this.demuxer.onErrorOccurred((error) => { + this.emit('error', error) + }) + } + + private handleDecodedFrame(decodedFrame: DecodedFrame) { + // 添加到队列 + this.frameQueue.push(decodedFrame) + + // 按时间戳排序 + this.frameQueue.sort((a, b) => a.timestamp - b.timestamp) + + // 背压控制:队列满时暂停 demuxer + if (this.frameQueue.length >= this.maxFrameQueueSize && !this.isPaused) { + this.isPaused = true + this.demuxer.pause() + } + } + + /** + * 渲染循环 + */ + private renderLoop = () => { + try { + if (!this.state.isPlaying) { + console.warn('[Player] renderLoop: isPlaying is false, stopping') + return + } + + const now = performance.now() + + // 如果还没有帧,继续等待 + if (this.frameQueue.length === 0) { + this.animationFrameId = requestAnimationFrame(this.renderLoop) + return + } + + // 记录第一帧的时间戳,用于同步 + if (this.firstFrameTimestamp < 0) { + this.firstFrameTimestamp = this.frameQueue[0].timestamp + this.playbackStartTime = now + console.info( + '[Player] First frame sync:', + this.firstFrameTimestamp, + 'queue:', + this.frameQueue.length, + ) + } + + // 计算播放时间(相对于第一帧) + const playbackTime = (now - this.playbackStartTime) * 1000 + this.firstFrameTimestamp + + // 调试:每秒输出一次状态 + if (now - this.stats.lastFpsTime >= 1000) { + console.info( + '[Player] Status - queue:', + this.frameQueue.length, + 'playbackTime:', + playbackTime, + 'nextFrame:', + this.frameQueue[0]?.timestamp, + ) + } + + // 找到应该显示的帧 + let frameToRender: DecodedFrame | null = null + + while (this.frameQueue.length > 0) { + const frame = this.frameQueue[0] + + if (frame.timestamp <= playbackTime) { + // 这一帧应该显示了 + frameToRender = this.frameQueue.shift()! + + // 如果还有更早应该显示的帧,跳过它们(丢帧) + while (this.frameQueue.length > 0 && this.frameQueue[0].timestamp <= playbackTime) { + const skippedFrame = this.frameQueue.shift()! + skippedFrame.frame.close() + this.stats.droppedFrames++ + } + + break + } else { + // 还没到显示时间 + break + } + } + + // 渲染帧 + if (frameToRender && this.renderer) { + if (this.renderer instanceof Canvas2DRenderer) { + this.renderer.render(frameToRender.frame) + } + + // 更新当前时间 + this.state.currentTime = frameToRender.timestamp / 1_000_000 // 转换为秒 + this.emit('timeupdate', this.state.currentTime) + + // 释放帧资源 + frameToRender.frame.close() + + // FPS 统计 + this.stats.frameCount++ + } + + // 背压控制:队列有空间时恢复 demuxer + if (this.isPaused && this.frameQueue.length < this.maxFrameQueueSize / 2) { + this.isPaused = false + this.demuxer.resume() + } + + // FPS 统计(移到外面,每秒更新一次) + if (now - this.stats.lastFpsTime >= 1000) { + this.stats.fps = this.stats.frameCount + this.stats.frameCount = 0 + this.stats.lastFpsTime = now + } + + // 检查是否播放结束 + if ( + this.state.duration > 0 && + this.state.currentTime >= this.state.duration && + this.frameQueue.length === 0 + ) { + this.handlePlaybackEnded() + return + } + + // 继续下一帧 + this.animationFrameId = requestAnimationFrame(this.renderLoop) + } catch (error) { + console.error('[Player] renderLoop error:', error) + // 即使出错也要继续循环 + this.animationFrameId = requestAnimationFrame(this.renderLoop) + this.emit('error', error) + } + } + + private handlePlaybackEnded() { + this.state.isPlaying = false + this.emit('ended') + + if (this.options.loop) { + this.seek(0) + this.play() + } + } + + // ==================== 公共 API ==================== + + /** + * 加载视频文件 + */ + async load(source: string | File): Promise { + this.state.isBuffering = true + + try { + if (typeof source === 'string') { + await this.demuxer.loadFromUrl(source) + } else { + await this.demuxer.loadFromFile(source) + } + } catch (error) { + this.emit('error', error) + throw error + } finally { + this.state.isBuffering = false + } + } + + /** + * 播放 + */ + play(): void { + if (this.state.isPlaying) return + + this.state.isPlaying = true + // 重置时间同步,让 renderLoop 重新计算 + this.firstFrameTimestamp = -1 + + this.emit('play') + this.animationFrameId = requestAnimationFrame(this.renderLoop) + } + + /** + * 暂停 + */ + pause(): void { + if (!this.state.isPlaying) return + + this.state.isPlaying = false + + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId) + this.animationFrameId = null + } + + this.emit('pause') + } + + /** + * 跳转到指定时间 + */ + async seek(timeInSeconds: number): Promise { + this.emit('seeking') + + // 暂停播放 + const wasPlaying = this.state.isPlaying + this.pause() + + // 清空帧队列 + for (const frame of this.frameQueue) { + frame.frame.close() + } + this.frameQueue = [] + + // 重置时间同步 + this.firstFrameTimestamp = -1 + + // 重置解码器 + this.videoDecoder?.reset() + + // Seek demuxer + this.demuxer.seek(timeInSeconds) + + // 更新状态 + this.state.currentTime = timeInSeconds + + this.emit('seeked') + + // 如果之前在播放,继续播放 + if (wasPlaying) { + this.play() + } + } + + /** + * 获取当前时间 + */ + get currentTime(): number { + return this.state.currentTime + } + + /** + * 设置当前时间(seek) + */ + set currentTime(value: number) { + this.seek(value) + } + + /** + * 获取时长 + */ + get duration(): number { + return this.state.duration + } + + /** + * 是否正在播放 + */ + get paused(): boolean { + return !this.state.isPlaying + } + + /** + * 获取视频信息 + */ + get videoInfo(): VideoTrackInfo | null { + return this.state.videoInfo + } + + /** + * 获取性能统计 + */ + get statistics() { + return { + ...this.stats, + queueSize: this.frameQueue.length, + decoderQueueSize: this.videoDecoder?.queueSize ?? 0, + } + } + + // ==================== 事件系统 ==================== + + on(event: PlayerEventType, callback: Function): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()) + } + this.eventListeners.get(event)!.add(callback) + } + + off(event: PlayerEventType, callback: Function): void { + this.eventListeners.get(event)?.delete(callback) + } + + private emit(event: PlayerEventType, ...args: any[]): void { + this.eventListeners.get(event)?.forEach((callback) => { + try { + callback(...args) + } catch (e) { + console.error(`Error in event handler for ${event}:`, e) + } + }) + } + + // ==================== 销毁 ==================== + + destroy(): void { + this.pause() + + // 清空帧队列 + for (const frame of this.frameQueue) { + frame.frame.close() + } + this.frameQueue = [] + + // 销毁组件 + this.videoDecoder?.close() + this.renderer?.destroy() + this.demuxer.destroy() + + // 移除 canvas + this.canvas.remove() + } +} diff --git a/src/renderer/src/components/modules/core/ffmpeg-player/video-decoder.ts b/src/renderer/src/components/modules/core/ffmpeg-player/video-decoder.ts new file mode 100644 index 00000000..f2feab7e --- /dev/null +++ b/src/renderer/src/components/modules/core/ffmpeg-player/video-decoder.ts @@ -0,0 +1,285 @@ +/** + * WebCodecs 视频解码器 + * + * 优先使用 WebCodecs 硬解,如果不支持则需要降级到 WASM + */ + +import type { VideoTrackInfo } from './mp4-demuxer' + +export interface DecodedFrame { + frame: VideoFrame + timestamp: number // 微秒 +} + +export interface VideoDecoderConfig { + codec: string + codedWidth: number + codedHeight: number + description?: Uint8Array + hardwareAcceleration?: 'prefer-hardware' | 'prefer-software' | 'no-preference' +} + +export class WebCodecsVideoDecoder { + private decoder: VideoDecoder | null = null + private pendingFrames: DecodedFrame[] = [] + private config: VideoDecoderConfig | null = null + private needsKeyFrame = true // 是否需要等待关键帧 + + private onFrame?: (frame: DecodedFrame) => void + private onError?: (error: Error) => void + + private decodePromiseResolve?: () => void + private frameCount = 0 + private isConfigured = false + + /** + * 检查 WebCodecs 是否支持指定 codec + */ + static async isSupported(config: VideoDecoderConfig): Promise { + if (!('VideoDecoder' in window)) { + console.warn('[VideoDecoder] WebCodecs not supported in this browser') + return false + } + + try { + const support = await VideoDecoder.isConfigSupported({ + codec: config.codec, + codedWidth: config.codedWidth, + codedHeight: config.codedHeight, + description: config.description, + hardwareAcceleration: config.hardwareAcceleration || 'prefer-hardware', + }) + + return support.supported ?? false + } catch (e) { + console.error('[VideoDecoder] isConfigSupported error:', e) + return false + } + } + + /** + * 从轨道信息创建解码器配置 + */ + static fromTrackInfo(track: VideoTrackInfo): VideoDecoderConfig { + return { + codec: track.codec, + codedWidth: track.codedWidth, + codedHeight: track.codedHeight, + description: track.description, + hardwareAcceleration: 'prefer-hardware', + } + } + + /** + * 配置解码器 + */ + async configure(config: VideoDecoderConfig): Promise { + this.config = config + + // 检查支持性 + const supported = await WebCodecsVideoDecoder.isSupported(config) + if (!supported) { + console.error('[VideoDecoder] Codec not supported:', config.codec) + return false + } + + // 创建解码器 + this.decoder = new VideoDecoder({ + output: (frame) => { + this.handleDecodedFrame(frame) + }, + error: (error) => { + this.onError?.(error) + }, + }) + + // 配置解码器 + try { + this.decoder.configure({ + codec: config.codec, + codedWidth: config.codedWidth, + codedHeight: config.codedHeight, + description: config.description, + hardwareAcceleration: config.hardwareAcceleration || 'prefer-hardware', + }) + + this.isConfigured = true + return true + } catch (e) { + console.error('[VideoDecoder] Configure error:', e) + return false + } + } + + private handleDecodedFrame(frame: VideoFrame) { + this.frameCount++ + + const decodedFrame: DecodedFrame = { + frame, + timestamp: frame.timestamp ?? 0, + } + + if (this.onFrame) { + this.onFrame(decodedFrame) + } else { + this.pendingFrames.push(decodedFrame) + } + } + + /** + * 设置帧输出回调 + */ + setFrameCallback(callback: (frame: DecodedFrame) => void) { + this.onFrame = callback + + // 输出之前积压的帧 + while (this.pendingFrames.length > 0) { + const frame = this.pendingFrames.shift()! + callback(frame) + } + } + + /** + * 设置错误回调 + */ + setErrorCallback(callback: (error: Error) => void) { + this.onError = callback + } + + /** + * 解码一个 chunk + */ + decode(data: Uint8Array, timestamp: number, isKeyFrame: boolean) { + if (!this.decoder || this.decoder.state !== 'configured') { + console.warn('[VideoDecoder] Decoder not ready') + return + } + + // 如果需要关键帧但当前不是关键帧,跳过 + if (this.needsKeyFrame && !isKeyFrame) { + return + } + + // 遇到关键帧后,清除标志 + if (isKeyFrame) { + this.needsKeyFrame = false + } + + const chunk = new EncodedVideoChunk({ + type: isKeyFrame ? 'key' : 'delta', + timestamp, + data, + }) + + try { + this.decoder.decode(chunk) + } catch (e) { + this.onError?.(e as Error) + } + } + + /** + * 等待所有帧解码完成 + */ + async flush(): Promise { + if (!this.decoder) return + + await this.decoder.flush() + } + + /** + * 重置解码器(用于 seek) + */ + reset() { + if (!this.decoder) return + + this.decoder.reset() + this.needsKeyFrame = true + + // 清空积压的帧 + for (const frame of this.pendingFrames) { + frame.frame.close() + } + this.pendingFrames = [] + + // 重新配置 + if (this.config) { + this.decoder.configure({ + codec: this.config.codec, + codedWidth: this.config.codedWidth, + codedHeight: this.config.codedHeight, + description: this.config.description, + hardwareAcceleration: this.config.hardwareAcceleration || 'prefer-hardware', + }) + } + } + + /** + * 获取解码器状态 + */ + get state(): string { + return this.decoder?.state ?? 'closed' + } + + /** + * 获取待解码队列大小 + */ + get queueSize(): number { + return this.decoder?.decodeQueueSize ?? 0 + } + + /** + * 关闭解码器 + */ + close() { + if (this.decoder) { + this.decoder.close() + this.decoder = null + } + + // 释放所有帧 + for (const frame of this.pendingFrames) { + frame.frame.close() + } + this.pendingFrames = [] + this.isConfigured = false + } +} + +/** + * 解码器工厂 + * 根据 codec 选择合适的解码器 + */ +export const DecoderFactory = { + /** + * 创建视频解码器 + * 优先 WebCodecs,不支持时降级到 WASM + */ + async createVideoDecoder( + track: VideoTrackInfo, + options?: { preferSoftware?: boolean }, + ): Promise { + const config = WebCodecsVideoDecoder.fromTrackInfo(track) + + if (options?.preferSoftware) { + config.hardwareAcceleration = 'prefer-software' + } + + // 尝试 WebCodecs + const webCodecsDecoder = new WebCodecsVideoDecoder() + const success = await webCodecsDecoder.configure(config) + + if (success) { + return webCodecsDecoder + } + + // WebCodecs 不支持,需要 WASM 降级 + console.warn('[DecoderFactory] WebCodecs not supported, need WASM fallback') + console.warn('[DecoderFactory] WASM decoder not implemented yet') + + // TODO: 返回 WASM 解码器 + // return new WasmVideoDecoder(config) + + return null + }, +} diff --git a/src/renderer/src/components/modules/core/ffmpeg-player/video-renderer.ts b/src/renderer/src/components/modules/core/ffmpeg-player/video-renderer.ts new file mode 100644 index 00000000..9076d94a --- /dev/null +++ b/src/renderer/src/components/modules/core/ffmpeg-player/video-renderer.ts @@ -0,0 +1,379 @@ +/** + * 视频渲染器 + * + * 支持两种渲染方式: + * 1. Canvas 2D - 简单,但性能较低 + * 2. WebGL - 高性能,支持 YUV 直接渲染 + */ + +export interface RendererOptions { + canvas: HTMLCanvasElement + width: number + height: number + preferWebGL?: boolean +} + +/** + * Canvas 2D 渲染器 + * 简单直接,适合低分辨率或兼容性要求高的场景 + */ +export class Canvas2DRenderer { + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + + constructor(options: RendererOptions) { + this.canvas = options.canvas + this.canvas.width = options.width + this.canvas.height = options.height + + const ctx = this.canvas.getContext('2d', { + alpha: false, + desynchronized: true // 提高性能 + }) + + if (!ctx) { + throw new Error('Failed to get 2d context') + } + + this.ctx = ctx + } + + /** + * 渲染 VideoFrame(WebCodecs 输出) + */ + render(frame: VideoFrame) { + // VideoFrame 可以直接绘制到 canvas + this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height) + } + + /** + * 渲染 ImageData(WASM 解码输出 RGBA) + */ + renderImageData(imageData: ImageData) { + this.ctx.putImageData(imageData, 0, 0) + } + + /** + * 渲染 RGBA buffer + */ + renderRGBA(buffer: Uint8ClampedArray, width: number, height: number) { + const imageData = new ImageData(buffer, width, height) + this.ctx.putImageData(imageData, 0, 0) + } + + /** + * 清空画布 + */ + clear() { + this.ctx.fillStyle = '#000' + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) + } + + /** + * 调整大小 + */ + resize(width: number, height: number) { + this.canvas.width = width + this.canvas.height = height + } + + destroy() { + this.clear() + } +} + + +/** + * WebGL 渲染器 + * 高性能,支持 YUV -> RGB 在 GPU 上转换 + */ +export class WebGLRenderer { + private canvas: HTMLCanvasElement + private gl: WebGLRenderingContext + private program: WebGLProgram + private textures: WebGLTexture[] = [] + + // Uniform locations + private yTextureLoc: WebGLUniformLocation | null = null + private uTextureLoc: WebGLUniformLocation | null = null + private vTextureLoc: WebGLUniformLocation | null = null + + private videoWidth: number + private videoHeight: number + + // YUV -> RGB 转换矩阵 (BT.709) + private static readonly VERTEX_SHADER = ` + attribute vec2 a_position; + attribute vec2 a_texCoord; + varying vec2 v_texCoord; + + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + } + ` + + private static readonly FRAGMENT_SHADER_YUV = ` + precision mediump float; + varying vec2 v_texCoord; + + uniform sampler2D u_yTexture; + uniform sampler2D u_uTexture; + uniform sampler2D u_vTexture; + + void main() { + float y = texture2D(u_yTexture, v_texCoord).r; + float u = texture2D(u_uTexture, v_texCoord).r - 0.5; + float v = texture2D(u_vTexture, v_texCoord).r - 0.5; + + // BT.709 YUV -> RGB + float r = y + 1.5748 * v; + float g = y - 0.1873 * u - 0.4681 * v; + float b = y + 1.8556 * u; + + gl_FragColor = vec4(r, g, b, 1.0); + } + ` + + // 用于直接渲染 VideoFrame/RGBA 的 shader + private static readonly FRAGMENT_SHADER_RGBA = ` + precision mediump float; + varying vec2 v_texCoord; + uniform sampler2D u_texture; + + void main() { + gl_FragColor = texture2D(u_texture, v_texCoord); + } + ` + + constructor(options: RendererOptions) { + this.canvas = options.canvas + this.canvas.width = options.width + this.canvas.height = options.height + this.videoWidth = options.width + this.videoHeight = options.height + + const gl = this.canvas.getContext('webgl', { + alpha: false, + antialias: false, + depth: false, + desynchronized: true, + powerPreference: 'high-performance' + }) + + if (!gl) { + throw new Error('WebGL not supported') + } + + this.gl = gl + this.program = this.createProgram( + WebGLRenderer.VERTEX_SHADER, + WebGLRenderer.FRAGMENT_SHADER_YUV + ) + + this.initBuffers() + this.initTextures() + } + + private createProgram(vertexSource: string, fragmentSource: string): WebGLProgram { + const {gl} = this + + const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource) + const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource) + + const program = gl.createProgram()! + gl.attachShader(program, vertexShader) + gl.attachShader(program, fragmentShader) + gl.linkProgram(program) + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link error: ${ gl.getProgramInfoLog(program)}`) + } + + return program + } + + private compileShader(type: number, source: string): WebGLShader { + const {gl} = this + const shader = gl.createShader(type)! + + gl.shaderSource(shader, source) + gl.compileShader(shader) + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`Shader compile error: ${ gl.getShaderInfoLog(shader)}`) + } + + return shader + } + + private initBuffers() { + const {gl} = this + + // 顶点位置(全屏四边形) + const positions = new Float32Array([ + -1, -1, + 1, -1, + -1, 1, + 1, 1 + ]) + + // 纹理坐标(Y轴翻转) + const texCoords = new Float32Array([ + 0, 1, + 1, 1, + 0, 0, + 1, 0 + ]) + + // 创建并绑定顶点缓冲 + const positionBuffer = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) + gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW) + + const positionLoc = gl.getAttribLocation(this.program, 'a_position') + gl.enableVertexAttribArray(positionLoc) + gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0) + + // 创建并绑定纹理坐标缓冲 + const texCoordBuffer = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer) + gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW) + + const texCoordLoc = gl.getAttribLocation(this.program, 'a_texCoord') + gl.enableVertexAttribArray(texCoordLoc) + gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0) + } + + private initTextures() { + const {gl} = this + + gl.useProgram(this.program) + + // 创建 Y, U, V 三个纹理 + for (let i = 0; i < 3; i++) { + const texture = gl.createTexture()! + gl.activeTexture(gl.TEXTURE0 + i) + gl.bindTexture(gl.TEXTURE_2D, texture) + + // 设置纹理参数 + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + + this.textures.push(texture) + } + + // 设置 uniform + this.yTextureLoc = gl.getUniformLocation(this.program, 'u_yTexture') + this.uTextureLoc = gl.getUniformLocation(this.program, 'u_uTexture') + this.vTextureLoc = gl.getUniformLocation(this.program, 'u_vTexture') + + gl.uniform1i(this.yTextureLoc, 0) + gl.uniform1i(this.uTextureLoc, 1) + gl.uniform1i(this.vTextureLoc, 2) + } + + /** + * 渲染 YUV420P 数据(WASM 解码器可能输出这种格式) + */ + renderYUV( + yPlane: Uint8Array, yStride: number, + uPlane: Uint8Array, uStride: number, + vPlane: Uint8Array, vStride: number, + width: number, height: number + ) { + const {gl} = this + + gl.viewport(0, 0, this.canvas.width, this.canvas.height) + + // 上传 Y 平面 + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this.textures[0]) + gl.texImage2D( + gl.TEXTURE_2D, 0, gl.LUMINANCE, + yStride, height, 0, + gl.LUMINANCE, gl.UNSIGNED_BYTE, yPlane + ) + + // 上传 U 平面 + gl.activeTexture(gl.TEXTURE1) + gl.bindTexture(gl.TEXTURE_2D, this.textures[1]) + gl.texImage2D( + gl.TEXTURE_2D, 0, gl.LUMINANCE, + uStride, height / 2, 0, + gl.LUMINANCE, gl.UNSIGNED_BYTE, uPlane + ) + + // 上传 V 平面 + gl.activeTexture(gl.TEXTURE2) + gl.bindTexture(gl.TEXTURE_2D, this.textures[2]) + gl.texImage2D( + gl.TEXTURE_2D, 0, gl.LUMINANCE, + vStride, height / 2, 0, + gl.LUMINANCE, gl.UNSIGNED_BYTE, vPlane + ) + + // 绘制 + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) + } + + /** + * 渲染 VideoFrame(WebCodecs 输出) + * 使用 texImage2D 直接上传 VideoFrame + */ + render(frame: VideoFrame) { + const {gl} = this + + gl.viewport(0, 0, this.canvas.width, this.canvas.height) + + // VideoFrame 可以直接作为纹理源 + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this.textures[0]) + + // @ts-ignore - VideoFrame 可以作为 texImage2D 的源 + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame) + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) + } + + clear() { + const {gl} = this + gl.clearColor(0, 0, 0, 1) + gl.clear(gl.COLOR_BUFFER_BIT) + } + + resize(width: number, height: number) { + this.canvas.width = width + this.canvas.height = height + this.videoWidth = width + this.videoHeight = height + } + + destroy() { + const {gl} = this + + for (const texture of this.textures) { + gl.deleteTexture(texture) + } + + gl.deleteProgram(this.program) + } +} + + +/** + * 渲染器工厂 + */ +export function createRenderer(options: RendererOptions): Canvas2DRenderer | WebGLRenderer { + if (options.preferWebGL) { + try { + return new WebGLRenderer(options) + } catch (e) { + console.warn('WebGL not available, falling back to Canvas 2D:', e) + } + } + + return new Canvas2DRenderer(options) +} diff --git a/src/renderer/src/components/modules/player/Context.tsx b/src/renderer/src/components/modules/core/html5-player/Context.tsx similarity index 100% rename from src/renderer/src/components/modules/player/Context.tsx rename to src/renderer/src/components/modules/core/html5-player/Context.tsx diff --git a/src/renderer/src/components/modules/player/index.tsx b/src/renderer/src/components/modules/core/html5-player/HTML5Player.tsx similarity index 93% rename from src/renderer/src/components/modules/player/index.tsx rename to src/renderer/src/components/modules/core/html5-player/HTML5Player.tsx index efb719c9..1e5f2cf4 100644 --- a/src/renderer/src/components/modules/player/index.tsx +++ b/src/renderer/src/components/modules/core/html5-player/HTML5Player.tsx @@ -12,7 +12,7 @@ interface PlayerProps { url: string } -export const Player: FC = (props) => { +export const HTML5Player: FC = (props) => { const { playerRef, playerInstance } = useXgPlayer(props.url) return ( <> diff --git a/src/renderer/src/components/modules/player/initialize/Event.tsx b/src/renderer/src/components/modules/core/html5-player/initialize/Event.tsx similarity index 100% rename from src/renderer/src/components/modules/player/initialize/Event.tsx rename to src/renderer/src/components/modules/core/html5-player/initialize/Event.tsx diff --git a/src/renderer/src/components/modules/player/initialize/Subtitle.tsx b/src/renderer/src/components/modules/core/html5-player/initialize/Subtitle.tsx similarity index 100% rename from src/renderer/src/components/modules/player/initialize/Subtitle.tsx rename to src/renderer/src/components/modules/core/html5-player/initialize/Subtitle.tsx diff --git a/src/renderer/src/components/modules/player/initialize/config.ts b/src/renderer/src/components/modules/core/html5-player/initialize/config.ts similarity index 100% rename from src/renderer/src/components/modules/player/initialize/config.ts rename to src/renderer/src/components/modules/core/html5-player/initialize/config.ts diff --git a/src/renderer/src/components/modules/player/initialize/hooks.tsx b/src/renderer/src/components/modules/core/html5-player/initialize/hooks.tsx similarity index 100% rename from src/renderer/src/components/modules/player/initialize/hooks.tsx rename to src/renderer/src/components/modules/core/html5-player/initialize/hooks.tsx diff --git a/src/renderer/src/components/modules/player/loading/PlayerProvider.tsx b/src/renderer/src/components/modules/core/html5-player/loading/PlayerProvider.tsx similarity index 95% rename from src/renderer/src/components/modules/player/loading/PlayerProvider.tsx rename to src/renderer/src/components/modules/core/html5-player/loading/PlayerProvider.tsx index a7ea0d4b..9e481ce1 100644 --- a/src/renderer/src/components/modules/player/loading/PlayerProvider.tsx +++ b/src/renderer/src/components/modules/core/html5-player/loading/PlayerProvider.tsx @@ -4,8 +4,8 @@ import { LoadingStatus, videoAtom, } from '@renderer/atoms/player' -import { MatchAnimeDialog } from '@renderer/components/modules/player/loading/dialog/MatchAnimeDialog' -import { LoadingDanmuTimeLine } from '@renderer/components/modules/player/loading/Timeline' +import { MatchAnimeDialog } from '@renderer/components/modules/core/html5-player/loading/dialog/MatchAnimeDialog' +import { LoadingDanmuTimeLine } from '@renderer/components/modules/core/html5-player/loading/Timeline' import queryClient from '@renderer/lib/query-client' import { apiClient } from '@renderer/request' import { useAtom, useAtomValue } from 'jotai' diff --git a/src/renderer/src/components/modules/player/loading/Timeline.tsx b/src/renderer/src/components/modules/core/html5-player/loading/Timeline.tsx similarity index 100% rename from src/renderer/src/components/modules/player/loading/Timeline.tsx rename to src/renderer/src/components/modules/core/html5-player/loading/Timeline.tsx diff --git a/src/renderer/src/components/modules/player/loading/dialog/MatchAnimeDialog.tsx b/src/renderer/src/components/modules/core/html5-player/loading/dialog/MatchAnimeDialog.tsx similarity index 100% rename from src/renderer/src/components/modules/player/loading/dialog/MatchAnimeDialog.tsx rename to src/renderer/src/components/modules/core/html5-player/loading/dialog/MatchAnimeDialog.tsx diff --git a/src/renderer/src/components/modules/player/loading/dialog/hooks.ts b/src/renderer/src/components/modules/core/html5-player/loading/dialog/hooks.ts similarity index 100% rename from src/renderer/src/components/modules/player/loading/dialog/hooks.ts rename to src/renderer/src/components/modules/core/html5-player/loading/dialog/hooks.ts diff --git a/src/renderer/src/components/modules/player/loading/hooks.ts b/src/renderer/src/components/modules/core/html5-player/loading/hooks.ts similarity index 99% rename from src/renderer/src/components/modules/player/loading/hooks.ts rename to src/renderer/src/components/modules/core/html5-player/loading/hooks.ts index 80230668..41ccd51c 100644 --- a/src/renderer/src/components/modules/player/loading/hooks.ts +++ b/src/renderer/src/components/modules/core/html5-player/loading/hooks.ts @@ -54,6 +54,7 @@ export const useVideo = () => { } let url = '' + let path = '' let playList: { urlWithPrefix: string name: string @@ -62,7 +63,7 @@ export const useVideo = () => { if (isWeb) { url = URL.createObjectURL(file) } else { - const path = window.api.showFilePath(file) + path = window.api.showFilePath(file) playList = (await tipcClient?.getAnimeInSamePath({ path })) ?? [] url = `${MARCHEN_PROTOCOL_PREFIX}${path}` tipcClient?.addRecentDocument({ path }) @@ -70,7 +71,7 @@ export const useVideo = () => { const { size, name: fileName } = file try { const hash = await calculateFileHash(file) - setVideo((prev) => ({ ...prev, url, hash, size, name: fileName, playList })) + setVideo((prev) => ({ ...prev, url, path, hash, size, name: fileName, playList })) setProgress(LoadingStatus.CALC_HASH) } catch (error) { console.error('Failed to calculate file hash:', error) @@ -465,4 +466,4 @@ export const useLoadingHistoricalAnime = () => { setProgress(LoadingStatus.IMPORT_VIDEO) } }, [hash]) -} \ No newline at end of file +} diff --git a/src/renderer/src/components/modules/player/setting/Container.tsx b/src/renderer/src/components/modules/core/html5-player/setting/Container.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/Container.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/Container.tsx diff --git a/src/renderer/src/components/modules/player/setting/Sheet.tsx b/src/renderer/src/components/modules/core/html5-player/setting/Sheet.tsx similarity index 97% rename from src/renderer/src/components/modules/player/setting/Sheet.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/Sheet.tsx index 1a9b99ad..e5cabb0f 100644 --- a/src/renderer/src/components/modules/player/setting/Sheet.tsx +++ b/src/renderer/src/components/modules/core/html5-player/setting/Sheet.tsx @@ -14,7 +14,7 @@ import { useQuery } from '@tanstack/react-query' import { useAtom, useAtomValue } from 'jotai' import { createContext, lazy, use, useEffect } from 'react' -import { MatchDanmakuDialog } from '../../shared/MatchDanmakuDialog' +import { MatchDanmakuDialog } from '../../../shared/MatchDanmakuDialog' import { Danmaku } from './items/damaku/Danmaku' import { Subtitle } from './items/subtitle/Subtitle' diff --git a/src/renderer/src/components/modules/player/setting/items/audio/Audio.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/audio/Audio.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/audio/Audio.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/audio/Audio.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/damaku/AddDanmaku.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/damaku/AddDanmaku.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/damaku/AddDanmaku.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/damaku/AddDanmaku.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/damaku/Danmaku.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/damaku/Danmaku.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/damaku/Danmaku.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/damaku/Danmaku.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/damaku/DanmakuSource.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/damaku/DanmakuSource.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/damaku/DanmakuSource.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/damaku/DanmakuSource.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/playList/PlayList.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/playList/PlayList.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/playList/PlayList.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/playList/PlayList.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/subtitle/Subtitle.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/Subtitle.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/subtitle/Subtitle.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/Subtitle.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/subtitle/SubtitleImport.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/SubtitleImport.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/subtitle/SubtitleImport.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/SubtitleImport.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/subtitle/SubtitleTimeOff.tsx b/src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/SubtitleTimeOff.tsx similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/subtitle/SubtitleTimeOff.tsx rename to src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/SubtitleTimeOff.tsx diff --git a/src/renderer/src/components/modules/player/setting/items/subtitle/hooks.ts b/src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/hooks.ts similarity index 100% rename from src/renderer/src/components/modules/player/setting/items/subtitle/hooks.ts rename to src/renderer/src/components/modules/core/html5-player/setting/items/subtitle/hooks.ts diff --git a/src/renderer/src/components/modules/settings/views/player/VideoSetting.tsx b/src/renderer/src/components/modules/settings/views/player/PlayerSetting.tsx similarity index 66% rename from src/renderer/src/components/modules/settings/views/player/VideoSetting.tsx rename to src/renderer/src/components/modules/settings/views/player/PlayerSetting.tsx index 2c301101..5019ca38 100644 --- a/src/renderer/src/components/modules/settings/views/player/VideoSetting.tsx +++ b/src/renderer/src/components/modules/settings/views/player/PlayerSetting.tsx @@ -1,14 +1,28 @@ import { usePlayerSettings } from '@renderer/atoms/settings/player' +import { SettingSelect } from '@renderer/components/modules/shared/setting/SettingSelect' import { SettingSwitch } from '@renderer/components/modules/shared/setting/SettingSwitch' import { isWeb } from '@renderer/lib/utils' import { FieldLayout, FieldsCardLayout } from '../Layout' +import { PlayerKernelList } from './list' -export const VideoSetting = () => { +export const PlayerSetting = () => { const [playerSetting, setPlayerSetting] = usePlayerSettings() return ( + { + + + setPlayerSetting((prev) => ({ ...prev, playerKernel: value })) + } + /> + + } {!isWeb && ( { return ( - + ) diff --git a/src/renderer/src/components/modules/settings/views/player/list.ts b/src/renderer/src/components/modules/settings/views/player/list.ts index cedf53d7..4d2aa1c1 100644 --- a/src/renderer/src/components/modules/settings/views/player/list.ts +++ b/src/renderer/src/components/modules/settings/views/player/list.ts @@ -79,3 +79,15 @@ export const danmakuEndAreaList = [ value: '1', }, ] satisfies SelectGroup[] + +export const PlayerKernelList = [ + { + label: 'HTML5', + value: 'html5', + default: true, + }, + { + label: 'FFmpeg(实验)', + value: 'ffmpeg', + }, +] diff --git a/src/renderer/src/components/modules/shared/MatchDanmakuDialog.tsx b/src/renderer/src/components/modules/shared/MatchDanmakuDialog.tsx index 213dad94..3ae80446 100644 --- a/src/renderer/src/components/modules/shared/MatchDanmakuDialog.tsx +++ b/src/renderer/src/components/modules/shared/MatchDanmakuDialog.tsx @@ -7,9 +7,9 @@ import { RouteName, useCurrentRoute } from '@renderer/router' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' -import { showMatchAnimeDialogAtom } from '../player/loading/dialog/hooks' -import { MatchAnimeDialog } from '../player/loading/dialog/MatchAnimeDialog' -import { saveToHistory } from '../player/loading/hooks' +import { showMatchAnimeDialogAtom } from '../core/html5-player/loading/dialog/hooks' +import { MatchAnimeDialog } from '../core/html5-player/loading/dialog/MatchAnimeDialog' +import { saveToHistory } from '../core/html5-player/loading/hooks' export const MatchDanmakuDialog = () => { const { hash } = useAtomValue(showMatchAnimeDialogAtom) diff --git a/src/renderer/src/page/history/index.tsx b/src/renderer/src/page/history/index.tsx index 01fc982c..080ec33e 100644 --- a/src/renderer/src/page/history/index.tsx +++ b/src/renderer/src/page/history/index.tsx @@ -1,6 +1,6 @@ import { useAppSettings, useAppSettingsValue } from '@renderer/atoms/settings/app' import { RouterLayout } from '@renderer/components/layout/root/RouterLayout' -import { showMatchAnimeDialog } from '@renderer/components/modules/player/loading/dialog/hooks' +import { showMatchAnimeDialog } from '@renderer/components/modules/core/html5-player/loading/dialog/hooks' import { MatchDanmakuDialog } from '@renderer/components/modules/shared/MatchDanmakuDialog' import { Badge } from '@renderer/components/ui/badge' import { FunctionAreaButton, FunctionAreaToggle } from '@renderer/components/ui/button' diff --git a/src/renderer/src/page/player/index.tsx b/src/renderer/src/page/player/index.tsx index 23b09feb..15353721 100644 --- a/src/renderer/src/page/player/index.tsx +++ b/src/renderer/src/page/player/index.tsx @@ -1,6 +1,8 @@ -import { Player } from '@renderer/components/modules/player' -import { useVideo } from '@renderer/components/modules/player/loading/hooks' -import { VideoProvider } from '@renderer/components/modules/player/loading/PlayerProvider' +import { usePlayerSettingsValue } from '@renderer/atoms/settings/player' +import { FFmpegPlayer } from '@renderer/components/modules/core/ffmpeg-player/FFmpegPlayer' +import { HTML5Player } from '@renderer/components/modules/core/html5-player/HTML5Player' +import { useVideo } from '@renderer/components/modules/core/html5-player/loading/hooks' +import { VideoProvider } from '@renderer/components/modules/core/html5-player/loading/PlayerProvider' import { cn, isWeb } from '@renderer/lib/utils' import { AnimatePresence, m } from 'framer-motion' import type { FC } from 'react' @@ -9,6 +11,7 @@ import { useCallback, useMemo, useRef } from 'react' export default function VideoPlayer() { const { importAnimeViaIPC, importAnimeViaDragging, video } = useVideo() const fileInputRef = useRef(null) + const { playerKernel } = usePlayerSettingsValue() const { url } = video const manualImport = useCallback(() => { if (isWeb) { @@ -17,16 +20,30 @@ export default function VideoPlayer() { importAnimeViaIPC() }, [importAnimeViaIPC]) - const content = useMemo( - () => (url ? : ), - [url, manualImport], - ) + const content = useMemo(() => { + if (!url) { + return + } + switch (playerKernel) { + case 'html5': { + return + } + case 'ffmpeg': { + return + } + + default: { + return + } + } + }, [url, manualImport]) + return (
    e.preventDefault()} - className={cn('flex size-full items-center justify-center ')} + className={cn('flex size-full items-center justify-center')} > {content} {!url && ( diff --git a/src/renderer/src/providers/TipcListener.tsx b/src/renderer/src/providers/TipcListener.tsx index f6e84907..c6361035 100644 --- a/src/renderer/src/providers/TipcListener.tsx +++ b/src/renderer/src/providers/TipcListener.tsx @@ -3,7 +3,7 @@ import type { useAppSettingsValue } from '@renderer/atoms/settings/app' import { appSettingAtom } from '@renderer/atoms/settings/app' import { jotaiStore } from '@renderer/atoms/store' import { WindowState, windowStateAtom } from '@renderer/atoms/window' -import { useVideo } from '@renderer/components/modules/player/loading/hooks' +import { useVideo } from '@renderer/components/modules/core/html5-player/loading/hooks' import { useSettingModal } from '@renderer/components/modules/settings/hooks' import { settingTabs } from '@renderer/components/modules/settings/tabs' import { toast } from '@renderer/components/ui/toast/use-toast'