├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── .vscode └── settings.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.3.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── assets ├── conv ├── license.rtf ├── miraktest.icns ├── miraktest.ico ├── miraktest.iconset │ ├── icon_128x128.png │ ├── icon_16x16.png │ ├── icon_256x256.png │ ├── icon_32x32.png │ └── icon_512x512.png ├── miraktest_logo.png ├── miraktest_text.svg ├── resize ├── rounded-mplus-1m-arib.woff2 └── rounded-mplus-m1-arib.md ├── electron-builder.yml ├── entitlements.plist ├── index.html ├── package.json ├── pickRequiredDeps.ts ├── postcss.config.js ├── setBuildVersion.ts ├── setLicenseInDts.ts ├── setPackageVersion.ts ├── setup_libvlc.ps1 ├── setup_libvlc_mac.sh ├── setup_wcjs.ps1 ├── setup_wcjs.sh ├── src ├── @types │ └── global.d.ts ├── Plugin.tsx ├── Router.tsx ├── State.tsx ├── atoms │ ├── contentPlayer.ts │ ├── contentPlayerSelectors.ts │ ├── global.ts │ ├── globalFamilies.ts │ ├── globalFamilyKeys.ts │ ├── globalKeys.ts │ ├── globalSelectors.ts │ ├── index.ts │ ├── mirakurun.ts │ ├── mirakurunSelectors.ts │ ├── settings.ts │ ├── settingsKey.ts │ ├── settingsSelector.ts │ └── window.ts ├── components │ ├── common │ │ ├── AutoLinkedText.tsx │ │ ├── ComponentShadowWrapper.tsx │ │ ├── EscapeEnclosed.tsx │ │ └── PluginPositionComponents.tsx │ ├── contentPlayer │ │ ├── Controller.tsx │ │ ├── LoadingCircle.tsx │ │ ├── MirakurunManager.tsx │ │ ├── ProgramTitleManager.tsx │ │ ├── SubtitleRenderer.tsx │ │ ├── VideoPlayer.tsx │ │ └── controllers │ │ │ ├── AudioChannelSelector.tsx │ │ │ ├── AudioTrackSelector.tsx │ │ │ ├── FullScreenToggleButton.tsx │ │ │ ├── PlayToggleButton.tsx │ │ │ ├── ScreenshotButton.tsx │ │ │ ├── SeekableControl.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── SidebarSelectedServiceList.tsx │ │ │ ├── SidebarServiceCarousel.tsx │ │ │ ├── SidebarServiceDetail.tsx │ │ │ ├── SidebarServiceQuickButton.tsx │ │ │ ├── SpeedSelector.tsx │ │ │ ├── SubtitleToggleButton.tsx │ │ │ └── VolumeSlider.tsx │ ├── global │ │ ├── EpgUpdatedObserver.tsx │ │ ├── RecoilSharedSync.tsx │ │ ├── RecoilStoredSync.tsx │ │ └── Splash.tsx │ ├── programTable │ │ ├── HourIndicator.tsx │ │ ├── ProgramItem.tsx │ │ ├── ProgramModal.tsx │ │ ├── ScrollArea.tsx │ │ ├── ServiceRoll.tsx │ │ ├── Services.tsx │ │ └── WeekdaySelector.tsx │ └── settings │ │ ├── Mirakurun.tsx │ │ ├── Plugins.tsx │ │ └── general │ │ ├── Controller.tsx │ │ ├── Experimental.tsx │ │ ├── Screenshot.tsx │ │ ├── Subtitle.tsx │ │ └── index.tsx ├── constants │ ├── drcs-mapping.json │ ├── enclosed.ts │ ├── font.ts │ ├── genreColor.ts │ ├── ipc.ts │ ├── program.ts │ ├── recoil.ts │ ├── routes.ts │ └── style.ts ├── hooks │ ├── date.ts │ ├── mirakurun.ts │ └── ref.ts ├── index.scss ├── index.web.tsx ├── infra │ └── mirakurun │ │ ├── README.md │ │ ├── api.ts │ │ ├── base.ts │ │ ├── common.ts │ │ ├── configuration.ts │ │ └── index.ts ├── main │ ├── afterpack.ts │ ├── constants.ts │ ├── contextmenu.ts │ ├── epgManager.ts │ ├── fsUtils.ts │ ├── index.electron.ts │ ├── preload.ts │ ├── tsconfig.json │ └── vm │ │ ├── init.ts │ │ ├── setup.ts │ │ └── vm.d.ts ├── types │ ├── contentPlayer.ts │ ├── ipc.ts │ ├── mirakurun.ts │ ├── plugin.ts │ ├── setting.ts │ └── struct.ts ├── utils │ ├── enclosed.ts │ ├── mirakurun.ts │ ├── plugin.ts │ ├── recoil.ts │ ├── store.ts │ ├── string.ts │ ├── subtitle.ts │ ├── videoRenderer.ts │ └── vlc.ts └── windows │ ├── ContentPlayer.tsx │ ├── ProgramTable.tsx │ └── Settings.tsx ├── tailwind.config.js ├── tsconfig.eslint.json ├── tsconfig.json ├── webpack-loaders.ts ├── webpack.config.ts ├── webpack.main.config.ts └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | require("@rushstack/eslint-patch/modern-module-resolution") 2 | 3 | module.exports = { 4 | extends: ["@ci7lus/eslint-config"], 5 | parserOptions: { 6 | project: ["./tsconfig.eslint.json"], 7 | }, 8 | plugins: ["classnames"], 9 | rules: { 10 | "classnames/prefer-classnames-function": [ 11 | "error", 12 | { functionName: "clsx" }, 13 | ], 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Next.js build output 83 | .next 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # End of https://www.toptal.com/developers/gitignore/api/node 114 | 115 | lib 116 | build 117 | vlc_libs 118 | VLC.app 119 | .yarn/* 120 | !.yarn/releases 121 | !.yarn/plugins 122 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": false 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | checksumBehavior: update 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 7 | spec: "@yarnpkg/plugin-interactive-tools" 8 | 9 | yarnPath: .yarn/releases/yarn-3.3.1.cjs 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ci7lus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MirakTest 2 | 3 | [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/ci7lus/MirakTest?include_prereleases)](https://github.com/ci7lus/MirakTest/releases) 4 | [![CI](https://github.com/ci7lus/MirakTest/actions/workflows/ci.yml/badge.svg)](https://github.com/ci7lus/MirakTest/actions/workflows/ci.yml) 5 | 6 | [Mirakurun](https://github.com/Chinachu/Mirakurun) 用映像視聴アプリ実装研究資料
7 | 8 | ## 概要 9 | 10 | MirakTest は macOS / Windows / Linux 上で Mirakurun を利用しデジタル放送を視聴するアプリの実装を研究する目的で配布される研究資料です。本アプリに CAS 処理は含まれていないため、デコードされていない放送データを視聴することは出来ません。
11 | macOS / Windows 版ビルドでは [aribb24.js](https://github.com/monyone/aribb24.js) による ARIB-STD-B24 形式の字幕表示に対応しています。
12 | プラグインを導入して機能を拡張することが出来ます。 13 | 14 | ## 導入方法 15 | 16 | ### 安定版 17 | 18 | 各 OS 向けビルドを [Releases](https://github.com/ci7lus/MirakTest/releases) にて配布しています。 19 | 20 | #### macOS での実行 21 | 22 | ```sh 23 | brew install --cask ci7lus/miraktest/miraktest 24 | ``` 25 | 26 | Intel / Apple Silicon mac (aarch64) 上で動作する macOS Monterey / Ventura での動作を確認しています。
27 | 28 | #### Windows での実行 29 | 30 | exe のインストーラーをダウンロードして実行するか、zip を解凍して使用してください。
31 | Windows 11 での動作を確認しています。 32 | 33 | #### Linux での実行 34 | 35 | 実験的なサポートのため、環境によっては正しく動作しない可能性があります。
36 | PRと発生した問題/解決方法を共有するための Issue は歓迎しますが、Issue は基本的に対応できません。
37 | ハードウェア支援周りの不具合については[こちら](https://github.com/ci7lus/MirakTest/wiki/Linux-%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B-libVLC-%E3%81%AE%E3%83%8F%E3%83%BC%E3%83%89%E3%82%A6%E3%82%A7%E3%82%A2%E6%94%AF%E6%8F%B4%E5%91%A8%E3%82%8A%E3%81%AE%E4%B8%8D%E5%85%B7%E5%90%88%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)。
38 | vlc の導入が必要です。debian の場合は以下のコマンドでインストールできます。 39 | 40 | ```bash 41 | apt-get install vlc 42 | ``` 43 | 44 | AppImage に実行権限と `--no-sandbox` をつけて実行するか、アーカイブ版の `chrome-sandbox` を適切な権限に設定してください([参考](https://github.com/Revolutionary-Games/Thrive/issues/749))。 45 | 46 | ### 開発版 47 | 48 | 下記開発手順に沿ってビルドを行うか、CI にてコミット毎にビルドが行われているので、コミットメッセージ右の緑色チェック → Artifacts からダウンロードできます(ログインが必要です)。 49 | 50 | ## 機能 51 | 52 | ### プラグイン 53 | 54 | プラグインを導入して機能を拡張することが出来ます。
55 | 利用できるプラグインの一覧は[こちら](https://github.com/ci7lus/MirakTest/wiki/Userland-Plugin)。
56 | API 仕様は[plugin.ts](./src/types/plugin.ts)を参照してください。
57 | 型定義ファイル(`plugin.d.ts`)はリリースにてアプリイメージと一緒に配布しています。 58 | 59 | ### 操作 60 | 61 | - [キーボードショートカット](https://github.com/ci7lus/MirakTest/wiki/%E3%82%AD%E3%83%BC%E3%83%9C%E3%83%BC%E3%83%89%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88) 62 | 63 | ## 開発 64 | 65 | ### macOS 66 | 67 | ```bash 68 | brew install vlc cmake 69 | git clone git@github.com:ci7lus/MirakTest.git 70 | cd MirakTest 71 | yarn 72 | ./setup_libvlc_mac.sh 73 | ./setup_wcjs.sh 74 | yarn build:tsc 75 | yarn dev:webpack 76 | yarn dev:electron 77 | yarn build 78 | ``` 79 | 80 | [vlc-miraktest](https://github.com/vivid-lapin/vlc-miraktest) の [Releases](https://github.com/vivid-lapin/vlc-miraktest/releases) にある dmg から `VLC.app` を抽出し MirakTest ディレクトリに配置することで、ビルドが aribb24.js を用いるようになります。 81 | 82 | ### Windows 83 | 84 | ```powershell 85 | choco install -y cmake powershell-core 86 | git clone git@github.com:ci7lus/MirakTest.git 87 | cd MirakTest 88 | yarn 89 | pwsh .\setup_wcjs.ps1 90 | yarn build:tsc 91 | yarn dev:webpack 92 | yarn dev:electron 93 | yarn build 94 | ``` 95 | 96 | ### Linux (debian) 97 | 98 | ```bash 99 | sudo apt-get install build-essential cmake libvlc-dev vlc 100 | git clone git@github.com:ci7lus/MirakTest.git 101 | cd MirakTest 102 | yarn 103 | ./setup_wcjs.sh 104 | yarn build:tsc 105 | yarn dev:webpack 106 | yarn dev:electron 107 | yarn build 108 | ``` 109 | 110 | ## 謝辞 111 | 112 | MirakTest は次のプロジェクトを利用/参考にして実装しています。 113 | 114 | - [Chinachu/Mirakurun](https://github.com/Chinachu/Mirakurun) 115 | - [RSATom/WebChimera.js](https://github.com/RSATom/WebChimera.js) 116 | - [search-future/miyou.tv](https://github.com/search-future/miyou.tv) 117 | - [monyone/aribb24.js](https://github.com/monyone/aribb24.js) 118 | - [tsukumijima/KonomiTV](https://github.com/tsukumijima/KonomiTV) 119 | 120 | DTV コミュニティの皆さまに感謝します。 121 | 122 | ## ライセンス 123 | 124 | MirakTest のソースコードは MIT ライセンスの下で提供されますが、ビルド済みパッケージは libVLC を含んでいる場合があり、その場合は LGPLv2.1 または GPLv2 でライセンスされます([詳細](https://wiki.videolan.org/Frequently_Asked_Questions/))。ビルド済みパッケージを Releases や Artifacts にて配布する場合は可能な限り周辺情報としてその旨を表示し、パッケージにはライセンス情報を同梱します。 125 | -------------------------------------------------------------------------------- /assets/conv: -------------------------------------------------------------------------------- 1 | convert miraktest.iconset/icon_256x256.png miraktest.ico 2 | iconutil -c icns miraktest.iconset 3 | -------------------------------------------------------------------------------- /assets/license.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg932\deff0\nouicompat\deflang1033\deflangfe1041{\fonttbl{\f0\fnil\fcharset128 Meiryo UI;}} 2 | {\colortbl ;\red0\green0\blue255;} 3 | {\*\generator Riched20 10.0.19041}\viewkind4\uc1 4 | \pard\sa200\sl276\slmult1\f0\fs22\lang1041 MirakTest \'82\'cd\'81\'41Mirakurun \'89\'66\'91\'9c\'8e\'8b\'92\'ae\'8a\'6d\'94\'46\'97\'70\'83\'41\'83\'76\'83\'8a\'82\'c5\'82\'b7\'81\'42\par 5 | \'83\'5c\'81\'5b\'83\'58\'83\'52\'81\'5b\'83\'68\'82\'cd {{\field{\*\fldinst{HYPERLINK https://github.com/ci7lus/MirakTest }}{\fldrslt{https://github.com/ci7lus/MirakTest\ul0\cf0}}}}\f0\fs22 \'82\'c9\'82\'c4\'8c\'f6\'8a\'4a\'82\'b3\'82\'ea\'82\'c4\'82\'a2\'82\'dc\'82\'b7\'81\'42\par 6 | macOS \'8c\'fc\'82\'af\'83\'72\'83\'8b\'83\'68\'8b\'79\'82\'d1 Windows \'8c\'fc\'82\'af\'83\'72\'83\'8b\'83\'68\'82\'c9\'82\'cd libVLC \'82\'aa\'93\'af\'8d\'ab\'82\'b3\'82\'ea\'82\'c4\'82\'a2\'82\'dc\'82\'b7\'81\'42\'82\'b1\'82\'ea\'82\'e7\'82\'cc\'83\'72\'83\'8b\'83\'68\'82\'c9\'82\'cd LGPLv2.1 \'82\'dc\'82\'bd\'82\'cd GPLv2 \'82\'aa\'93\'4b\'97\'70\'82\'b3\'82\'ea\'82\'dc\'82\'b7\'81\'42\par 7 | \par 8 | MirakTest is an application for testing Mirakurun video viewing.\par 9 | The source code is available at {{\field{\*\fldinst{HYPERLINK https://github.com/ci7lus/MirakTest }}{\fldrslt{https://github.com/ci7lus/MirakTest\ul0\cf0}}}}\f0\fs22 .\par 10 | The macOS and Windows builds include libVLC. These builds are licensed under the LGPLv2.1 or GPLv2.\lang17\par 11 | } 12 | -------------------------------------------------------------------------------- /assets/miraktest.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.icns -------------------------------------------------------------------------------- /assets/miraktest.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.ico -------------------------------------------------------------------------------- /assets/miraktest.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.iconset/icon_128x128.png -------------------------------------------------------------------------------- /assets/miraktest.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.iconset/icon_16x16.png -------------------------------------------------------------------------------- /assets/miraktest.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.iconset/icon_256x256.png -------------------------------------------------------------------------------- /assets/miraktest.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.iconset/icon_32x32.png -------------------------------------------------------------------------------- /assets/miraktest.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest.iconset/icon_512x512.png -------------------------------------------------------------------------------- /assets/miraktest_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/miraktest_logo.png -------------------------------------------------------------------------------- /assets/resize: -------------------------------------------------------------------------------- 1 | convert miraktest.iconset/icon_512x512.png -resize 256x256 miraktest.iconset/icon_256x256.png 2 | convert miraktest.iconset/icon_512x512.png -resize 128x128 miraktest.iconset/icon_128x128.png 3 | convert miraktest.iconset/icon_512x512.png -resize 32x32 miraktest.iconset/icon_32x32.png 4 | convert miraktest.iconset/icon_512x512.png -resize 16x16 miraktest.iconset/icon_16x16.png -------------------------------------------------------------------------------- /assets/rounded-mplus-1m-arib.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/assets/rounded-mplus-1m-arib.woff2 -------------------------------------------------------------------------------- /assets/rounded-mplus-m1-arib.md: -------------------------------------------------------------------------------- 1 | # Rounded M+ 1m for ARIB 2 | 3 | > 高水準漢字とARIB記号が収録されている丸ゴのフリーフォントは(多分)和田研ぐらいだと 4 | > 思いますが、割とクセのあるフォントなのでRounded M+と混ぜたものを作ってみました。 5 | > 外字テーブル名は和田研フォントと同じです。"j"などの下部を表示できるようにやや上 6 | > 付きなので文字位置補正のY方向を5ぐらいに設定すると丁度いいです。 7 | > rounded-mplus-1m-arib-20131205.zip 8 | > 9 | 10 | 11 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: io.github.ci7lus.miraktest 2 | productName: MirakTest 3 | copyright: Copyright © 2021 ci7lus 4 | directories: 5 | output: "./build" 6 | files: 7 | - "index.html" 8 | - "package.json" 9 | - "dist/**/*" 10 | - "assets/*.{png,svg}" 11 | - "!node_modules" 12 | - "node_modules/bindings" 13 | - "node_modules/font-list" 14 | - "node_modules/electron-store" 15 | - "node_modules/conf" 16 | - "node_modules/ajv" 17 | - "node_modules/fast-deep-equal" 18 | - "node_modules/json-schema-traverse" 19 | - "node_modules/require-from-string" 20 | - "node_modules/uri-js" 21 | - "node_modules/punycode" 22 | - "node_modules/ajv-formats" 23 | - "node_modules/ajv" 24 | - "node_modules/atomically" 25 | - "node_modules/debounce-fn" 26 | - "node_modules/mimic-fn" 27 | - "node_modules/dot-prop" 28 | - "node_modules/is-obj" 29 | - "node_modules/env-paths" 30 | - "node_modules/json-schema-typed" 31 | - "node_modules/onetime" 32 | - "node_modules/mimic-fn" 33 | - "node_modules/pkg-up" 34 | - "node_modules/find-up" 35 | - "node_modules/locate-path" 36 | - "node_modules/p-locate" 37 | - "node_modules/p-limit" 38 | - "node_modules/p-try" 39 | - "node_modules/path-exists" 40 | - "node_modules/semver" 41 | - "node_modules/lru-cache" 42 | - "node_modules/yallist" 43 | - "node_modules/type-fest" 44 | - from: node_modules/webchimera.js 45 | to: node_modules/webchimera.js 46 | publish: null 47 | npmRebuild: false 48 | npmArgs: "--runtime=node " 49 | afterPack: "./dist/src/main/afterpack.js" 50 | mac: 51 | icon: "assets/miraktest.icns" 52 | entitlements: "entitlements.plist" 53 | entitlementsInherit: "entitlements.plist" 54 | extendInfo: 55 | NSHumanReadableCopyright: "Copyright © 2021 ci7lus\nこのアプリは libVLC を同梱しています。このアプリイメージには LGPLv2.1 または GPLv2 が適用されます。" 56 | identity: null 57 | win: 58 | icon: "assets/miraktest.ico" 59 | target: 60 | - "zip" 61 | - "nsis" 62 | nsis: 63 | oneClick: false 64 | allowToChangeInstallationDirectory: true 65 | license: "assets/license.rtf" 66 | linux: 67 | icon: "assets/miraktest.iconset" 68 | category: "AudioVideo" 69 | target: 70 | - "AppImage" 71 | - "tar.gz" 72 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MirakTest 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 | 21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miraktest", 3 | "productName": "MirakTest", 4 | "version": "2.1.0", 5 | "description": "Mirakurun 映像視聴確認用アプリ", 6 | "author": "ci7lus <7887955+ci7lus@users.noreply.github.com>", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/ci7lus/miraktest.git" 11 | }, 12 | "main": "dist/main.electron.js", 13 | "scripts": { 14 | "dev:electron": "cross-env NODE_ENV=development electron .", 15 | "run:electron": "cross-env NODE_ENV=production electron .", 16 | "dev:webpack": "webpack serve --hot --mode=development --color", 17 | "build:electron": "electron-builder", 18 | "build:tsc": "tsc --build ./src/main/tsconfig.json", 19 | "build:renderer": "cross-env NODE_ENV=production webpack --mode production", 20 | "build:main": "cross-env NODE_ENV=production webpack --mode production --config ./webpack.main.config.ts", 21 | "build": "yarn build:renderer && yarn build:main && yarn build:tsc && yarn build:electron", 22 | "lint:prettier": "prettier --check './src/**/*.{js,ts,tsx}'", 23 | "format:prettier": "prettier --write './src/**/*.{js,ts,tsx}'", 24 | "lint:eslint": "eslint --max-warnings 0 --cache './src/**/*.{js,ts,tsx}'", 25 | "format:eslint": "eslint './src/**/*.{js,ts,tsx}' --cache --fix", 26 | "build:dts-plugin": "yarn dts-bundle-generator --export-referenced-types=false --external-inlines=electron -o dist/plugin.d.ts --no-check src/types/plugin.ts && ts-node setLicenseInDts.ts && prettier --write dist/plugin.d.ts", 27 | "typecheck": "yarn tsc --noEmit && yarn tsc -p ./src/main/tsconfig.json --noEmit" 28 | }, 29 | "lint-staged": { 30 | "*.{js,ts,tsx}": [ 31 | "eslint --max-warnings 0 --cache", 32 | "bash -c 'tsc --noEmit'" 33 | ], 34 | "*.{js,ts,tsx,md}": "prettier" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.21.8", 38 | "@babel/plugin-proposal-class-properties": "^7.18.6", 39 | "@babel/plugin-transform-modules-commonjs": "^7.21.5", 40 | "@babel/plugin-transform-react-jsx": "^7.21.5", 41 | "@babel/plugin-transform-runtime": "^7.21.4", 42 | "@babel/plugin-transform-typescript": "^7.21.3", 43 | "@babel/preset-env": "^7.21.5", 44 | "@babel/preset-react": "^7.18.6", 45 | "@babel/preset-typescript": "^7.21.5", 46 | "@babel/runtime": "^7.21.5", 47 | "@ci7lus/eslint-config": "^1.1.1", 48 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", 49 | "@rushstack/eslint-patch": "^1.2.0", 50 | "@tailwindcss/custom-forms": "^0.2.1", 51 | "@tailwindcss/forms": "^0.5.3", 52 | "@types/dom-screen-wake-lock": "^1.0.1", 53 | "@types/mini-css-extract-plugin": "^2.5.1", 54 | "@types/react": "^18.2.2", 55 | "@types/react-dom": "^18.2.3", 56 | "@types/stream-json": "^1.7.3", 57 | "@welldone-software/why-did-you-render": "^7.0.1", 58 | "aphrodite": "^2.4.0", 59 | "autoprefixer": "^10.4.14", 60 | "babel-loader": "^9.1.2", 61 | "cross-env": "^7.0.3", 62 | "css-loader": "^6.7.3", 63 | "dts-bundle-generator": "^8.0.1", 64 | "electron": "^21.4.4", 65 | "electron-builder": "^23.6.0", 66 | "eslint": "^8.39.0", 67 | "eslint-plugin-classnames": "^0.3.1", 68 | "husky": "^8.0.3", 69 | "jest": "^29.5.0", 70 | "lint-staged": "^13.2.2", 71 | "mini-css-extract-plugin": "^2.7.5", 72 | "node-loader": "^2.0.0", 73 | "postcss": "^8.4.23", 74 | "postcss-loader": "^7.3.0", 75 | "prettier": "^2.8.8", 76 | "react-refresh": "^0.14.0", 77 | "sass": "^1.62.1", 78 | "sass-loader": "^13.2.2", 79 | "tailwind-scrollbar": "^3.0.0", 80 | "tailwindcss": "^3.3.2", 81 | "tailwindcss-textshadow": "^2.1.3", 82 | "ts-node": "^10.9.1", 83 | "typescript": "^5.0.4", 84 | "webpack": "^5.82.0", 85 | "webpack-cli": "^5.0.2", 86 | "webpack-dev-server": "^4.13.3" 87 | }, 88 | "dependencies": { 89 | "@headlessui/react": "^1.7.14", 90 | "aribb24.js": "^1.11.0", 91 | "axios": "^0.27.2", 92 | "base64-arraybuffer": "^1.0.2", 93 | "clsx": "^1.2.1", 94 | "dayjs": "^1.11.7", 95 | "electron-store": "^8.1.0", 96 | "font-list": "^1.4.5", 97 | "format-duration": "^3.0.2", 98 | "interweave": "^13.1.0", 99 | "interweave-autolink": "^5.1.0", 100 | "js-base64": "^3.7.5", 101 | "path-browserify": "^1.0.1", 102 | "react": "^18.2.0", 103 | "react-dom": "^18.2.0", 104 | "react-fast-marquee": "^1.5.2", 105 | "react-feather": "^2.0.10", 106 | "react-query": "^3.39.3", 107 | "react-spring-carousel-js": "^1.9.32", 108 | "react-use": "^17.4.0", 109 | "recoil": "0.7.6", 110 | "recoil-sync": "0.1.0", 111 | "stream-json": "^1.7.5", 112 | "webchimera.js": "git+https://github.com/neneka/WebChimera.js.git#v0.5.4", 113 | "zod": "^3.21.4" 114 | }, 115 | "browserslist": "chrome 100", 116 | "packageManager": "yarn@3.3.1", 117 | "peerDependencies": { 118 | "eslint": "*" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /pickRequiredDeps.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MIT License 3 | * Copyright 2021 ci7lus 4 | */ 5 | 6 | import fs from "fs" 7 | import yaml from "yaml" 8 | import pkg from "./package.json" 9 | 10 | type Package = { 11 | version: string 12 | resolution: string 13 | dependencies: { [version: string]: string } 14 | } 15 | 16 | const excludes = ["cmake-js"] 17 | const targets = ["font-list", "electron-store"] as const 18 | const dependencies = { ...pkg.dependencies, ...pkg.devDependencies } 19 | const targetWithVersion = targets.map( 20 | (depName) => `${depName}@npm:${dependencies[depName]}` 21 | ) 22 | const file = fs.readFileSync("yarn.lock", "utf8") 23 | const lock: { [key: string]: Package } = yaml.parse(file) 24 | const packages = Object.fromEntries( 25 | Object.entries(lock) 26 | .map(([key, value]) => key.split(",").map((key) => [key.trim(), value])) 27 | .flat() 28 | ) 29 | const deps: string[] = [] 30 | const deps_dedupe: string[] = [] 31 | const pickPackage = (dep: Package) => { 32 | Object.entries(dep.dependencies || {}).map(([packageName, version]) => { 33 | const key = `${packageName}@npm:${version}` 34 | if (!deps_dedupe.includes(key) && !excludes.includes(packageName)) { 35 | deps.push(packageName) 36 | deps_dedupe.push(key) 37 | packages[key] && pickPackage(packages[key]) 38 | } 39 | }) 40 | } 41 | targetWithVersion.forEach((target) => { 42 | deps.push(target.split("@")[0]) 43 | deps_dedupe.push(target) 44 | packages[target] && pickPackage(packages[target]) 45 | }) 46 | deps.map((dep) => console.info(` - "node_modules/${dep}"`)) 47 | console.info(`count: ${deps.length}`) 48 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /setBuildVersion.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import pkg from "./package.json" 3 | 4 | const main = async () => { 5 | const sha1 = process.env.SHA1 6 | if (!sha1) throw new Error("no sha1") 7 | const yml = await fs.promises.readFile("./electron-builder.yml", "utf8") 8 | if (yml.includes("buildVersion")) 9 | throw new Error("already buildVersion included") 10 | let buildVersion: string 11 | if (process.env.OS === "Windows") { 12 | const [version] = pkg.version.split("-") 13 | buildVersion = `${version}.${sha1.slice(0, 7)}` 14 | } else { 15 | buildVersion = sha1.slice(0, 7) 16 | } 17 | const rewrited = yml.replace( 18 | "productName: MirakTest", 19 | `productName: MirakTest\nbuildVersion: "${buildVersion}"` 20 | ) 21 | await fs.promises.writeFile("./electron-builder.yml", rewrited) 22 | } 23 | main() 24 | -------------------------------------------------------------------------------- /setLicenseInDts.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import axios from "axios" 3 | import pkg from "./package.json" 4 | 5 | const main = async () => { 6 | const dts = await fs.promises.readFile("./dist/plugin.d.ts", "utf8") 7 | const license = await fs.promises.readFile(`./LICENSE`, "utf8") 8 | const licenses = ["MirakTest: " + license] 9 | const libs = ["electron"] 10 | for (const lib of libs) { 11 | const license = await fs.promises.readFile( 12 | `./node_modules/${lib}/LICENSE`, 13 | "utf8" 14 | ) 15 | licenses.push(`${lib}: ${license}`) 16 | } 17 | const mirakurunLicense = await axios.get( 18 | "https://raw.githubusercontent.com/Chinachu/Mirakurun/0f7290b017bd6c80904dc8c253801f2556733377/LICENSE", 19 | { responseType: "text" } 20 | ) 21 | licenses.push(`Mirakurun: ${mirakurunLicense.data}`) 22 | const rewrited = `/* eslint-disable */\n/** plugin.d.ts - Type definitions for creating plug-ins for ${ 23 | pkg.productName 24 | }.\n---\n${licenses.join("\n---\n")}\n*/\n${dts}` 25 | await fs.promises.writeFile("./dist/plugin.d.ts", rewrited) 26 | } 27 | main() 28 | -------------------------------------------------------------------------------- /setPackageVersion.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import pkg from "./package.json" 3 | 4 | const main = async () => { 5 | const sha1 = process.env.SHA1 6 | if (!sha1) throw new Error("no sha1") 7 | const [version, after] = pkg.version.split("-") 8 | let [prerelease, metadata] = (after || "").split("+") 9 | prerelease = "nightly" 10 | metadata = sha1.slice(0, 7) 11 | const newVersion = `${version}-${prerelease}+${metadata}` 12 | console.info(newVersion) 13 | pkg.version = newVersion 14 | await fs.promises.writeFile("./package.json", JSON.stringify(pkg)) 15 | } 16 | main() 17 | -------------------------------------------------------------------------------- /setup_libvlc.ps1: -------------------------------------------------------------------------------- 1 | $LIBVLC_VER = "3.0.18" 2 | $OS_NAME = "windows" 3 | 4 | # Setup VLC 5 | Set-Location "node_modules" 6 | Invoke-WebRequest -Uri "https://github.com/vivid-lapin/vlc-miraktest/releases/download/${LIBVLC_VER}/vlc-${OS_NAME}-${LIBVLC_VER}.zip" -OutFile "libvlc.zip" 7 | Expand-Archive -Path ".\libvlc.zip" -DestinationPath ".\webchimera.js" -Force 8 | Remove-Item ".\libvlc.zip" 9 | Set-Location ".." 10 | -------------------------------------------------------------------------------- /setup_libvlc_mac.sh: -------------------------------------------------------------------------------- 1 | set -eu 2 | rm -rf vlc_libs 3 | if [ -d ./VLC.app ]; then 4 | BASE_PATH=./VLC.app 5 | else 6 | BASE_PATH=/Applications/VLC.app 7 | fi 8 | echo "Using $BASE_PATH" 9 | cp -Ra $BASE_PATH/Contents/MacOS/lib vlc_libs 10 | mkdir vlc_libs/vlc 11 | cp -Ra $BASE_PATH/Contents/MacOS/{plugins,share} vlc_libs/vlc 12 | rm vlc_libs/vlc/plugins/libsecuretransport_plugin.dylib 13 | rm -rf vlc_libs/vlc/share/locale 14 | rm -rf vlc_libs/vlc/share/lua/playlist/*.luac 15 | rm -rf node_modules/electron/dist/Electron.app/Contents/Frameworks/libvlc*.dylib 16 | rm -rf node_modules/electron/dist/Electron.app/Contents/Frameworks/vlc 17 | for d in vlc_libs/*; do cp -Ra $d node_modules/electron/dist/Electron.app/Contents/Frameworks; done 18 | curl -sL https://raw.githubusercontent.com/videolan/vlc/master/COPYING > vlc_libs/COPYING 19 | curl -sL https://raw.githubusercontent.com/videolan/vlc/master/COPYING.LIB > vlc_libs/COPYING.LIB 20 | curl -sL https://raw.githubusercontent.com/videolan/vlc/master/COPYING > node_modules/electron/dist/Electron.app/Contents/VLC-COPYING 21 | curl -sL https://raw.githubusercontent.com/videolan/vlc/master/COPYING.LIB > node_modules/electron/dist/Electron.app/Contents/VLC-COPYING.LIB -------------------------------------------------------------------------------- /setup_wcjs.ps1: -------------------------------------------------------------------------------- 1 | $LIBVLC_VER = "3.0.16" 2 | $LIBVLC_VER_EXTRA = "3" 3 | $OS_NAME = "windows" 4 | 5 | $ELECTRON_VERSION = (yarn run --silent electron --version) | Out-String 6 | 7 | $Env:YARN_ENABLE_IMMUTABLE_INSTALLS = "false" 8 | $Env:npm_config_wcjs_runtime = "electron" 9 | $Env:npm_config_wcjs_runtime_version = $ELECTRON_VERSION.Replace("v", "") -replace "`t|`n|`r","" 10 | $Env:npm_config_wcjs_arch = "x64" 11 | $Env:ELECTRON_MIRROR = "https://artifacts.electronjs.org/headers/dist" 12 | 13 | # Setup WebChimera.js 14 | Set-Location "node_modules\webchimera.js" 15 | 16 | function TryRemove { 17 | param($file) 18 | if (Test-Path $file) { 19 | Remove-Item $file -Force -Recurse 20 | } 21 | } 22 | 23 | function onExit { 24 | TryRemove ".\.yarn" 25 | TryRemove ".\node_modules" 26 | TryRemove ".\build" 27 | TryRemove ".\deps\vlc-${LIBVLC_VER}" 28 | TryRemove ".\yarn.lock" 29 | Set-Location "..\.." 30 | } 31 | 32 | Register-EngineEvent PowerShell.Exiting -Action { 33 | onExit 34 | } 35 | onExit 36 | Set-Location "node_modules\webchimera.js" 37 | 38 | Invoke-WebRequest -Uri "https://github.com/vivid-lapin/vlc-miraktest/releases/download/${LIBVLC_VER}.${LIBVLC_VER_EXTRA}/vlc-${OS_NAME}-${LIBVLC_VER}.zip" -OutFile "libvlc.zip" 39 | Expand-Archive -Path ".\libvlc.zip" -DestinationPath ".\deps\vlc-${LIBVLC_VER}" -Force 40 | Remove-Item ".\libvlc.zip" 41 | Write-Output "nodeLinker: node-modules" | Set-Content ".\.yarnrc.yml" 42 | Write-Output "" | Set-Content ".\yarn.lock" 43 | Remove-Item ".\deps\libvlc_wrapper" -Recurse -Force 44 | git clone --depth 1 --recursive https://github.com/RSATom/ya-libvlc-wrapper.git deps/libvlc_wrapper 45 | yarn install 46 | node rebuild.js 47 | @" 48 | module.exports = { 49 | ...require('./WebChimera.js.node'), 50 | path: __dirname.replace('app.asar', 'app.asar.unpacked') 51 | } 52 | "@ | Set-Content ".\index.js" 53 | Copy-Item .\build\Release\WebChimera.js.node . -------------------------------------------------------------------------------- /setup_wcjs.sh: -------------------------------------------------------------------------------- 1 | set -eu 2 | export ELECTRON_VER="$(yarn run --silent electron --version | sed -e "s/v//")" 3 | export BUILD_DIR="./build/Release" 4 | export npm_config_wcjs_runtime=electron 5 | export npm_config_wcjs_runtime_version=$ELECTRON_VER 6 | npm_config_wcjs_arch=${npm_config_wcjs_arch:-} 7 | if [[ -z "${npm_config_wcjs_arch}" ]]; then 8 | export npm_config_wcjs_arch="$(arch | sed -e "s/i386/x64/")" 9 | fi 10 | export ELECTRON_MIRROR="https://artifacts.electronjs.org/headers/dist" 11 | export OS_NAME="$(uname)" 12 | cd node_modules/webchimera.js 13 | 14 | function finally { 15 | echo "Cleanup" 16 | rm -rf node_modules build deps/VLC.app yarn.lock .yarn 17 | } 18 | trap finally EXIT 19 | finally 20 | 21 | if [ "$OS_NAME" = "Darwin" ]; then 22 | if [ -d "../../VLC.app" ]; then 23 | echo "Using ./VLC.app" 24 | cp -Ra ../../VLC.app ./deps; 25 | else 26 | echo "Using /Application/VLC.app" 27 | cp -Ra /Applications/VLC.app ./deps; 28 | fi 29 | fi 30 | export WCJS_ARCHIVE=WebChimera.js_${npm_config_wcjs_runtime}_${npm_config_wcjs_runtime_version}_${npm_config_wcjs_arch}_${OS_NAME}.zip 31 | export WCJS_ARCHIVE_PATH=$BUILD_DIR/$WCJS_ARCHIVE 32 | export WCJS_FULL_ARCHIVE=WebChimera.js_${npm_config_wcjs_runtime}_v${npm_config_wcjs_runtime_version}_VLC_${npm_config_wcjs_arch}_${OS_NAME}.tar.gz 33 | if [ "$OS_NAME" = "Darwin" ]; then export WCJS_FULL_ARCHIVE_PATH=$BUILD_DIR/$WCJS_FULL_ARCHIVE; else export WCJS_FULL_ARCHIVE_PATH=$WCJS_ARCHIVE_PATH; fi 34 | echo 'nodeLinker: node-modules' > .yarnrc.yml 35 | echo '' > yarn.lock 36 | rm -rf deps/libvlc_wrapper 37 | git clone --depth 1 --recursive https://github.com/RSATom/ya-libvlc-wrapper.git deps/libvlc_wrapper 38 | YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install 39 | node rebuild.js 40 | file ./build/Release/WebChimera.js.node 41 | mv ./build/Release/WebChimera.js.node . 42 | echo "module.exports = require('./WebChimera.js.node')" > index.js -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import _Recoil from "recoil" 2 | import _RecoilSync from "recoil-sync" 3 | import { Preload } from "../types/ipc" 4 | import { InternalPluginDefineInRenderer, DefineAtom } from "../types/plugin" 5 | import { PluginDatum } from "../types/struct" 6 | 7 | declare global { 8 | interface Window { 9 | atoms?: DefineAtom[] 10 | pluginData?: PluginDatum[] 11 | disabledPluginFileNames?: string[] 12 | plugins?: InternalPluginDefineInRenderer[] 13 | Preload: Preload 14 | id?: number 15 | } 16 | 17 | // eslint-disable-next-line no-var 18 | declare var Recoil: typeof _Recoil 19 | // eslint-disable-next-line no-var 20 | declare var RecoilSync: typeof _RecoilSync 21 | 22 | declare function structuredClone( 23 | obj: T, 24 | options?: StructuredSerializeOptions 25 | ): T 26 | } 27 | -------------------------------------------------------------------------------- /src/Router.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | import { 3 | useRecoilBridgeAcrossReactRoots_UNSTABLE, 4 | useSetRecoilState, 5 | } from "recoil" 6 | import { windowRootFontSizeAtom } from "./atoms/window" 7 | import { ComponentShadowWrapper } from "./components/common/ComponentShadowWrapper" 8 | import { Splash } from "./components/global/Splash" 9 | import { ROUTES } from "./constants/routes" 10 | import { Routes } from "./types/struct" 11 | import { CoiledContentPlayer } from "./windows/ContentPlayer" 12 | import { CoiledProgramTable } from "./windows/ProgramTable" 13 | import { Settings } from "./windows/Settings" 14 | 15 | export const Router: React.FC<{}> = () => { 16 | const RecoilBridge = useRecoilBridgeAcrossReactRoots_UNSTABLE() 17 | const [hash, setHash] = useState("") 18 | useEffect(() => { 19 | const onHashChange = () => { 20 | const hash = window.location.hash.replace("#", "") 21 | setHash(hash) 22 | } 23 | window.addEventListener("hashchange", onHashChange) 24 | onHashChange() 25 | window.Preload.public.showWindow() 26 | return () => window.removeEventListener("hashchange", onHashChange) 27 | }, []) 28 | const setRootFontSize = useSetRecoilState(windowRootFontSizeAtom) 29 | useEffect(() => { 30 | const root = document.querySelector(":root") 31 | if (!root) { 32 | return 33 | } 34 | let timer: NodeJS.Timeout | null = null 35 | const onResize = () => { 36 | if (timer) { 37 | clearTimeout(timer) 38 | } 39 | timer = setTimeout(() => { 40 | // clamp(14px, 1.25vw, 100%); 41 | // max(14px, min(1.25vw, 16px)) 42 | const fontSize = Math.max( 43 | 14, 44 | Math.min((window.innerWidth / 100) * 1.25, 16) 45 | ) 46 | root.style.fontSize = fontSize + "px" 47 | setRootFontSize(fontSize) 48 | timer = null 49 | }, 50) 50 | } 51 | window.addEventListener("resize", onResize) 52 | onResize() 53 | return () => window.removeEventListener("resize", onResize) 54 | }, []) 55 | if (hash === ROUTES["ContentPlayer"]) { 56 | return 57 | } else if (hash === ROUTES["Settings"]) { 58 | return 59 | } else if (hash === ROUTES["ProgramTable"]) { 60 | return 61 | } else { 62 | const Component = window.plugins?.find((plugin) => plugin.windows?.[hash]) 63 | ?.windows[hash] 64 | if (Component) { 65 | return ( 66 | ( 69 | 70 | 71 | 72 | )} 73 | /> 74 | ) 75 | } 76 | return Error: 想定していない表示です({hash}) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/State.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React from "react" 3 | import { QueryClient, QueryClientProvider } from "react-query" 4 | import { RecoilRoot } from "recoil" 5 | import { Router } from "./Router" 6 | import { PluginPositionComponents } from "./components/common/PluginPositionComponents" 7 | import { RecoilSharedSync } from "./components/global/RecoilSharedSync" 8 | import { RecoilStoredSync } from "./components/global/RecoilStoredSync" 9 | import { ObjectLiteral } from "./types/struct" 10 | import { initializeState } from "./utils/recoil" 11 | 12 | const queryClient = new QueryClient({ 13 | defaultOptions: { 14 | queries: { 15 | refetchOnWindowFocus: false, 16 | }, 17 | }, 18 | }) 19 | 20 | export const StateRoot: React.FC<{ 21 | states: ObjectLiteral 22 | fonts: string[] 23 | }> = ({ states, fonts }) => { 24 | return ( 25 | 30 | 31 | 32 | 33 |
36 |
48 | 49 |
50 | 51 |
52 |
53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/atoms/contentPlayer.ts: -------------------------------------------------------------------------------- 1 | import $ from "@recoiljs/refine" 2 | import { atom } from "recoil" 3 | import { syncEffect } from "recoil-sync" 4 | import pkg from "../../package.json" 5 | import { RECOIL_SYNC_STORED_KEY } from "../constants/recoil" 6 | import type { 7 | AribSubtitleData, 8 | ContentPlayerKeyForRestoration, 9 | } from "../types/contentPlayer" 10 | import { VLCAudioChannel } from "../utils/vlc" 11 | 12 | const prefix = `${pkg.name}.contentPlayer` 13 | 14 | export const contentPlayerTitleAtom = atom({ 15 | key: `${prefix}.title`, 16 | default: null, 17 | }) 18 | 19 | export const contentPlayerBoundsAtom = atom({ 20 | key: `${prefix}.bounds`, 21 | default: null, 22 | effects: [ 23 | syncEffect({ 24 | storeKey: RECOIL_SYNC_STORED_KEY, 25 | refine: $.nullable($.mixed()), 26 | }), 27 | ], 28 | }) 29 | 30 | export const contentPlayerSubtitleEnabledAtom = atom({ 31 | key: `${prefix}.subtitleEnabled`, 32 | default: false, 33 | effects: [ 34 | syncEffect({ 35 | storeKey: RECOIL_SYNC_STORED_KEY, 36 | refine: $.bool(), 37 | }), 38 | ], 39 | }) 40 | 41 | export const contentPlayerVolumeAtom = atom({ 42 | key: `${prefix}.volume`, 43 | default: 100, 44 | effects: [ 45 | syncEffect({ storeKey: RECOIL_SYNC_STORED_KEY, refine: $.number() }), 46 | ], 47 | }) 48 | 49 | export const contentPlayerSpeedAtom = atom({ 50 | key: `${prefix}.speed`, 51 | default: 1, 52 | }) 53 | 54 | export const contentPlayerAudioChannelAtom = atom({ 55 | key: `${prefix}.audioChannel`, 56 | default: VLCAudioChannel.Stereo, 57 | }) 58 | 59 | export const contentPlayerAudioTrackAtom = atom({ 60 | key: `${prefix}.audioTrack`, 61 | default: 1, 62 | }) 63 | 64 | export const contentPlayerAudioTracksAtom = atom({ 65 | key: `${prefix}.audioTracks`, 66 | default: [], 67 | }) 68 | 69 | export const contentPlayerAudioChannelTypeAtom = atom<"surround" | "stereo">({ 70 | key: `${prefix}.audioChannelType`, 71 | default: "stereo", 72 | }) 73 | 74 | export const contentPlayerIsSeekableAtom = atom({ 75 | key: `${prefix}.isSeekable`, 76 | default: false, 77 | }) 78 | 79 | export const contentPlayerPlayingPositionAtom = atom({ 80 | key: `${prefix}.playingPosition`, 81 | default: 0, 82 | }) 83 | 84 | export const contentPlayerPlayingTimeAtom = atom({ 85 | key: `${prefix}.playingTime`, 86 | default: 0, 87 | }) 88 | 89 | export const contentPlayerBufferingAtom = atom({ 90 | key: `${prefix}.buffering`, 91 | default: 100, 92 | }) 93 | 94 | export const contentPlayerTotAtom = atom({ 95 | key: `${prefix}.tot`, 96 | default: 0, 97 | }) 98 | 99 | export const contentPlayerAribSubtitleDataAtom = atom({ 100 | key: `${prefix}.aribSubtitleData`, 101 | default: null, 102 | }) 103 | 104 | export const contentPlayerTsFirstPcrAtom = atom({ 105 | key: `${prefix}.tsFirstPcr`, 106 | default: 0, 107 | }) 108 | 109 | export const contentPlayerDisplayingAribSubtitleDataAtom = 110 | atom({ 111 | key: `${prefix}.displayngAribSubtitleData`, 112 | default: null, 113 | }) 114 | 115 | export const contentPlayerPositionUpdateTriggerAtom = atom({ 116 | key: `${prefix}.positionUpdateTrigger`, 117 | default: 0, 118 | }) 119 | 120 | export const contentPlayerScreenshotTriggerAtom = atom({ 121 | key: `${prefix}.screenshotTrigger`, 122 | default: 0, 123 | }) 124 | 125 | export const contentPlayerScreenshotUrlAtom = atom({ 126 | key: `${prefix}.screenshotUrl`, 127 | default: null, 128 | }) 129 | 130 | export const contentPlayerKeyForRestorationAtom = 131 | atom({ 132 | key: `${prefix}.keyForRestoration`, 133 | default: null, 134 | effects: [ 135 | syncEffect({ 136 | storeKey: RECOIL_SYNC_STORED_KEY, 137 | refine: $.nullable( 138 | $.object({ 139 | contentType: $.string(), 140 | serviceId: $.number(), 141 | }) 142 | ), 143 | }), 144 | ], 145 | }) 146 | 147 | export const lastEpgUpdatedAtom = atom({ 148 | key: `${prefix}.lastEpgUpdated`, 149 | default: 0, 150 | }) 151 | -------------------------------------------------------------------------------- /src/atoms/contentPlayerSelectors.ts: -------------------------------------------------------------------------------- 1 | import { selector } from "recoil" 2 | import pkg from "../../package.json" 3 | import { Program } from "../infra/mirakurun/api" 4 | import { ServiceWithLogoData } from "../types/mirakurun" 5 | import { 6 | contentPlayerAudioTracksAtom, 7 | contentPlayerIsSeekableAtom, 8 | contentPlayerPlayingPositionAtom, 9 | contentPlayerPlayingTimeAtom, 10 | contentPlayerScreenshotUrlAtom, 11 | contentPlayerTotAtom, 12 | } from "./contentPlayer" 13 | import { globalContentPlayerPlayingContentFamily } from "./globalFamilies" 14 | 15 | const prefix = `${pkg.name}.contentPlayer` 16 | 17 | export const contentPlayerAudioTracksSelector = selector({ 18 | key: `${prefix}.audioTracksSelector`, 19 | get: ({ get }) => { 20 | return get(contentPlayerAudioTracksAtom) 21 | }, 22 | }) 23 | 24 | export const contentPlayerIsSeekableSelector = selector({ 25 | key: `${prefix}.isSeekableSelector`, 26 | get: ({ get }) => { 27 | return get(contentPlayerIsSeekableAtom) 28 | }, 29 | }) 30 | 31 | export const contentPlayerPlayingPositionSelector = selector({ 32 | key: `${prefix}.playingPositionSelector`, 33 | get: ({ get }) => { 34 | return get(contentPlayerPlayingPositionAtom) 35 | }, 36 | }) 37 | 38 | export const contentPlayerPlayingTimeSelector = selector({ 39 | key: `${prefix}.playingTimeSelector`, 40 | get: ({ get }) => { 41 | return get(contentPlayerPlayingTimeAtom) 42 | }, 43 | }) 44 | 45 | export const contentPlayerTotSelector = selector({ 46 | key: `${prefix}.totSelector`, 47 | get: ({ get }) => { 48 | return get(contentPlayerTotAtom) 49 | }, 50 | }) 51 | 52 | export const contentPlayerAribSubtitleDataSelector = selector({ 53 | key: `${prefix}.aribSubtitleDataSelector`, 54 | get: ({ get }) => { 55 | return get(contentPlayerPlayingTimeAtom) 56 | }, 57 | }) 58 | 59 | export const contentPlayerTsFirstPcrSelector = selector({ 60 | key: `${prefix}.tsFirstPcrSelector`, 61 | get: ({ get }) => { 62 | return get(contentPlayerPlayingTimeAtom) 63 | }, 64 | }) 65 | 66 | export const contentPlayerUrlSelector = selector({ 67 | key: `${prefix}.url`, 68 | get: ({ get }) => { 69 | const content = get( 70 | globalContentPlayerPlayingContentFamily(window.id ?? -1) 71 | ) 72 | return content?.url || null 73 | }, 74 | }) 75 | 76 | export const contentPlayerServiceSelector = 77 | selector({ 78 | key: `${prefix}.service`, 79 | get: ({ get }) => { 80 | const content = get( 81 | globalContentPlayerPlayingContentFamily(window.id ?? -1) 82 | ) 83 | return content?.service || null 84 | }, 85 | }) 86 | 87 | export const contentPlayerProgramSelector = selector({ 88 | key: `${prefix}.program`, 89 | get: ({ get }) => { 90 | const content = get( 91 | globalContentPlayerPlayingContentFamily(window.id ?? -1) 92 | ) 93 | return content?.program || null 94 | }, 95 | }) 96 | 97 | export const contentPlayerScreenshotUrlSelector = selector({ 98 | key: `${prefix}.screenshotUrlSelector`, 99 | get: ({ get }) => { 100 | return get(contentPlayerScreenshotUrlAtom) 101 | }, 102 | }) 103 | -------------------------------------------------------------------------------- /src/atoms/global.ts: -------------------------------------------------------------------------------- 1 | import $ from "@recoiljs/refine" 2 | import { atom } from "recoil" 3 | import { syncEffect } from "recoil-sync" 4 | import pkg from "../../package.json" 5 | import { 6 | RECOIL_SYNC_SHARED_KEY, 7 | RECOIL_SYNC_STORED_KEY, 8 | } from "../constants/recoil" 9 | import { 10 | globalActiveContentPlayerIdAtomKey, 11 | globalContentPlayerIdsAtomKey, 12 | globalLastEpgUpdatedAtomKey, 13 | globalDisabledPluginFileNamesAtomKey, 14 | } from "./globalKeys" 15 | 16 | const prefix = `${pkg.name}.global` 17 | 18 | export const globalActiveContentPlayerIdAtom = atom({ 19 | key: globalActiveContentPlayerIdAtomKey, 20 | default: null, 21 | effects: [ 22 | syncEffect({ 23 | storeKey: RECOIL_SYNC_SHARED_KEY, 24 | refine: $.nullable($.number()), 25 | }), 26 | ], 27 | }) 28 | 29 | export const globalContentPlayerIdsAtom = atom({ 30 | key: globalContentPlayerIdsAtomKey, 31 | default: [], 32 | effects: [ 33 | syncEffect({ 34 | storeKey: RECOIL_SYNC_SHARED_KEY, 35 | refine: $.array($.number()), 36 | }), 37 | ], 38 | }) 39 | 40 | export const globalFontsAtom = atom({ 41 | key: `${prefix}.fonts`, 42 | default: [], 43 | effects: [ 44 | syncEffect({ 45 | storeKey: RECOIL_SYNC_SHARED_KEY, 46 | refine: $.array($.string()), 47 | }), 48 | ], 49 | }) 50 | 51 | export const globalLastEpgUpdatedAtom = atom({ 52 | key: globalLastEpgUpdatedAtomKey, 53 | default: 0, 54 | effects: [ 55 | syncEffect({ 56 | storeKey: RECOIL_SYNC_SHARED_KEY, 57 | refine: $.number(), 58 | }), 59 | ], 60 | }) 61 | 62 | const globalDisabledPluginFileNamesAtomRefine = $.array($.string()) 63 | 64 | export const globalDisabledPluginFileNamesAtom = atom({ 65 | key: globalDisabledPluginFileNamesAtomKey, 66 | default: [], 67 | effects: [ 68 | syncEffect({ 69 | storeKey: RECOIL_SYNC_SHARED_KEY, 70 | refine: globalDisabledPluginFileNamesAtomRefine, 71 | }), 72 | syncEffect({ 73 | storeKey: RECOIL_SYNC_STORED_KEY, 74 | refine: globalDisabledPluginFileNamesAtomRefine, 75 | }), 76 | ], 77 | }) 78 | -------------------------------------------------------------------------------- /src/atoms/globalFamilies.ts: -------------------------------------------------------------------------------- 1 | import $ from "@recoiljs/refine" 2 | import { atomFamily } from "recoil" 3 | import { syncEffect } from "recoil-sync" 4 | import { RECOIL_SYNC_SHARED_KEY } from "../constants/recoil" 5 | import { Service } from "../infra/mirakurun/api" 6 | 7 | import { ContentPlayerPlayingContent } from "../types/contentPlayer" 8 | import { 9 | globalContentPlayerIsPlayingFamilyKey, 10 | globalContentPlayerPlayingContentFamilyKey, 11 | globalContentPlayerSelectedServiceFamilyKey, 12 | } from "./globalFamilyKeys" 13 | 14 | export const globalContentPlayerPlayingContentFamily = atomFamily< 15 | ContentPlayerPlayingContent | null, 16 | number 17 | >({ 18 | key: globalContentPlayerPlayingContentFamilyKey, 19 | default: null, 20 | effects: [ 21 | syncEffect({ 22 | storeKey: RECOIL_SYNC_SHARED_KEY, 23 | refine: $.nullable( 24 | $.object({ 25 | contentType: $.string(), 26 | url: $.string(), 27 | program: $.voidable($.mixed()), 28 | service: $.voidable($.mixed()), 29 | }) 30 | ), 31 | }), 32 | ], 33 | }) 34 | 35 | export const globalContentPlayerSelectedServiceFamily = atomFamily< 36 | Service | null, 37 | number 38 | >({ 39 | key: globalContentPlayerSelectedServiceFamilyKey, 40 | default: null, 41 | effects: [ 42 | syncEffect({ 43 | storeKey: RECOIL_SYNC_SHARED_KEY, 44 | refine: $.nullable($.mixed()), 45 | }), 46 | ], 47 | }) 48 | 49 | export const globalContentPlayerIsPlayingFamily = atomFamily({ 50 | key: globalContentPlayerIsPlayingFamilyKey, 51 | default: false, 52 | effects: [syncEffect({ storeKey: RECOIL_SYNC_SHARED_KEY, refine: $.bool() })], 53 | }) 54 | -------------------------------------------------------------------------------- /src/atoms/globalFamilyKeys.ts: -------------------------------------------------------------------------------- 1 | import pkg from "../../package.json" 2 | 3 | const prefix = `${pkg.name}.global` 4 | 5 | export const globalContentPlayerPlayingContentFamilyKey = `${prefix}.playingContent` 6 | 7 | export const globalContentPlayerSelectedServiceFamilyKey = `${prefix}.selectedService` 8 | 9 | export const globalContentPlayerIsPlayingFamilyKey = `${prefix}.isPlaying` 10 | -------------------------------------------------------------------------------- /src/atoms/globalKeys.ts: -------------------------------------------------------------------------------- 1 | import pkg from "../../package.json" 2 | 3 | const prefix = `${pkg.name}.global` 4 | 5 | export const globalActiveContentPlayerIdAtomKey = `${prefix}.activeContentPlayerId` 6 | 7 | export const globalContentPlayerIdsAtomKey = `${prefix}.contentPlayerIds` 8 | 9 | export const globalLastEpgUpdatedAtomKey = `${prefix}.lastEpgUpdated` 10 | 11 | export const globalDisabledPluginFileNamesAtomKey = `${prefix}.disabledPluginFileNames` 12 | -------------------------------------------------------------------------------- /src/atoms/globalSelectors.ts: -------------------------------------------------------------------------------- 1 | import { selector } from "recoil" 2 | import pkg from "../../package.json" 3 | import { 4 | globalActiveContentPlayerIdAtom, 5 | globalContentPlayerIdsAtom, 6 | } from "./global" 7 | 8 | const prefix = `${pkg.name}.global` 9 | 10 | export const globalActiveContentPlayerIdSelector = selector({ 11 | key: `${prefix}.activeContentPlayerIdSelector`, 12 | get: ({ get }) => { 13 | return get(globalActiveContentPlayerIdAtom) 14 | }, 15 | }) 16 | 17 | export const globalContentPlayerIdsSelector = selector({ 18 | key: `${prefix}.globalContentPlayerIdsSelector`, 19 | get: ({ get }) => { 20 | return get(globalContentPlayerIdsAtom) 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /src/atoms/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/MirakTest/e760296b9bbe7cc4d3dcd5fa8ab3cc99c962f370/src/atoms/index.ts -------------------------------------------------------------------------------- /src/atoms/mirakurun.ts: -------------------------------------------------------------------------------- 1 | import $ from "@recoiljs/refine" 2 | import { atom } from "recoil" 3 | import { syncEffect } from "recoil-sync" 4 | import pkg from "../../package.json" 5 | import { RECOIL_SYNC_SHARED_KEY } from "../constants/recoil" 6 | import { 7 | MirakurunCompatibilityTypes, 8 | ServiceWithLogoData, 9 | } from "../types/mirakurun" 10 | 11 | const prefix = `${pkg.name}.mirakurun` 12 | 13 | export const mirakurunCompatibilityAtom = 14 | atom({ 15 | key: `${prefix}.compatibility`, 16 | default: null, 17 | }) 18 | 19 | export const mirakurunVersionAtom = atom({ 20 | key: `${prefix}.version`, 21 | default: null, 22 | }) 23 | 24 | export const mirakurunServicesAtom = atom({ 25 | key: `${prefix}.services`, 26 | default: null, 27 | effects: [ 28 | syncEffect({ 29 | storeKey: RECOIL_SYNC_SHARED_KEY, 30 | refine: $.nullable($.array($.mixed())), 31 | }), 32 | ], 33 | }) 34 | -------------------------------------------------------------------------------- /src/atoms/mirakurunSelectors.ts: -------------------------------------------------------------------------------- 1 | import { selector } from "recoil" 2 | import pkg from "../../package.json" 3 | import { Service } from "../infra/mirakurun/api" 4 | import { MirakurunCompatibilityTypes } from "../types/mirakurun" 5 | import { 6 | mirakurunCompatibilityAtom, 7 | mirakurunServicesAtom, 8 | mirakurunVersionAtom, 9 | } from "./mirakurun" 10 | 11 | const prefix = `${pkg.name}.mirakurun` 12 | 13 | export const mirakurunCompatibilitySelector = 14 | selector({ 15 | key: `${prefix}.compatibilitySelector`, 16 | get: ({ get }) => { 17 | return get(mirakurunCompatibilityAtom) 18 | }, 19 | }) 20 | 21 | export const mirakurunVersionSelector = selector({ 22 | key: `${prefix}.versionSelector`, 23 | get: ({ get }) => { 24 | return get(mirakurunVersionAtom) 25 | }, 26 | }) 27 | 28 | export const mirakurunServicesSelector = selector({ 29 | key: `${prefix}.servicesSelector`, 30 | get: ({ get }) => { 31 | return get(mirakurunServicesAtom) 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /src/atoms/settings.ts: -------------------------------------------------------------------------------- 1 | import $ from "@recoiljs/refine" 2 | import { atom } from "recoil" 3 | import { syncEffect } from "recoil-sync" 4 | import pkg from "../../package.json" 5 | import { SUBTITLE_DEFAULT_FONT } from "../constants/font" 6 | import { 7 | RECOIL_SYNC_SHARED_KEY, 8 | RECOIL_SYNC_STORED_KEY, 9 | } from "../constants/recoil" 10 | import type { 11 | ControllerSetting, 12 | ExperimentalSetting, 13 | MirakurunSetting, 14 | ScreenshotSetting, 15 | SubtitleSetting, 16 | } from "../types/setting" 17 | import { 18 | experimentalSettingAtomKey, 19 | screenshotSettingAtomKey, 20 | } from "./settingsKey" 21 | 22 | const prefix = `${pkg.name}.settings` 23 | 24 | const mirakurunSettingRefine = $.object({ 25 | isEnableServiceTypeFilter: $.withDefault($.bool(), true), 26 | baseUrl: $.voidable($.string()), 27 | userAgent: $.voidable($.string()), 28 | }) 29 | 30 | export const mirakurunSetting = atom({ 31 | key: `${prefix}.mirakurun`, 32 | default: { 33 | isEnableServiceTypeFilter: true, 34 | }, 35 | effects: [ 36 | syncEffect({ 37 | storeKey: RECOIL_SYNC_SHARED_KEY, 38 | refine: mirakurunSettingRefine, 39 | }), 40 | syncEffect({ 41 | storeKey: RECOIL_SYNC_STORED_KEY, 42 | refine: mirakurunSettingRefine, 43 | }), 44 | ], 45 | }) 46 | 47 | export const mirakurunUrlHistory = atom({ 48 | key: `${prefix}.mirakurunUrlHistory`, 49 | default: [], 50 | effects: [ 51 | syncEffect({ 52 | storeKey: RECOIL_SYNC_STORED_KEY, 53 | refine: $.array($.string()), 54 | }), 55 | ], 56 | }) 57 | 58 | const controllerSettingRefine = $.object({ 59 | volumeRange: $.withDefault($.array($.number()), [0, 150]), 60 | isVolumeWheelDisabled: $.withDefault($.bool(), false), 61 | }) 62 | 63 | export const controllerSetting = atom({ 64 | key: `${prefix}.controller`, 65 | default: { 66 | volumeRange: [0, 150], 67 | isVolumeWheelDisabled: false, 68 | }, 69 | effects: [ 70 | syncEffect({ 71 | storeKey: RECOIL_SYNC_SHARED_KEY, 72 | refine: controllerSettingRefine, 73 | }), 74 | syncEffect({ 75 | storeKey: RECOIL_SYNC_STORED_KEY, 76 | refine: controllerSettingRefine, 77 | }), 78 | ], 79 | }) 80 | 81 | const subtitleSettingRefine = $.object({ 82 | font: $.withDefault($.string(), SUBTITLE_DEFAULT_FONT), 83 | }) 84 | 85 | export const subtitleSetting = atom({ 86 | key: `${prefix}.subtitle`, 87 | default: { 88 | font: SUBTITLE_DEFAULT_FONT, 89 | }, 90 | effects: [ 91 | syncEffect({ 92 | storeKey: RECOIL_SYNC_SHARED_KEY, 93 | refine: subtitleSettingRefine, 94 | }), 95 | syncEffect({ 96 | storeKey: RECOIL_SYNC_STORED_KEY, 97 | refine: subtitleSettingRefine, 98 | }), 99 | ], 100 | }) 101 | 102 | const screenshotSettingRefine = $.object({ 103 | saveAsAFile: $.withDefault($.bool(), true), 104 | includeSubtitle: $.withDefault($.bool(), true), 105 | keepQuality: $.withDefault($.bool(), true), 106 | basePath: $.voidable($.string()), 107 | }) 108 | 109 | export const screenshotSetting = atom({ 110 | key: screenshotSettingAtomKey, 111 | default: { 112 | saveAsAFile: true, 113 | includeSubtitle: true, 114 | keepQuality: true, 115 | }, 116 | effects: [ 117 | syncEffect({ 118 | storeKey: RECOIL_SYNC_SHARED_KEY, 119 | refine: screenshotSettingRefine, 120 | }), 121 | syncEffect({ 122 | storeKey: RECOIL_SYNC_STORED_KEY, 123 | refine: screenshotSettingRefine, 124 | }), 125 | ], 126 | }) 127 | 128 | const experimentalSettingRefine = $.object({ 129 | isWindowDragMoveEnabled: $.withDefault($.bool(), false), 130 | isVlcAvCodecHwAny: $.withDefault($.bool(), false), 131 | vlcNetworkCaching: $.withDefault($.number(), -1), 132 | isDualMonoAutoAdjustEnabled: $.withDefault($.bool(), true), 133 | isSurroundAutoAdjustEnabeld: $.withDefault($.bool(), true), 134 | globalScreenshotAccelerator: $.withDefault($.or($.string(), $.bool()), false), 135 | isCodeBlack: $.withDefault($.bool(), false), 136 | }) 137 | 138 | export const experimentalSetting = atom({ 139 | key: experimentalSettingAtomKey, 140 | default: { 141 | isWindowDragMoveEnabled: false, 142 | isVlcAvCodecHwAny: false, 143 | vlcNetworkCaching: -1, 144 | isDualMonoAutoAdjustEnabled: true, 145 | isSurroundAutoAdjustEnabeld: true, 146 | globalScreenshotAccelerator: false, 147 | isCodeBlack: false, 148 | }, 149 | effects: [ 150 | syncEffect({ 151 | storeKey: RECOIL_SYNC_SHARED_KEY, 152 | refine: experimentalSettingRefine, 153 | }), 154 | syncEffect({ 155 | storeKey: RECOIL_SYNC_STORED_KEY, 156 | refine: experimentalSettingRefine, 157 | }), 158 | ], 159 | }) 160 | -------------------------------------------------------------------------------- /src/atoms/settingsKey.ts: -------------------------------------------------------------------------------- 1 | import pkg from "../../package.json" 2 | 3 | const prefix = `${pkg.name}.settings` 4 | 5 | export const screenshotSettingAtomKey = `${prefix}.screenshot` 6 | 7 | export const experimentalSettingAtomKey = `${prefix}.experimental` 8 | -------------------------------------------------------------------------------- /src/atoms/settingsSelector.ts: -------------------------------------------------------------------------------- 1 | import { selector } from "recoil" 2 | import pkg from "../../package.json" 3 | import { 4 | controllerSetting, 5 | experimentalSetting, 6 | screenshotSetting, 7 | subtitleSetting, 8 | } from "./settings" 9 | 10 | const prefix = `${pkg.name}.settings` 11 | 12 | export const controllerSettingSelector = selector({ 13 | key: `${prefix}.controllerSelector`, 14 | get: ({ get }) => { 15 | return get(controllerSetting) 16 | }, 17 | }) 18 | 19 | export const subtitleSettingSelector = selector({ 20 | key: `${prefix}.subtitleSettingSelector`, 21 | get: ({ get }) => { 22 | return get(subtitleSetting) 23 | }, 24 | }) 25 | 26 | export const screenshotSettingSelector = selector({ 27 | key: `${prefix}.screenshotSettingSelector`, 28 | get: ({ get }) => { 29 | return get(screenshotSetting) 30 | }, 31 | }) 32 | 33 | export const experimentalSettingSelector = selector({ 34 | key: `${prefix}.experimentalSettingSelector`, 35 | get: ({ get }) => { 36 | return get(experimentalSetting) 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/atoms/window.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | import pkg from "../../package.json" 3 | 4 | const prefix = `${pkg.name}.window` 5 | 6 | export const windowRootFontSizeAtom = atom({ 7 | key: `${prefix}.rootFontSize`, 8 | default: 16, 9 | }) 10 | -------------------------------------------------------------------------------- /src/components/common/AutoLinkedText.tsx: -------------------------------------------------------------------------------- 1 | import { Interweave } from "interweave" 2 | import { 3 | Email, 4 | EmailMatcher, 5 | Hashtag, 6 | HashtagMatcher, 7 | Url, 8 | UrlMatcher, 9 | } from "interweave-autolink" 10 | import React from "react" 11 | //import { MentionMatcher } from "../../utils/interweave-mention/Matcher" 12 | //import { Mention } from "../../utils/interweave-mention/Mention" 13 | 14 | export const AutoLinkedText: React.FC<{ children: string }> = ({ 15 | children, 16 | }) => ( 17 | ), 21 | new HashtagMatcher("hashtag", {}, (args) => ( 22 | `https://twitter.com/hashtag/${url}`} 24 | newWindow={true} 25 | {...args} 26 | /> 27 | )), 28 | new EmailMatcher("email", {}, (args) => ( 29 | 30 | )), 31 | // TODO: MentionMatcher 32 | /*new MentionMatcher("mention", {}, (args) => ( 33 | `https://twitter.com/${mentionTo}`} 35 | newWindow={true} 36 | {...args} 37 | /> 38 | )),*/ 39 | ]} 40 | /> 41 | ) 42 | -------------------------------------------------------------------------------- /src/components/common/ComponentShadowWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react" 2 | import { createRoot, Root } from "react-dom/client" 3 | 4 | export const ComponentShadowWrapper: React.FC<{ 5 | Component: React.FC<{}> 6 | _id?: string 7 | className?: string 8 | }> = ({ Component, _id, className }) => { 9 | const ref = useRef(null) 10 | const rootRef = useRef() 11 | useEffect(() => { 12 | const div = ref.current 13 | const root = rootRef.current 14 | if (root) { 15 | root.render() 16 | } else if (div) { 17 | let shadow: ShadowRoot 18 | if (div.shadowRoot) { 19 | shadow = div.shadowRoot 20 | } else { 21 | shadow = div.attachShadow({ mode: "open" }) 22 | } 23 | const root = createRoot(shadow) 24 | root.render() 25 | rootRef.current = root 26 | } 27 | }, [ref, _id]) 28 | return
29 | } 30 | -------------------------------------------------------------------------------- /src/components/common/EscapeEnclosed.tsx: -------------------------------------------------------------------------------- 1 | import { css, StyleSheet } from "aphrodite" 2 | import clsx from "clsx" 3 | import React, { memo } from "react" 4 | import { 5 | ENCLOSED_CHARACTERS_TABLE, 6 | ENCLOSED_STYLES, 7 | } from "../../constants/enclosed" 8 | 9 | const style = StyleSheet.create>({ 10 | enclosed: { 11 | fontSize: 0, 12 | lineHeight: 0, 13 | }, 14 | ...ENCLOSED_STYLES, 15 | }) 16 | 17 | export const EscapeEnclosed = memo( 18 | ({ str, size }: { str: string; size: string }) => ( 19 | <> 20 | {Array.from(str).map((char, idx) => { 21 | if (ENCLOSED_CHARACTERS_TABLE[char]) { 22 | return ( 23 | 34 | {char} 35 | 36 | ) 37 | } 38 | return {char} 39 | })} 40 | 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /src/components/common/PluginPositionComponents.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useEffect, useState } from "react" 3 | import { useRecoilBridgeAcrossReactRoots_UNSTABLE } from "recoil" 4 | import { ComponentWithPosition } from "../../types/plugin" 5 | import { ComponentShadowWrapper } from "./ComponentShadowWrapper" 6 | 7 | export const PluginPositionComponents: React.FC<{ 8 | position: ComponentWithPosition["position"] 9 | isAbsolute?: boolean 10 | }> = ({ position, isAbsolute = true }) => { 11 | const [components, setComponents] = useState( 12 | null 13 | ) 14 | const RecoilBridge = useRecoilBridgeAcrossReactRoots_UNSTABLE() 15 | useEffect(() => { 16 | const components = 17 | window.plugins 18 | ?.map((plugin) => 19 | plugin.components.filter( 20 | (component) => component.position === position 21 | ) 22 | ) 23 | .flat() || [] 24 | setComponents(components) 25 | }, []) 26 | if (components === null) return <> 27 | return ( 28 |
29 | {components.map((component) => ( 30 | ( 37 | 38 | 39 | 40 | )} 41 | /> 42 | ))} 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/contentPlayer/LoadingCircle.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useMemo } from "react" 3 | import { useEffect } from "react" 4 | import { useState } from "react" 5 | import { useRecoilValue } from "recoil" 6 | import { windowRootFontSizeAtom } from "../../atoms/window" 7 | 8 | const OUTER_R = 5 / 2 9 | const STROKE_WIDTH = 0.4 10 | 11 | export const CoiledLoadingCircle: React.FC<{ percentage: number }> = ({ 12 | percentage, 13 | }) => { 14 | const rootFontSize = useRecoilValue(windowRootFontSizeAtom) 15 | const r = useMemo(() => OUTER_R - STROKE_WIDTH, []) 16 | const circumreference = useMemo( 17 | () => r * rootFontSize * 2 * Math.PI, 18 | [rootFontSize] 19 | ) 20 | const [internalPercent, setInternalPercent] = useState(percentage) 21 | useEffect(() => { 22 | setInternalPercent(percentage) 23 | if (percentage === 100) { 24 | const timeout = setTimeout(() => { 25 | setInternalPercent(0) 26 | }, 1000) 27 | return () => clearTimeout(timeout) 28 | } 29 | }, [percentage]) 30 | return ( 31 | 32 | 41 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/contentPlayer/ProgramTitleManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | import { useRecoilValue, useSetRecoilState } from "recoil" 3 | import { contentPlayerTitleAtom } from "../../atoms/contentPlayer" 4 | import { 5 | contentPlayerProgramSelector, 6 | contentPlayerServiceSelector, 7 | } from "../../atoms/contentPlayerSelectors" 8 | import { convertVariationSelectedClosed } from "../../utils/enclosed" 9 | 10 | export const CoiledProgramTitleManager: React.FC<{}> = () => { 11 | const service = useRecoilValue(contentPlayerServiceSelector) 12 | const program = useRecoilValue(contentPlayerProgramSelector) 13 | const setTitle = useSetRecoilState(contentPlayerTitleAtom) 14 | 15 | useEffect(() => { 16 | const title = [program?.name, service?.name].filter((s) => s).join(" - ") 17 | const variationSelected = convertVariationSelectedClosed(title).trim() 18 | setTitle(variationSelected || null) 19 | }, [program, service]) 20 | 21 | useEffect(() => { 22 | if (!program) return 23 | console.info("放送中の番組:", program) 24 | }, [program]) 25 | 26 | return <> 27 | } 28 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/AudioChannelSelector.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo } from "react" 3 | import { 4 | VLCAudioChannel, 5 | VLCAudioChannelSurroundTranslated, 6 | VLCAudioChannelTranslated, 7 | VLCAudioStereoChannel, 8 | } from "../../../utils/vlc" 9 | 10 | export const AudioChannelSelector: React.FC<{ 11 | audioChannel: number 12 | setAudioChannel: React.Dispatch> 13 | isStereo: boolean 14 | }> = memo(({ audioChannel, setAudioChannel, isStereo }) => ( 15 | 45 | )) 46 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/AudioTrackSelector.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo } from "react" 3 | 4 | export const AudioTrackSelector: React.FC<{ 5 | audioTrack: number 6 | setAudioTrack: React.Dispatch> 7 | audioTracks: string[] 8 | }> = memo(({ audioTrack, setAudioTrack, audioTracks }) => ( 9 | 37 | )) 38 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/FullScreenToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React from "react" 3 | import { Maximize } from "react-feather" 4 | 5 | export const FullScreenToggleButton: React.FC<{ toggle: Function }> = ({ 6 | toggle, 7 | }) => { 8 | return ( 9 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/PlayToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { Pause, Play } from "react-feather" 3 | 4 | export const PlayToggleButton: React.FC<{ 5 | isPlaying: boolean 6 | setIsPlaying: React.Dispatch> 7 | }> = memo(({ isPlaying, setIsPlaying }) => ( 8 | 21 | )) 22 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/ScreenshotButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo } from "react" 3 | import { Camera } from "react-feather" 4 | import { useSetRecoilState } from "recoil" 5 | import { contentPlayerScreenshotTriggerAtom } from "../../../atoms/contentPlayer" 6 | 7 | export const CoiledScreenshotButton: React.FC<{}> = memo(() => { 8 | const setScreenshotTrigger = useSetRecoilState( 9 | contentPlayerScreenshotTriggerAtom 10 | ) 11 | 12 | return ( 13 | 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SeekableControl.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import formatDuration from "format-duration" 3 | import React, { memo, useCallback, useEffect, useState } from "react" 4 | import { FastForward, Rewind, SkipBack } from "react-feather" 5 | import { useDebounce } from "react-use" 6 | 7 | export const SeekableControl: React.FC<{ 8 | time: number 9 | position: number 10 | setPosition: React.Dispatch> 11 | duration?: number 12 | seekRequest: number | null 13 | setSeekRequest: React.Dispatch> 14 | }> = memo( 15 | ({ time, position, setPosition, duration, seekRequest, setSeekRequest }) => { 16 | const [isPreview, setIsPreview] = useState(false) 17 | const [previewPosition, setPreviewPosition] = useState(0) 18 | 19 | const relativeMove = useCallback( 20 | (relativeMs: number) => { 21 | if (!duration) { 22 | return 23 | } 24 | setPosition((position * duration + relativeMs) / duration) 25 | }, 26 | [position, duration] 27 | ) 28 | 29 | useEffect(() => { 30 | if (!seekRequest) { 31 | return 32 | } 33 | relativeMove(seekRequest) 34 | setSeekRequest(null) 35 | }, [seekRequest]) 36 | 37 | const [pastDuration, setPastDuration] = useState("0:00") 38 | useDebounce( 39 | () => { 40 | setPastDuration( 41 | isPreview ? formatDuration(previewPosition) : formatDuration(time) 42 | ) 43 | }, 44 | 30, 45 | [isPreview, previewPosition, time] 46 | ) 47 | 48 | return ( 49 |
52 |
64 | {pastDuration} 65 |
66 |
67 | 76 | 90 | 104 |
105 | { 114 | const { left, width } = event.currentTarget.getBoundingClientRect() 115 | const pos = event.pageX - left - window.pageXOffset 116 | const seekTo = Math.max(pos / width, 0) 117 | setPosition(seekTo) 118 | }} 119 | onMouseEnter={() => { 120 | duration && setIsPreview(true) 121 | }} 122 | onMouseMove={(event) => { 123 | if (!duration) { 124 | return 125 | } 126 | const { left, width } = event.currentTarget.getBoundingClientRect() 127 | const pos = event.pageX - left - window.pageXOffset 128 | const seekTo = Math.max(Math.round((pos / width) * duration), 0) 129 | setPreviewPosition(seekTo) 130 | }} 131 | onMouseLeave={() => { 132 | setIsPreview(false) 133 | }} 134 | /> 135 |
136 | ) 137 | } 138 | ) 139 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SidebarSelectedServiceList.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo, useRef } from "react" 3 | import { Service, Program } from "../../../infra/mirakurun/api" 4 | import { SidebarServiceCarousel } from "./SidebarServiceCarousel" 5 | import { SidebarServiceDetail } from "./SidebarServiceDetail" 6 | import { SidebarServiceQuickButton } from "./SidebarServiceQuickButton" 7 | 8 | export const SidebarSelectedServiceList: React.FC<{ 9 | services: Service[][] 10 | queriedPrograms: Program[] 11 | setService: (s: Service) => void 12 | }> = memo( 13 | ({ services, queriedPrograms, setService }) => { 14 | const scrollAreaRef = useRef(null) 15 | return ( 16 |
17 |
18 | {services.map((services) => { 19 | const service = services[0] 20 | const programs = queriedPrograms 21 | .filter( 22 | (program) => 23 | program.serviceId === service.serviceId && 24 | program.networkId === service.networkId 25 | ) 26 | .sort((a, b) => a.startAt - b.startAt) 27 | const current = programs?.[0] 28 | return ( 29 | 35 | ) 36 | })} 37 |
38 | {services.map((services) => { 39 | if (services.length < 2) { 40 | const service = services[0] 41 | return ( 42 | 48 | ) 49 | } 50 | return ( 51 | 57 | ) 58 | })} 59 |
60 | ) 61 | }, 62 | (prev, next) => 63 | JSON.stringify(prev.services) === JSON.stringify(next.services) && 64 | prev.queriedPrograms === next.queriedPrograms 65 | ) 66 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SidebarServiceCarousel.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo, useState } from "react" 3 | import { ChevronLeft, ChevronRight } from "react-feather" 4 | import { useSpringCarousel } from "react-spring-carousel-js" 5 | import { Service, Program } from "../../../infra/mirakurun/api" 6 | import { SidebarServiceDetail } from "./SidebarServiceDetail" 7 | 8 | const RightArrow = ({ onClick }: { onClick: () => void }) => { 9 | return ( 10 | 28 | ) 29 | } 30 | 31 | const LeftArrow = ({ onClick }: { onClick: () => void }) => { 32 | return ( 33 | 51 | ) 52 | } 53 | 54 | export const SidebarServiceCarousel = memo( 55 | ({ 56 | services, 57 | queriedPrograms, 58 | setService, 59 | }: { 60 | services: Service[] 61 | queriedPrograms: Program[] 62 | setService: (s: Service) => void 63 | }) => { 64 | const { 65 | carouselFragment, 66 | slideToNextItem, 67 | slideToPrevItem, 68 | getCurrentActiveItem, 69 | } = useSpringCarousel({ 70 | items: services.map((service) => ({ 71 | id: service.id.toString(), 72 | renderItem: ( 73 | 78 | ), 79 | })), 80 | }) 81 | const [currentItem, setCurrentItem] = useState(0) 82 | return ( 83 |
{ 86 | setCurrentItem(getCurrentActiveItem()?.index ?? 0) 87 | }} 88 | > 89 | {currentItem !== 0 && ( 90 | { 92 | slideToPrevItem() 93 | setCurrentItem(getCurrentActiveItem()?.index ?? 0) 94 | }} 95 | /> 96 | )} 97 | {carouselFragment} 98 | {currentItem !== services.length - 1 && ( 99 | { 101 | slideToNextItem() 102 | setCurrentItem(getCurrentActiveItem()?.index ?? 0) 103 | }} 104 | /> 105 | )} 106 |
107 | ) 108 | }, 109 | (prev, next) => 110 | prev.services.map((s) => s.id).join() === 111 | next.services.map((s) => s.id).join() && 112 | prev.queriedPrograms === next.queriedPrograms 113 | ) 114 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SidebarServiceDetail.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import dayjs from "dayjs" 3 | import React, { memo, useState } from "react" 4 | import { ChevronsRight } from "react-feather" 5 | import { Service, Program } from "../../../infra/mirakurun/api" 6 | import { ServiceWithLogoData } from "../../../types/mirakurun" 7 | import { EscapeEnclosed } from "../../common/EscapeEnclosed" 8 | 9 | export const SidebarServiceDetail = memo( 10 | ({ 11 | service, 12 | queriedPrograms, 13 | setService, 14 | }: { 15 | service: ServiceWithLogoData 16 | queriedPrograms: Program[] 17 | setService: (s: Service) => void 18 | }) => { 19 | const programs = queriedPrograms 20 | .filter( 21 | (program) => 22 | program.serviceId === service.serviceId && 23 | program.networkId === service.networkId 24 | ) 25 | .sort((a, b) => a.startAt - b.startAt) 26 | const current = programs?.[0] 27 | const next = programs?.[1] 28 | const [whenMouseDown, setWhenMouseDown] = useState(0) 29 | return ( 30 | 124 | ) 125 | }, 126 | (prev, next) => 127 | prev.service.id === next.service.id && 128 | prev.queriedPrograms === next.queriedPrograms 129 | ) 130 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SidebarServiceQuickButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import dayjs from "dayjs" 3 | import React, { memo, useState } from "react" 4 | import Marquee from "react-fast-marquee" 5 | import { Service, Program } from "../../../infra/mirakurun/api" 6 | import { ServiceWithLogoData } from "../../../types/mirakurun" 7 | import { convertVariationSelectedClosed } from "../../../utils/enclosed" 8 | import { EscapeEnclosed } from "../../common/EscapeEnclosed" 9 | 10 | export const SidebarServiceQuickButton = memo( 11 | ({ 12 | service, 13 | setService, 14 | program, 15 | }: { 16 | service: ServiceWithLogoData 17 | setService: (s: Service) => void 18 | program: Program | undefined 19 | }) => { 20 | const [isHovering, setIsHovering] = useState(false) 21 | return ( 22 | 100 | ) 101 | }, 102 | (prev, next) => 103 | prev.service.id === next.service.id && prev.program?.id === next.program?.id 104 | ) 105 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SpeedSelector.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useEffect, useState } from "react" 3 | 4 | export const SpeedSelector = ({ 5 | isSeekable, 6 | speed, 7 | setSpeed, 8 | }: { 9 | isSeekable: boolean 10 | speed: number 11 | setSpeed: React.Dispatch> 12 | }) => { 13 | const [local, setLocal] = useState(speed) 14 | const [isInvalid, setIsInvalid] = useState(false) 15 | useEffect(() => { 16 | if (local < 0.1 || local > 5) { 17 | setIsInvalid(true) 18 | return 19 | } 20 | setIsInvalid(false) 21 | setSpeed(local) 22 | }, [local]) 23 | useEffect(() => setLocal(speed), [speed]) 24 | return ( 25 | { 36 | const value = parseFloat(e.target.value) 37 | if (Number.isNaN(value)) { 38 | return 39 | } 40 | setLocal(value) 41 | }} 42 | disabled={!isSeekable} 43 | onKeyDown={(e) => e.stopPropagation()} 44 | /> 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/SubtitleToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo } from "react" 3 | import { Type } from "react-feather" 4 | 5 | export const SubtitleToggleButton: React.FC<{ 6 | subtitleEnabled: boolean 7 | setSubtitleEnabled: React.Dispatch> 8 | }> = memo(({ subtitleEnabled, setSubtitleEnabled }) => ( 9 | 18 | )) 19 | -------------------------------------------------------------------------------- /src/components/contentPlayer/controllers/VolumeSlider.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { memo, useEffect, useState } from "react" 3 | import { VolumeX, Volume1, Volume2 } from "react-feather" 4 | import { useDebounce } from "react-use" 5 | 6 | export const VolumeSlider: React.FC<{ 7 | volume: number 8 | setVolume: React.Dispatch> 9 | min: number 10 | max: number 11 | }> = memo(({ volume, setVolume, min, max }) => { 12 | const [rangeVolume, setRangeVolume] = useState(volume) 13 | 14 | useDebounce( 15 | () => { 16 | setVolume(rangeVolume) 17 | }, 18 | 10, 19 | [rangeVolume] 20 | ) 21 | useEffect(() => { 22 | setRangeVolume(volume) 23 | }, [volume]) 24 | 25 | return ( 26 |
29 | 42 | { 50 | const p = parseInt(e.target.value) 51 | if (Number.isNaN(p)) return 52 | setRangeVolume(p) 53 | }} 54 | /> 55 |
56 | ) 57 | }) 58 | -------------------------------------------------------------------------------- /src/components/global/EpgUpdatedObserver.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | import { useThrottleFn } from "react-use" 3 | import { useRecoilValue, useSetRecoilState } from "recoil" 4 | import { lastEpgUpdatedAtom } from "../../atoms/contentPlayer" 5 | import { globalLastEpgUpdatedAtom } from "../../atoms/global" 6 | 7 | export const CoiledEpgUpdatedObserver = () => { 8 | const globalLastEpgUpdated = useRecoilValue(globalLastEpgUpdatedAtom) 9 | const setLastEpgUpdated = useSetRecoilState(lastEpgUpdatedAtom) 10 | const [isFirst, setIsFirst] = useState(true) 11 | // 初回更新 12 | useEffect(() => { 13 | if (globalLastEpgUpdated === 0 || isFirst === false) { 14 | return 15 | } 16 | setIsFirst(false) 17 | setLastEpgUpdated(globalLastEpgUpdated) 18 | console.debug("番組表が更新されました:", globalLastEpgUpdated) 19 | }, [globalLastEpgUpdated]) 20 | // 継続更新 21 | useThrottleFn( 22 | (globalLastEpgUpdated) => { 23 | if (globalLastEpgUpdated === 0) { 24 | return 25 | } 26 | setLastEpgUpdated(globalLastEpgUpdated) 27 | console.debug("番組表が更新されました:", globalLastEpgUpdated) 28 | }, 29 | 1000 * 30, 30 | [globalLastEpgUpdated] 31 | ) 32 | return <> 33 | } 34 | -------------------------------------------------------------------------------- /src/components/global/RecoilSharedSync.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react" 2 | import type { SerializableParam } from "recoil" 3 | import { DefaultValue } from "recoil" 4 | import { RecoilSync } from "recoil-sync" 5 | import { RECOIL_SYNC_SHARED_KEY } from "../../constants/recoil" 6 | import { SerializableKV } from "../../types/ipc" 7 | import { ObjectLiteral } from "../../types/struct" 8 | 9 | export const RecoilSharedSync: React.FC<{ 10 | initialStates: ObjectLiteral 11 | children?: React.ReactNode 12 | }> = ({ initialStates, children }) => { 13 | const eventRef = useRef(new EventTarget()) 14 | const eventName = "recoil-shared-sync-from-main" 15 | const statesRef = useRef(new Map(Object.entries(initialStates))) 16 | const [broadcastChannel, setBroadcastChannel] = 17 | useState(null) 18 | useEffect(() => { 19 | const broadcastChannel = new BroadcastChannel("recoil-sync") 20 | setBroadcastChannel(broadcastChannel) 21 | return () => { 22 | setBroadcastChannel(null) 23 | broadcastChannel.close() 24 | } 25 | }, []) 26 | useEffect(() => { 27 | const onPayloadFromMain = (payload: SerializableKV) => 28 | eventRef.current.dispatchEvent( 29 | new CustomEvent(eventName, { 30 | detail: payload, 31 | }) 32 | ) 33 | const off = window.Preload.onRecoilStateUpdate(onPayloadFromMain) 34 | return () => off() 35 | }, []) 36 | return broadcastChannel ? ( 37 | { 40 | const value = statesRef.current.get(key) 41 | if (typeof value === "undefined" || value === null) { 42 | return new DefaultValue() 43 | } 44 | return value 45 | }} 46 | write={({ diff }) => { 47 | broadcastChannel.postMessage(diff) 48 | for (const [key, value] of diff.entries()) { 49 | window.Preload.recoilStateUpdate({ 50 | key, 51 | value: value as SerializableParam, 52 | }) 53 | statesRef.current.set(key, value) 54 | } 55 | }} 56 | listen={({ updateItem }) => { 57 | const listener = (event: MessageEvent>) => { 58 | for (const [key, value] of event.data.entries()) { 59 | updateItem(key, value) 60 | } 61 | } 62 | broadcastChannel.addEventListener("message", listener) 63 | const onPayloadFromMain = (event: Event) => { 64 | const { key, value } = (event as CustomEvent).detail 65 | updateItem(key, value) 66 | } 67 | const event = eventRef.current 68 | event.addEventListener(eventName, onPayloadFromMain) 69 | return () => { 70 | event.removeEventListener(eventName, onPayloadFromMain) 71 | broadcastChannel.removeEventListener("message", listener) 72 | } 73 | }} 74 | children={children} 75 | /> 76 | ) : null 77 | } 78 | -------------------------------------------------------------------------------- /src/components/global/RecoilStoredSync.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react" 2 | import { DefaultValue } from "recoil" 3 | import { RecoilSync } from "recoil-sync" 4 | import { RECOIL_SYNC_STORED_KEY } from "../../constants/recoil" 5 | 6 | export const RecoilStoredSync: React.FC<{ children?: React.ReactElement }> = ({ 7 | children, 8 | }) => { 9 | const mapRef = useRef(new Map()) 10 | return ( 11 | { 14 | const map = mapRef.current 15 | const value = map.get(key) || window.Preload.store.get(key) 16 | if (value === undefined) { 17 | return new DefaultValue() 18 | } 19 | map.set(key, value) 20 | return value 21 | }} 22 | write={({ diff }) => { 23 | for (const [key, value] of diff.entries()) { 24 | mapRef.current.set(key, value) 25 | if (value === undefined) { 26 | window.Preload.store.delete(key) 27 | } else { 28 | window.Preload.store.set(key, value) 29 | } 30 | } 31 | }} 32 | children={children} 33 | /> 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/global/Splash.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useEffect, useState } from "react" 3 | 4 | export const Splash: React.FC<{ children?: React.ReactNode }> = ({ 5 | children, 6 | }) => { 7 | const [opacity, setOpacity] = useState(0) 8 | useEffect(() => { 9 | setOpacity(1) 10 | }, []) 11 | return ( 12 |
19 |
28 |
37 |
50 | 54 | 58 |
59 | {children && ( 60 |
70 | {children} 71 |
72 | )} 73 |
74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/programTable/HourIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { css, StyleSheet } from "aphrodite" 2 | import dayjs from "dayjs" 3 | import React from "react" 4 | import { HOUR_HEIGHT } from "../../constants/style" 5 | 6 | const style = StyleSheet.create({ 7 | hourHeight: { 8 | height: `${HOUR_HEIGHT}rem`, 9 | }, 10 | }) 11 | 12 | export const HourIndicator: React.FC<{ 13 | displayStartTimeInString: string 14 | }> = ({ displayStartTimeInString }) => { 15 | const displayStartTime = dayjs(displayStartTimeInString) 16 | return ( 17 | <> 18 | {[...Array(24).keys()].map((idx) => { 19 | return ( 20 |
26 | {displayStartTime.clone().add(idx, "hour").hour()} 27 |
28 | ) 29 | })} 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/programTable/ProgramItem.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, css } from "aphrodite" 2 | import clsx from "clsx" 3 | import dayjs from "dayjs" 4 | import React, { memo } from "react" 5 | import { GenreColors } from "../../constants/genreColor" 6 | import { Genre, SubGenre } from "../../constants/program" 7 | import { HOUR_HEIGHT } from "../../constants/style" 8 | import { Program, Service } from "../../infra/mirakurun/api" 9 | import { convertVariationSelectedClosed } from "../../utils/enclosed" 10 | import { EscapeEnclosed } from "../common/EscapeEnclosed" 11 | 12 | const { hover } = StyleSheet.create({ 13 | hover: { 14 | ":hover": { 15 | height: "auto", 16 | maxHeight: `${HOUR_HEIGHT * 24}rem`, 17 | zIndex: 50, 18 | }, 19 | }, 20 | }) 21 | 22 | export const ProgramItem: React.FC<{ 23 | program: Program 24 | service: Service 25 | displayStartTimeInString: string 26 | setSelectedProgram: (program: Program | null) => void 27 | }> = memo( 28 | ({ program, service, displayStartTimeInString, setSelectedProgram }) => { 29 | const startAt = dayjs(program.startAt) 30 | //const remain = startAt.diff(now, "minute") 31 | const displayStartTime = dayjs(displayStartTimeInString) 32 | const diffInMinutes = startAt.diff(displayStartTime, "minute") 33 | const top = (diffInMinutes / 60) * HOUR_HEIGHT 34 | // 終了時間未定の場合はdurationが1になるので表示長さを調節する 35 | const duration = 36 | program.duration === 1 37 | ? // 既に開始済みの番組 38 | startAt.isBefore(displayStartTime) 39 | ? displayStartTime.clone().add(1, "hour").diff(startAt, "seconds") 40 | : // 未来の長さ未定番組はとりあえず1時間にする 41 | 60 * 60 42 | : program.duration / 1000 43 | const height = (duration / 3600) * HOUR_HEIGHT 44 | 45 | const firstGenre = program.genres?.[0] 46 | const lv1 = firstGenre?.lv1 47 | const genre = lv1 !== undefined && Genre[lv1] 48 | const lv2 = firstGenre?.lv2 49 | const subGenre = 50 | lv1 !== undefined && lv2 !== undefined && SubGenre[lv1][lv2] 51 | const genreColor = 52 | genre && ((subGenre && GenreColors[subGenre]) || GenreColors[genre]) 53 | 54 | const calcHeight = 0 < top ? height : height + top 55 | 56 | const style = StyleSheet.create({ 57 | button: { 58 | top: `${Math.max(top, 0)}rem`, 59 | height: `${calcHeight}rem`, 60 | minHeight: `${calcHeight}rem`, 61 | containIntrinsicSize: `10rem ${calcHeight}rem`, 62 | maxHeight: `${calcHeight}rem`, 63 | }, 64 | }) 65 | 66 | return ( 67 | 124 | ) 125 | }, 126 | (prev, next) => 127 | prev.program.id === next.program.id && 128 | prev.program.startAt === next.program.startAt && 129 | prev.program.duration === next.program.duration && 130 | prev.displayStartTimeInString === next.displayStartTimeInString 131 | ) 132 | -------------------------------------------------------------------------------- /src/components/programTable/ProgramModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@headlessui/react" 2 | import clsx from "clsx" 3 | import dayjs from "dayjs" 4 | import React, { useEffect, useRef } from "react" 5 | import { 6 | Genre, 7 | SubGenre, 8 | VideoComponentType, 9 | AudioComponentType, 10 | AudioSamplingRate, 11 | } from "../../constants/program" 12 | import { Program } from "../../infra/mirakurun/api" 13 | import { ServiceWithLogoData } from "../../types/mirakurun" 14 | import { AutoLinkedText } from "../common/AutoLinkedText" 15 | import { EscapeEnclosed } from "../common/EscapeEnclosed" 16 | 17 | export const ProgramModal = ({ 18 | program, 19 | service, 20 | setSelectedProgram, 21 | }: { 22 | program: Program & { _pf?: boolean } 23 | service: ServiceWithLogoData 24 | setSelectedProgram: (program: Program | null) => void 25 | }) => { 26 | const genres = 27 | program.genres?.map((genre) => { 28 | const mainGenre = genre.lv1 !== undefined && Genre[genre.lv1] 29 | const subGenre = 30 | genre.lv1 !== undefined && 31 | genre.lv2 !== undefined && 32 | SubGenre[genre.lv1][genre.lv2] 33 | return [mainGenre, subGenre].filter((s) => s).join(" / ") 34 | }) || [] 35 | const ref = useRef(null) 36 | useEffect(() => { 37 | ref.current?.scrollTo({ top: 0 }) 38 | }, [program]) 39 | 40 | return ( 41 |
57 |
58 | 62 | 63 | 64 |

65 | {`${service.remoteControlKeyId || service.serviceId} ${service.name}`} 66 |
67 | {`${dayjs(program.startAt).format("HH:mm")}〜${ 68 | program.duration !== 1 69 | ? `${dayjs(program.startAt + program.duration).format( 70 | "HH:mm" 71 | )} (${Math.floor(program.duration / 1000 / 60)}分間)` 72 | : "" 73 | }`} 74 |

75 |

76 | {genres.map((genre) => ( 77 | 78 | {genre} 79 | 80 | ))} 81 |

82 | 85 | 89 | 90 | 91 |
92 | {Object.entries(program.extended || {}).map(([name, desc]) => ( 93 | 94 |

102 | {name} 103 |

104 |

112 | {desc} 113 |

114 |
115 | ))} 116 |
117 |
118 | {program._pf &&

EIT[p/f] による更新

} 119 | {program.video?.componentType !== undefined && ( 120 |

{VideoComponentType[program.video?.componentType]}

121 | )} 122 | {program.audios?.[0].componentType !== undefined && ( 123 |

{AudioComponentType[program.audios?.[0].componentType]}

124 | )} 125 | {program.audios?.[0].samplingRate !== undefined && ( 126 |

{AudioSamplingRate[program.audios?.[0].samplingRate ?? 0]}

127 | )} 128 |

{program.isFree ? "無料放送" : "有料放送"}

129 |
130 |
131 | 132 | 145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /src/components/programTable/ServiceRoll.tsx: -------------------------------------------------------------------------------- 1 | import { css, StyleSheet } from "aphrodite" 2 | import clsx from "clsx" 3 | import React from "react" 4 | import { HOUR_HEIGHT } from "../../constants/style" 5 | import { Service, Program } from "../../infra/mirakurun/api" 6 | import { ProgramItem } from "./ProgramItem" 7 | 8 | const style = StyleSheet.create({ 9 | containIntrinsicSize: { 10 | containIntrinsicSize: `10rem ${HOUR_HEIGHT * 24}rem`, 11 | height: `${HOUR_HEIGHT * 24}rem`, 12 | }, 13 | }) 14 | 15 | export const ServiceRoll: React.FC<{ 16 | service: Service 17 | programs: Program[] 18 | displayStartTimeInString: string 19 | setSelectedProgram: (program: Program | null) => void 20 | }> = ({ service, programs, displayStartTimeInString, setSelectedProgram }) => ( 21 |
31 | {programs.map((program) => ( 32 | 39 | ))} 40 |
41 | ) 42 | -------------------------------------------------------------------------------- /src/components/programTable/Services.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, Switch } from "@headlessui/react" 2 | import clsx from "clsx" 3 | import React, { useState } from "react" 4 | import { ROUTES } from "../../constants/routes" 5 | import { Service } from "../../infra/mirakurun/api" 6 | import { ServiceWithLogoData } from "../../types/mirakurun" 7 | 8 | export const ScrollServices: React.FC<{ 9 | services: ServiceWithLogoData[] 10 | setService: (service: Service) => void 11 | }> = ({ services, setService }) => { 12 | const [isOpenInNewWindow, setIsOpenInNewWindow] = useState(false) 13 | return ( 14 | <> 15 | {services.map((service, idx) => ( 16 | 17 | 38 | {service.logoData && ( 39 | 48 | )} 49 | 50 | {`${service.name}`} 51 | 52 | 53 | 70 |
71 |
82 | {service.logoData && ( 83 | 87 | )} 88 |

{`${ 89 | service.remoteControlKeyId ?? service.serviceId 90 | } ${service.name}`}

91 |
92 |
93 | 94 |
95 | boolean)) => 98 | setIsOpenInNewWindow(e) 99 | } 100 | className={`${ 101 | isOpenInNewWindow ? "bg-blue-600" : "bg-gray-300" 102 | } relative inline-flex items-center h-6 rounded-full w-11 text-sm`} 103 | > 104 | 109 | 110 | 111 | 新しいウィンドウで開く 112 | 113 |
114 |
115 | 141 |
142 |
143 | ))} 144 | 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /src/components/programTable/WeekdaySelector.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import dayjs from "dayjs" 3 | import React from "react" 4 | 5 | export const WeekdaySelector: React.FC<{ 6 | now: dayjs.Dayjs 7 | add: number 8 | setAdd: React.Dispatch> 9 | }> = ({ now, add, setAdd }) => ( 10 | <> 11 | {[...Array(7).keys()].map((i) => { 12 | const date = now.clone().add(i, "day") 13 | const weekday = date.format("dd") 14 | const color = 15 | weekday === "日" 16 | ? "text-red-400" 17 | : weekday === "土" 18 | ? "text-blue-400" 19 | : "" 20 | return ( 21 | 42 | ) 43 | })} 44 | 45 | ) 46 | -------------------------------------------------------------------------------- /src/components/settings/Mirakurun.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useState } from "react" 3 | import { useRecoilState } from "recoil" 4 | import { mirakurunSetting, mirakurunUrlHistory } from "../../atoms/settings" 5 | 6 | export const MirakurunSettingForm: React.FC<{}> = () => { 7 | const [mirakurun, setMirakurun] = useRecoilState(mirakurunSetting) 8 | const [url, setUrl] = useState(mirakurun.baseUrl) 9 | const [isEnableServiceTypeFilter, setIsEnableServiceTypeFilter] = useState( 10 | mirakurun.isEnableServiceTypeFilter 11 | ) 12 | const [urlHistory, setUrlHistory] = useRecoilState(mirakurunUrlHistory) 13 | return ( 14 |
{ 17 | e.preventDefault() 18 | if (url) { 19 | setUrlHistory((prev) => 20 | prev.find((_url) => _url === url) 21 | ? prev 22 | : [url, ...(10 < prev.length ? [...prev].slice(0, 10) : prev)] 23 | ) 24 | } 25 | setMirakurun((prev) => { 26 | if (prev.baseUrl !== url && prev.baseUrl) { 27 | window.Preload.public.epgManager.unregister(prev.baseUrl) 28 | } 29 | return { 30 | baseUrl: url || undefined, 31 | isEnableServiceTypeFilter, 32 | } 33 | }) 34 | }} 35 | > 36 | 59 | 68 | 82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/settings/Plugins.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React from "react" 3 | import { useRecoilState } from "recoil" 4 | import { globalDisabledPluginFileNamesAtom } from "../../atoms/global" 5 | 6 | export const CoiledPluginsSetting: React.FC<{}> = () => { 7 | const [disabledFileNames, setDisabledFileNames] = useRecoilState( 8 | globalDisabledPluginFileNamesAtom 9 | ) 10 | 11 | return ( 12 |
13 |

プラグイン

14 |
    15 | {window.plugins?.map((plugin) => ( 16 |
  • 28 | { 41 | let copied = structuredClone(disabledFileNames) 42 | if (disabledFileNames.includes(plugin.fileName)) { 43 | copied = copied.filter( 44 | (fileName) => fileName !== plugin.fileName 45 | ) 46 | } else { 47 | copied.push(plugin.fileName) 48 | } 49 | setDisabledFileNames(copied) 50 | }} 51 | title={`${plugin.name}を切り替える`} 52 | /> 53 |
    54 |

    55 | {plugin.name} {plugin.version} 56 | {plugin.authorUrl ? ( 57 | 62 | {plugin.author} 63 | 64 | ) : ( 65 | 66 | {plugin.author} 67 | 68 | )} 69 |

    70 | 71 | {plugin.id} 72 | 73 |

    {plugin.description}

    74 | {plugin.url && ( 75 | 80 | リンク 81 | 82 | )} 83 |
    84 |
  • 85 | ))} 86 |
87 |

無効化されているプラグイン

88 |
    89 | {window.disabledPluginFileNames?.map((plugin) => ( 90 |
  • 102 | { 116 | let copied = structuredClone(disabledFileNames) 117 | if (disabledFileNames.includes(plugin)) { 118 | copied = copied.filter((fileName) => fileName !== plugin) 119 | } else { 120 | copied.push(plugin) 121 | } 122 | setDisabledFileNames(copied) 123 | }} 124 | title={`${plugin}を切り替える`} 125 | /> 126 |
    127 |

    {plugin}

    128 |
    129 |
  • 130 | ))} 131 |
132 |

133 | チェックを入れたプラグインは次回再起動時に読み込まれます 134 |

135 |
136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /src/components/settings/general/Controller.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useState } from "react" 3 | import { useDebounce } from "react-use" 4 | import { ControllerSetting } from "../../../types/setting" 5 | 6 | export const ControllerSettingForm: React.FC<{ 7 | controllerSetting: ControllerSetting 8 | setControllerSetting: React.Dispatch> 9 | }> = ({ controllerSetting, setControllerSetting }) => { 10 | const [min, setMin] = useState(controllerSetting.volumeRange[0]) 11 | const [max, setMax] = useState(controllerSetting.volumeRange[1]) 12 | const [isVolumeWheelDisabled, setIsVolumeWheelDisabled] = useState( 13 | controllerSetting.isVolumeWheelDisabled 14 | ) 15 | useDebounce( 16 | () => { 17 | setControllerSetting({ 18 | volumeRange: [min, max], 19 | isVolumeWheelDisabled, 20 | }) 21 | }, 22 | 100, 23 | [min, max, isVolumeWheelDisabled] 24 | ) 25 | return ( 26 |
27 |

操作関連設定

28 | 77 | 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/components/settings/general/Screenshot.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useState } from "react" 3 | import { File } from "react-feather" 4 | import { useDebounce } from "react-use" 5 | import { ScreenshotSetting } from "../../../types/setting" 6 | 7 | export const ScreenshotSettingForm: React.FC<{ 8 | screenshotSetting: ScreenshotSetting 9 | setScreenshotSetting: React.Dispatch> 10 | }> = ({ screenshotSetting, setScreenshotSetting }) => { 11 | const [saveAsAFile, setSaveAsAFile] = useState(screenshotSetting.saveAsAFile) 12 | const [includeSubtitle, setIncludeSubtitle] = useState( 13 | screenshotSetting.includeSubtitle 14 | ) 15 | const [basePath, setBasePath] = useState(screenshotSetting.basePath) 16 | const [keepQuality, setKeepQuality] = useState(screenshotSetting.keepQuality) 17 | useDebounce( 18 | () => { 19 | setScreenshotSetting({ 20 | saveAsAFile, 21 | basePath, 22 | includeSubtitle, 23 | keepQuality, 24 | }) 25 | }, 26 | 100, 27 | [saveAsAFile, basePath, includeSubtitle, keepQuality] 28 | ) 29 | return ( 30 |
31 |

スクリーンショットの設定

32 | 41 | 50 | 59 | 111 |
112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /src/components/settings/general/Subtitle.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useEffect, useState } from "react" 3 | import { useRecoilValue } from "recoil" 4 | import { globalFontsAtom } from "../../../atoms/global" 5 | import { SUBTITLE_DEFAULT_FONT } from "../../../constants/font" 6 | import { SubtitleSetting } from "../../../types/setting" 7 | 8 | export const SubtitleSettingForm: React.FC<{ 9 | subtitleSetting: SubtitleSetting 10 | setSubtitleSetting: React.Dispatch> 11 | }> = ({ subtitleSetting, setSubtitleSetting }) => { 12 | const [font, setFont] = useState( 13 | subtitleSetting.font || SUBTITLE_DEFAULT_FONT 14 | ) 15 | const fonts = useRecoilValue(globalFontsAtom) 16 | useEffect(() => { 17 | setSubtitleSetting({ 18 | font, 19 | }) 20 | }, [font]) 21 | return ( 22 |
23 |

字幕設定

24 | 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/settings/general/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useState } from "react" 3 | import { useRecoilState } from "recoil" 4 | import { 5 | controllerSetting, 6 | experimentalSetting, 7 | screenshotSetting, 8 | subtitleSetting, 9 | } from "../../../atoms/settings" 10 | import { ControllerSettingForm } from "./Controller" 11 | import { ExperimentalSettingForm } from "./Experimental" 12 | import { ScreenshotSettingForm } from "./Screenshot" 13 | import { SubtitleSettingForm } from "./Subtitle" 14 | 15 | export const CoiledGeneralSetting: React.FC<{}> = () => { 16 | const [coiledControllerSetting, setCoiledControllerSetting] = 17 | useRecoilState(controllerSetting) 18 | const [controller, setController] = useState(coiledControllerSetting) 19 | const [coiledSubtitle, setCoiledSubtitle] = useRecoilState(subtitleSetting) 20 | const [subtitle, setSubtitle] = useState(coiledSubtitle) 21 | const [coiledScreenshotSetting, setCoiledScreenshotSetting] = 22 | useRecoilState(screenshotSetting) 23 | const [screenshot, setScreenshot] = useState(coiledScreenshotSetting) 24 | const [coiledExperimentalSetting, setCoiledExperimentalSetting] = 25 | useRecoilState(experimentalSetting) 26 | const [experimental, setExperimental] = useState(coiledExperimentalSetting) 27 | return ( 28 |
{ 31 | e.preventDefault() 32 | setCoiledControllerSetting(controller) 33 | setCoiledSubtitle(subtitle) 34 | setCoiledScreenshotSetting(screenshot) 35 | setCoiledExperimentalSetting((prev) => { 36 | if ( 37 | prev.globalScreenshotAccelerator !== 38 | experimental.globalScreenshotAccelerator 39 | ) { 40 | window.Preload.updateGlobalScreenshotAccelerator( 41 | experimental.globalScreenshotAccelerator || false 42 | ).then((isOk) => { 43 | if (isOk) { 44 | window.Preload.public.showNotification({ 45 | title: experimental.globalScreenshotAccelerator 46 | ? `グローバルスクリーンショットキーの設定に${ 47 | isOk ? "成功" : "失敗" 48 | }しました` 49 | : "グローバルスクリーンショットキーの設定を解除しました", 50 | body: isOk 51 | ? experimental.globalScreenshotAccelerator 52 | ? `新しいキーは「${experimental.globalScreenshotAccelerator}」です` 53 | : undefined 54 | : `キーとして${experimental.globalScreenshotAccelerator}は使用できません`, 55 | }) 56 | } else { 57 | } 58 | }) 59 | } 60 | return experimental 61 | }) 62 | }} 63 | > 64 |
65 | 69 | 73 | 77 | 81 |
82 | 95 |
96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /src/constants/enclosed.ts: -------------------------------------------------------------------------------- 1 | import type { StyleDeclaration } from "aphrodite" 2 | 3 | // https://github.com/l3tnun/EPGStation/blob/7949ffe4b4e3b79c896181e0f95526409818330f/src/util/StrUtil.ts#L10 4 | export const ENCLOSED_CHARACTERS_TABLE: { [key: string]: string } = { 5 | "\u{1f14a}": "HV", 6 | "\u{1f13f}": "P", 7 | "\u{1f14c}": "SD", 8 | "\u{1f146}": "W", 9 | "\u{1f14b}": "MV", 10 | "\u{1f210}": "手", 11 | "\u{1f211}": "字", 12 | "\u{1f212}": "双", 13 | "\u{1f213}": "デ", 14 | "\u{1f142}": "S", 15 | "\u{1f214}": "二", 16 | "\u{1f215}": "多", 17 | "\u{1f216}": "解", 18 | "\u{1f14d}": "SS", 19 | "\u{1f131}": "B", 20 | "\u{1f13d}": "N", 21 | "\u{1f217}": "天", 22 | "\u{1f218}": "交", 23 | "\u{1f219}": "映", 24 | "\u{1f21a}": "無", 25 | "\u{1f21b}": "料", 26 | "\u{26bf}": "鍵", 27 | "\u{1f21c}": "前", 28 | "\u{1f21d}": "後", 29 | "\u{1f21e}": "再", 30 | "\u{1f21f}": "新", 31 | "\u{1f220}": "初", 32 | "\u{1f221}": "終", 33 | "\u{1f222}": "生", 34 | "\u{1f223}": "販", 35 | "\u{1f224}": "声", 36 | "\u{1f225}": "吹", 37 | "\u{1f14e}": "PPV", 38 | "\u{3299}": "秘", 39 | "\u{1f200}": "ほか", 40 | } 41 | 42 | export const ENCLOSED_CHARACTERS = Object.keys(ENCLOSED_CHARACTERS_TABLE) 43 | 44 | export const ENCLOSED_STYLES: StyleDeclaration = Object.fromEntries( 45 | Object.entries(ENCLOSED_CHARACTERS_TABLE).map(([k, v]) => [ 46 | `reverse_${k.codePointAt(0)}`, 47 | { 48 | ":before": { 49 | content: `"${v}"`, 50 | border: "0.1rem solid currentColor", 51 | /*fontSize: ".8em", 52 | width: "1.25rem", 53 | height: "1.25rem", 54 | display: "inline-block", 55 | justifyContent: "center", 56 | alignContent: "center", 57 | textAlign: "center",*/ 58 | }, 59 | }, 60 | ]) 61 | ) 62 | -------------------------------------------------------------------------------- /src/constants/font.ts: -------------------------------------------------------------------------------- 1 | export const SUBTITLE_DEFAULT_FONT = `"Hiragino Maru Gothic ProN", "Rounded M+ 1m for ARIB"` 2 | -------------------------------------------------------------------------------- /src/constants/genreColor.ts: -------------------------------------------------------------------------------- 1 | export const GenreColors = { 2 | ドラマ: "bg-indigo-200", 3 | "アニメ・特撮": "bg-pink-200", 4 | 映画: "bg-yellow-100", 5 | バラエティ: "bg-indigo-100", 6 | "ドキュメンタリー・教養": "bg-yellow-100", 7 | "ニュース・報道": "bg-blue-100", 8 | "趣味・教育": "bg-indigo-200", 9 | スポーツ: "bg-purple-100", 10 | "情報・ワイドショー": "bg-yellow-100", 11 | "劇場・公演": "bg-yellow-100", 12 | 音楽: "bg-green-100", 13 | } as { [key: string]: string } 14 | -------------------------------------------------------------------------------- /src/constants/ipc.ts: -------------------------------------------------------------------------------- 1 | export const REQUEST_INITIAL_DATA = "request-initial-data" 2 | export const RECOIL_STATE_UPDATE = "recoil-state-update" 3 | export const REUQEST_OPEN_WINDOW = "request-open-window" 4 | export const EPG_MANAGER = { 5 | REGISTER: "epg-manager-register", 6 | UNREGISTER: "epg-manager-unregister", 7 | QUERY: "epg-manager-query", 8 | } 9 | export const SET_WINDOW_TITLE = "set-window-title" 10 | export const SET_WINDOW_ASPECT = "set-window-aspect" 11 | export const SET_WINDOW_POSITION = "set-window-position" 12 | export const SHOW_WINDOW = "show-window" 13 | export const REQUEST_APP_PATH = "request-app-path" 14 | export const REQUEST_CURSOR_SCREEN_POINT = "request-cursor-screen-point" 15 | export const TOGGLE_FULL_SCREEN = "toggle-full-screen" 16 | export const EXIT_FULL_SCREEN = "exit-full-screen" 17 | export const SHOW_NOTIFICATION = "show-notification" 18 | export const REQUEST_WINDOW_SCREENSHOT = "request-window-screenshot" 19 | export const TOGGLE_ALWAYS_ON_TOP = "toggle-always-on-top" 20 | export const REQUEST_CONTENT_BOUNDS = "request-content-bounds" 21 | export const SET_WINDOW_BUTTON_VISIBILITY = "set-window-button-visibility" 22 | export const SET_WINDOW_CONTENT_BOUNDS = "set-window-content-bounds" 23 | export const REQUEST_SHELL_OPEN_PATH = "request-shell-open-path" 24 | export const REQUEST_DIALOG = "request-dialog" 25 | export const REQUEST_WRITE_IMAGE_TO_CLIPBOARD = 26 | "request-write-image-to-clipboard" 27 | export const ON_WINDOW_MOVED = "on-window-moved" 28 | export const REQUEST_CONFIRM_DIALOG = "request-confirm-dialog" 29 | export const REQUEST_SCREENSHOT_BASE_PATH = "request-screenshot-base-path" 30 | export const ON_SCREENSHOT_REQUEST = "on-screenshot-request" 31 | export const UPDATE_GLOBAL_SCREENSHOT_ACCELERATOR = 32 | "update-global-screenshot-accelerator" 33 | -------------------------------------------------------------------------------- /src/constants/recoil.ts: -------------------------------------------------------------------------------- 1 | export const RECOIL_SYNC_SHARED_KEY = "shared" 2 | 3 | export const RECOIL_SYNC_STORED_KEY = "stored" 4 | -------------------------------------------------------------------------------- /src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const ROUTES = { 2 | ContentPlayer: "ContentPlayer", 3 | Settings: "Settings", 4 | ProgramTable: "ProgramTable", 5 | } as const 6 | -------------------------------------------------------------------------------- /src/constants/style.ts: -------------------------------------------------------------------------------- 1 | export const HOUR_HEIGHT = 12 2 | -------------------------------------------------------------------------------- /src/hooks/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import ja from "dayjs/locale/ja" 3 | import { useEffect, useState } from "react" 4 | dayjs.locale(ja) 5 | 6 | export const useNow = () => { 7 | const [now, setNow] = useState(dayjs()) 8 | 9 | useEffect(() => { 10 | const update = () => { 11 | setNow(dayjs()) 12 | timeout = setTimeout(update, (60 - new Date().getSeconds()) * 1000) 13 | } 14 | let timeout = setTimeout(() => { 15 | update() 16 | }, (60 - new Date().getSeconds()) * 1000) 17 | return () => { 18 | timeout && clearTimeout(timeout) 19 | } 20 | }, []) 21 | 22 | return now 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/mirakurun.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from "recoil" 2 | import { mirakurunSetting } from "../atoms/settings" 3 | import { MirakurunAPI } from "../infra/mirakurun" 4 | 5 | export const useMirakurun = () => { 6 | const mirakurun = useRecoilValue(mirakurunSetting) 7 | return new MirakurunAPI(mirakurun) 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/ref.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react" 2 | 3 | export const useRefFromState = (i: T) => { 4 | const ref = useRef(i) 5 | useEffect(() => { 6 | ref.current = i 7 | }, [i]) 8 | 9 | return ref 10 | } 11 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | user-select: none; 7 | } 8 | 9 | .content-visibility-auto { 10 | content-visibility: auto; 11 | } 12 | 13 | .contain-paint { 14 | contain: paint; 15 | } 16 | 17 | #react-refresh-overlay, 18 | .app-region-drag { 19 | -webkit-app-region: drag; 20 | } 21 | 22 | .app-region-no-drag { 23 | -webkit-app-region: no-drag; 24 | } 25 | 26 | @font-face { 27 | font-family: "AribEnclosedFontFace"; 28 | src: url("../assets/rounded-mplus-1m-arib.woff2") format("woff2"); 29 | font-weight: normal; 30 | font-style: normal; 31 | unicode-range: U+1f13f-225, U+26bf, U+3299; 32 | } 33 | 34 | .AribEnclosedFontFace { 35 | font-family: "Windows TV MaruGothic", "ヒラギノTV丸ゴS", AribEnclosedFontFace, 36 | ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", 37 | Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, 38 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 39 | } 40 | 41 | @font-face { 42 | font-family: "Rounded M+ 1m for ARIB"; 43 | src: url("../assets/rounded-mplus-1m-arib.woff2") format("woff2"); 44 | font-weight: normal; 45 | font-style: normal; 46 | } 47 | 48 | .programDescription { 49 | a { 50 | @apply text-blue-400; 51 | } 52 | 53 | * { 54 | user-select: text; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/index.web.tsx: -------------------------------------------------------------------------------- 1 | import refine from "@recoiljs/refine" 2 | import React, { useEffect, useState } from "react" 3 | import { createRoot } from "react-dom/client" 4 | import Recoil from "recoil" 5 | import RecoilSync from "recoil-sync" 6 | import { PluginLoader } from "./Plugin" 7 | import { Splash } from "./components/global/Splash" 8 | import { InitialData } from "./types/struct" 9 | import "./index.scss" 10 | 11 | global.React = React 12 | global.Recoil = Recoil 13 | const compatibilityRecoilSync = { ...RecoilSync, refine } 14 | global.RecoilSync = compatibilityRecoilSync 15 | 16 | if (process.env.NODE_ENV === "development") { 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | const whyDidYouRender = require("@welldone-software/why-did-you-render") 19 | whyDidYouRender(React, { 20 | trackAllPureComponents: true, 21 | }) 22 | } 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 25 | const root = createRoot(document.getElementById("app")!) 26 | 27 | const WebRoot: React.FC<{}> = () => { 28 | const [unmounted, setUnmounted] = useState(false) 29 | useEffect(() => { 30 | const app = document.getElementById("app") 31 | if (!app) return 32 | const beforeUnload = () => { 33 | setUnmounted(true) 34 | root.unmount() 35 | } 36 | window.addEventListener("beforeunload", beforeUnload) 37 | return () => { 38 | window.removeEventListener("beforeunload", beforeUnload) 39 | } 40 | }, []) 41 | const [initialData, setInitialData] = useState(null) 42 | useEffect(() => { 43 | window.Preload.requestInitialData().then((data) => { 44 | window.id = data.windowId 45 | setInitialData(data) 46 | }) 47 | }, []) 48 | if (unmounted || initialData === null) { 49 | return 50 | } 51 | return 52 | } 53 | 54 | root.render() 55 | -------------------------------------------------------------------------------- /src/infra/mirakurun/README.md: -------------------------------------------------------------------------------- 1 | # MirakTest/src/infra/mirakurun 2 | 3 | Swagger 定義の自動生成コードをもとにした Mirakurun の API クライアントです。 4 | 5 | ## 生成方法 6 | 7 | 1. Mirakurun の Swagger 定義を入手する 8 | - `/api/docs` から入手できます 9 | 1. `yarn dlx @openapitools/openapi-generator-cli generate -i -g typescript-axios -o /tmp/mirak-axios` 10 | 1. `cp /tmp/mirak-axios/api.ts src/infra/mirakurun/api.ts` 11 | 1. `yarn format:prettier` 12 | 1. 差分を調べて `index.ts` に反映します 13 | -------------------------------------------------------------------------------- /src/infra/mirakurun/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Mirakurun 5 | * DVR Tuner Server for Japanese TV. 6 | * 7 | * The version of the OpenAPI document: 3.9.0-rc.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | import { Configuration } from "./configuration" 16 | // Some imports not used depending on template conditions 17 | // @ts-ignore 18 | import globalAxios, { 19 | AxiosPromise, 20 | AxiosInstance, 21 | AxiosRequestConfig, 22 | } from "axios" 23 | 24 | export const BASE_PATH = "http://192.168.0.15:40772/api".replace(/\/+$/, "") 25 | 26 | /** 27 | * 28 | * @export 29 | */ 30 | export const COLLECTION_FORMATS = { 31 | csv: ",", 32 | ssv: " ", 33 | tsv: "\t", 34 | pipes: "|", 35 | } 36 | 37 | /** 38 | * 39 | * @export 40 | * @interface RequestArgs 41 | */ 42 | export interface RequestArgs { 43 | url: string 44 | options: AxiosRequestConfig 45 | } 46 | 47 | /** 48 | * 49 | * @export 50 | * @class BaseAPI 51 | */ 52 | export class BaseAPI { 53 | protected configuration: Configuration | undefined 54 | 55 | constructor( 56 | configuration?: Configuration, 57 | protected basePath: string = BASE_PATH, 58 | protected axios: AxiosInstance = globalAxios 59 | ) { 60 | if (configuration) { 61 | this.configuration = configuration 62 | this.basePath = configuration.basePath || this.basePath 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * 69 | * @export 70 | * @class RequiredError 71 | * @extends {Error} 72 | */ 73 | export class RequiredError extends Error { 74 | name: "RequiredError" = "RequiredError" 75 | constructor(public field: string, msg?: string) { 76 | super(msg) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/infra/mirakurun/common.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Mirakurun 5 | * DVR Tuner Server for Japanese TV. 6 | * 7 | * The version of the OpenAPI document: 3.9.0-rc.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | import { Configuration } from "./configuration" 16 | import { RequiredError, RequestArgs } from "./base" 17 | import { AxiosInstance, AxiosResponse } from "axios" 18 | 19 | /** 20 | * 21 | * @export 22 | */ 23 | export const DUMMY_BASE_URL = "https://example.com" 24 | 25 | /** 26 | * 27 | * @throws {RequiredError} 28 | * @export 29 | */ 30 | export const assertParamExists = function ( 31 | functionName: string, 32 | paramName: string, 33 | paramValue: unknown 34 | ) { 35 | if (paramValue === null || paramValue === undefined) { 36 | throw new RequiredError( 37 | paramName, 38 | `Required parameter ${paramName} was null or undefined when calling ${functionName}.` 39 | ) 40 | } 41 | } 42 | 43 | /** 44 | * 45 | * @export 46 | */ 47 | export const setApiKeyToObject = async function ( 48 | object: any, 49 | keyParamName: string, 50 | configuration?: Configuration 51 | ) { 52 | if (configuration && configuration.apiKey) { 53 | const localVarApiKeyValue = 54 | typeof configuration.apiKey === "function" 55 | ? await configuration.apiKey(keyParamName) 56 | : await configuration.apiKey 57 | object[keyParamName] = localVarApiKeyValue 58 | } 59 | } 60 | 61 | /** 62 | * 63 | * @export 64 | */ 65 | export const setBasicAuthToObject = function ( 66 | object: any, 67 | configuration?: Configuration 68 | ) { 69 | if (configuration && (configuration.username || configuration.password)) { 70 | object["auth"] = { 71 | username: configuration.username, 72 | password: configuration.password, 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * 79 | * @export 80 | */ 81 | export const setBearerAuthToObject = async function ( 82 | object: any, 83 | configuration?: Configuration 84 | ) { 85 | if (configuration && configuration.accessToken) { 86 | const accessToken = 87 | typeof configuration.accessToken === "function" 88 | ? await configuration.accessToken() 89 | : await configuration.accessToken 90 | object["Authorization"] = "Bearer " + accessToken 91 | } 92 | } 93 | 94 | /** 95 | * 96 | * @export 97 | */ 98 | export const setOAuthToObject = async function ( 99 | object: any, 100 | name: string, 101 | scopes: string[], 102 | configuration?: Configuration 103 | ) { 104 | if (configuration && configuration.accessToken) { 105 | const localVarAccessTokenValue = 106 | typeof configuration.accessToken === "function" 107 | ? await configuration.accessToken(name, scopes) 108 | : await configuration.accessToken 109 | object["Authorization"] = "Bearer " + localVarAccessTokenValue 110 | } 111 | } 112 | 113 | /** 114 | * 115 | * @export 116 | */ 117 | export const setSearchParams = function (url: URL, ...objects: any[]) { 118 | const searchParams = new URLSearchParams(url.search) 119 | for (const object of objects) { 120 | for (const key in object) { 121 | if (Array.isArray(object[key])) { 122 | searchParams.delete(key) 123 | for (const item of object[key]) { 124 | searchParams.append(key, item) 125 | } 126 | } else { 127 | searchParams.set(key, object[key]) 128 | } 129 | } 130 | } 131 | url.search = searchParams.toString() 132 | } 133 | 134 | /** 135 | * 136 | * @export 137 | */ 138 | export const serializeDataIfNeeded = function ( 139 | value: any, 140 | requestOptions: any, 141 | configuration?: Configuration 142 | ) { 143 | const nonString = typeof value !== "string" 144 | const needsSerialization = 145 | nonString && configuration && configuration.isJsonMime 146 | ? configuration.isJsonMime(requestOptions.headers["Content-Type"]) 147 | : nonString 148 | return needsSerialization 149 | ? JSON.stringify(value !== undefined ? value : {}) 150 | : value || "" 151 | } 152 | 153 | /** 154 | * 155 | * @export 156 | */ 157 | export const toPathString = function (url: URL) { 158 | return url.pathname + url.search + url.hash 159 | } 160 | 161 | /** 162 | * 163 | * @export 164 | */ 165 | export const createRequestFunction = function ( 166 | axiosArgs: RequestArgs, 167 | globalAxios: AxiosInstance, 168 | BASE_PATH: string, 169 | configuration?: Configuration 170 | ) { 171 | return >( 172 | axios: AxiosInstance = globalAxios, 173 | basePath: string = BASE_PATH 174 | ) => { 175 | const axiosRequestArgs = { 176 | ...axiosArgs.options, 177 | url: (configuration?.basePath || basePath) + axiosArgs.url, 178 | } 179 | // openapi 認証部書き換え 180 | const url = new URL(configuration?.basePath || basePath) 181 | const headers: Record = {} 182 | if (url.username || url.password) { 183 | headers["Authorization"] = `Basic ${btoa( 184 | [url.username, url.password].filter((s) => s).join(":") 185 | )}` 186 | } 187 | if (configuration?.userAgent) { 188 | headers["user-agent"] = configuration.userAgent 189 | } 190 | axiosRequestArgs.headers = Object.assign( 191 | axiosRequestArgs.headers || {}, 192 | headers 193 | ) 194 | return axios.request(axiosRequestArgs) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/infra/mirakurun/configuration.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Mirakurun 5 | * DVR Tuner Server for Japanese TV. 6 | * 7 | * The version of the OpenAPI document: 3.9.0-rc.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | export interface ConfigurationParameters { 16 | apiKey?: 17 | | string 18 | | Promise 19 | | ((name: string) => string) 20 | | ((name: string) => Promise) 21 | username?: string 22 | password?: string 23 | accessToken?: 24 | | string 25 | | Promise 26 | | ((name?: string, scopes?: string[]) => string) 27 | | ((name?: string, scopes?: string[]) => Promise) 28 | basePath?: string 29 | baseOptions?: any 30 | formDataCtor?: new () => any 31 | // openapi UserAgent 32 | userAgent?: string 33 | } 34 | 35 | export class Configuration { 36 | /** 37 | * parameter for apiKey security 38 | * @param name security name 39 | * @memberof Configuration 40 | */ 41 | apiKey?: 42 | | string 43 | | Promise 44 | | ((name: string) => string) 45 | | ((name: string) => Promise) 46 | /** 47 | * parameter for basic security 48 | * 49 | * @type {string} 50 | * @memberof Configuration 51 | */ 52 | username?: string 53 | /** 54 | * parameter for basic security 55 | * 56 | * @type {string} 57 | * @memberof Configuration 58 | */ 59 | password?: string 60 | /** 61 | * parameter for oauth2 security 62 | * @param name security name 63 | * @param scopes oauth2 scope 64 | * @memberof Configuration 65 | */ 66 | accessToken?: 67 | | string 68 | | Promise 69 | | ((name?: string, scopes?: string[]) => string) 70 | | ((name?: string, scopes?: string[]) => Promise) 71 | /** 72 | * override base path 73 | * 74 | * @type {string} 75 | * @memberof Configuration 76 | */ 77 | basePath?: string 78 | /** 79 | * base options for axios calls 80 | * 81 | * @type {any} 82 | * @memberof Configuration 83 | */ 84 | baseOptions?: any 85 | /** 86 | * The FormData constructor that will be used to create multipart form data 87 | * requests. You can inject this here so that execution environments that 88 | * do not support the FormData class can still run the generated client. 89 | * 90 | * @type {new () => FormData} 91 | */ 92 | formDataCtor?: new () => any 93 | userAgent?: string 94 | 95 | constructor(param: ConfigurationParameters = {}) { 96 | this.apiKey = param.apiKey 97 | this.username = param.username 98 | this.password = param.password 99 | this.accessToken = param.accessToken 100 | this.basePath = param.basePath 101 | this.baseOptions = param.baseOptions 102 | this.formDataCtor = param.formDataCtor 103 | this.userAgent = param.userAgent 104 | } 105 | 106 | /** 107 | * Check if the given MIME is a JSON MIME. 108 | * JSON MIME examples: 109 | * application/json 110 | * application/json; charset=UTF8 111 | * APPLICATION/JSON 112 | * application/vnd.company+json 113 | * @param mime - MIME (Multipurpose Internet Mail Extensions) 114 | * @return True if the given MIME is JSON, false otherwise. 115 | */ 116 | public isJsonMime(mime: string): boolean { 117 | const jsonMime: RegExp = new RegExp( 118 | "^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", 119 | "i" 120 | ) 121 | return ( 122 | mime !== null && 123 | (jsonMime.test(mime) || 124 | mime.toLowerCase() === "application/json-patch+json") 125 | ) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/infra/mirakurun/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * Mirakurun 5 | * DVR Tuner Server for Japanese TV. 6 | * 7 | * The version of the OpenAPI document: 3.9.0-rc.0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | import { MirakurunSetting } from "../../types/setting" 16 | import { 17 | ChannelsApi, 18 | ConfigApi, 19 | EventsApi, 20 | LogApi, 21 | MiscApi, 22 | IptvApi, 23 | ProgramsApi, 24 | ServicesApi, 25 | StatusApi, 26 | StreamApi, 27 | TunersApi, 28 | VersionApi, 29 | } from "./api" 30 | import { Configuration } from "./configuration" 31 | 32 | export class MirakurunAPI { 33 | baseUrl: string 34 | userAgent?: string 35 | constructor({ baseUrl, userAgent }: Partial) { 36 | if (!baseUrl) throw new Error("Mirakurun baseUrl error") 37 | this.baseUrl = baseUrl 38 | if (userAgent) { 39 | this.userAgent = userAgent 40 | } 41 | } 42 | 43 | getConfigure() { 44 | return new Configuration({ 45 | basePath: this.baseUrl, 46 | userAgent: this.userAgent, 47 | }) 48 | } 49 | 50 | get channels() { 51 | return new ChannelsApi(this.getConfigure()) 52 | } 53 | 54 | get config() { 55 | return new ConfigApi(this.getConfigure()) 56 | } 57 | 58 | get events() { 59 | return new EventsApi(this.getConfigure()) 60 | } 61 | 62 | get log() { 63 | return new LogApi(this.getConfigure()) 64 | } 65 | 66 | get misc() { 67 | return new MiscApi(this.getConfigure()) 68 | } 69 | 70 | get iptv() { 71 | return new IptvApi(this.getConfigure()) 72 | } 73 | 74 | get programs() { 75 | return new ProgramsApi(this.getConfigure()) 76 | } 77 | 78 | get services() { 79 | return new ServicesApi(this.getConfigure()) 80 | } 81 | 82 | get status() { 83 | return new StatusApi(this.getConfigure()) 84 | } 85 | 86 | get stream() { 87 | return new StreamApi(this.getConfigure()) 88 | } 89 | 90 | get tuners() { 91 | return new TunersApi(this.getConfigure()) 92 | } 93 | 94 | get version() { 95 | return new VersionApi(this.getConfigure()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/afterpack.ts: -------------------------------------------------------------------------------- 1 | import child from "child_process" 2 | import fs from "fs" 3 | import path from "path" 4 | import axios from "axios" 5 | import { Arch, Target, Packager } from "electron-builder" 6 | import glob from "glob" 7 | 8 | // https://www.electron.build/configuration/configuration#afterpack 9 | interface AfterPackContext { 10 | outDir: string 11 | appOutDir: string 12 | packager: Packager 13 | electronPlatformName: string 14 | arch: Arch 15 | targets: Array 16 | } 17 | 18 | const exec = async (command: string) => { 19 | return await new Promise((res, rej) => { 20 | child.exec(command, (err, std) => { 21 | if (err) rej(err) 22 | res(std) 23 | }) 24 | }) 25 | } 26 | 27 | exports.default = async (ctx: AfterPackContext) => { 28 | console.info(`${ctx.electronPlatformName} 用の変更を適用します`) 29 | let dest = "./build" 30 | if (ctx.electronPlatformName === "darwin") { 31 | const src = path.resolve("./vlc_libs/") 32 | dest = path.resolve( 33 | `./build/mac${ 34 | ctx.arch === Arch.arm64 ? "-arm64" : "" 35 | }/MirakTest.app/Contents/Frameworks/` 36 | ) 37 | 38 | if (!fs.existsSync(src) || !fs.existsSync(dest)) { 39 | console.info("ファイルが存在しません、スキップします") 40 | return 41 | } 42 | console.info("libVLC を Contents/Frameworks にコピーします") 43 | const files = await new Promise((res, rej) => { 44 | glob(path.join(src, "*"), (err, files) => { 45 | if (err) rej(err) 46 | res(files) 47 | }) 48 | }) 49 | for (const file of files) { 50 | await exec(`cp -Ra ${file} ${dest}`) 51 | } 52 | dest = path.resolve( 53 | `./build/mac${ 54 | ctx.arch === Arch.arm64 ? "-arm64" : "" 55 | }/MirakTest.app/Contents/` 56 | ) 57 | } else if (ctx.electronPlatformName === "win32") { 58 | dest = path.resolve("./build/win-unpacked/") 59 | } 60 | 61 | console.info("libVLC の COPYRING, COPYRING.LIB をコピーします") 62 | const COPYRING = await axios.get( 63 | "https://raw.githubusercontent.com/videolan/vlc/master/COPYING", 64 | { responseType: "text" } 65 | ) 66 | await fs.promises.writeFile( 67 | path.join(dest, "./LICENSE.VLC-COPYRING.txt"), 68 | COPYRING.data 69 | ) 70 | const COPYRING_LIB = await axios.get( 71 | "https://raw.githubusercontent.com/videolan/vlc/master/COPYING.LIB", 72 | { responseType: "text" } 73 | ) 74 | await fs.promises.writeFile( 75 | path.join(dest, "./LICENSE.VLC-COPYRING.LIB.txt"), 76 | COPYRING_LIB.data 77 | ) 78 | if (ctx.electronPlatformName === "darwin" && ctx.arch === Arch.arm64) { 79 | // aarch64のみappをcodesignしなおす 80 | console.info("codesignを実行します") 81 | await exec( 82 | "codesign --force --deep -s - -i - ./build/mac-arm64/MirakTest.app" 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/constants.ts: -------------------------------------------------------------------------------- 1 | import { builtinModules } from "module" 2 | 3 | const _FORBIDDEN_MODULES = ["child_process", "worker_threads", "vm", "v8"] 4 | export const FORBIDDEN_MODULES = [ 5 | ..._FORBIDDEN_MODULES, 6 | ..._FORBIDDEN_MODULES.map((mod) => `node:${mod}`), 7 | ] 8 | const _ALLOWED_MODULES = ["buffer", "console", "process", "timers"] 9 | export const ALLOWED_MODULES = [ 10 | ..._ALLOWED_MODULES, 11 | ..._ALLOWED_MODULES.map((mod) => `node:${mod}`), 12 | ] 13 | export const BUILTIN_MODULES = [ 14 | ...builtinModules, 15 | ...builtinModules.map((mod) => `node:${mod}`), 16 | ] 17 | -------------------------------------------------------------------------------- /src/main/contextmenu.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from "electron" 2 | 3 | export const generateContentPlayerContextMenu = ( 4 | { 5 | isPlaying, 6 | toggleIsPlaying, 7 | isAlwaysOnTop, 8 | toggleIsAlwaysOnTop, 9 | openContentPlayer, 10 | openProgramTable, 11 | openSetting, 12 | }: { 13 | isPlaying: boolean | null 14 | toggleIsPlaying: () => void 15 | isAlwaysOnTop: boolean 16 | toggleIsAlwaysOnTop: () => void 17 | openContentPlayer: () => void 18 | openProgramTable: () => void 19 | openSetting: () => void 20 | }, 21 | e: Electron.Event, 22 | params: Electron.ContextMenuParams 23 | ) => { 24 | e.preventDefault() 25 | const noParams = typeof params !== "object" 26 | 27 | return (plugins: Electron.MenuItemConstructorOptions[]) => { 28 | const pluginSeparator: Electron.MenuItemConstructorOptions[] = [] 29 | if (0 < plugins.length) { 30 | pluginSeparator.push({ 31 | type: "separator", 32 | }) 33 | } 34 | 35 | const menu = [ 36 | ...(isPlaying !== null 37 | ? [ 38 | { 39 | label: "再生停止", 40 | type: "checkbox", 41 | checked: !isPlaying, 42 | click: () => toggleIsPlaying(), 43 | }, 44 | { 45 | type: "separator", 46 | }, 47 | ] 48 | : []), 49 | { 50 | label: "最前面に固定", 51 | type: "checkbox", 52 | checked: isAlwaysOnTop, 53 | click: () => toggleIsAlwaysOnTop(), 54 | }, 55 | { 56 | type: "separator", 57 | }, 58 | { 59 | label: "新しいプレイヤーを開く", 60 | click: () => openContentPlayer(), 61 | }, 62 | { 63 | label: "番組表", 64 | click: () => openProgramTable(), 65 | }, 66 | { 67 | label: "設定", 68 | click: () => openSetting(), 69 | }, 70 | { 71 | type: "separator", 72 | }, 73 | { 74 | label: "切り取り", 75 | role: "cut", 76 | visible: noParams || params.editFlags.canCut, 77 | }, 78 | { 79 | label: "コピー", 80 | role: "copy", 81 | visible: noParams || params.editFlags.canCopy, 82 | }, 83 | { 84 | label: "貼り付け", 85 | role: "paste", 86 | visible: noParams || params.editFlags.canPaste, 87 | }, 88 | { 89 | label: "削除", 90 | role: "delete", 91 | visible: noParams || params.editFlags.canDelete, 92 | }, 93 | { 94 | label: "すべて選択", 95 | role: "selectAll", 96 | visible: noParams || params.editFlags.canSelectAll, 97 | }, 98 | { 99 | type: "separator", 100 | }, 101 | ...plugins, 102 | ...pluginSeparator, 103 | { 104 | label: "ウィンドウを閉じる", 105 | role: "close", 106 | }, 107 | { 108 | label: "終了", 109 | role: "quit", 110 | }, 111 | ].filter( 112 | (item): item is Electron.MenuItemConstructorOptions => 113 | item.visible !== false 114 | ) 115 | Menu.buildFromTemplate(menu).popup() 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/main/fsUtils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import os from "os" 3 | import path from "path" 4 | 5 | const homeDir = os.homedir() 6 | export const exists = async (filePath: string) => 7 | new Promise((res) => { 8 | fs.promises 9 | .lstat(filePath) 10 | .then((stat) => res(stat.isFile() || stat.isDirectory())) 11 | .catch(() => res(false)) 12 | }) 13 | export const isChildOf = (filePath: string, parent: string) => { 14 | // https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js 15 | const relative = path.relative(parent, filePath) 16 | return relative && !relative.startsWith("..") && !path.isAbsolute(relative) 17 | } 18 | export const isChildOfHome = (filePath: string) => isChildOf(filePath, homeDir) 19 | export const isHidden = (filePath: string) => { 20 | return filePath.split(/\/|\\/).some((part) => part.startsWith(".")) 21 | } 22 | -------------------------------------------------------------------------------- /src/main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "outDir": "../../dist", 6 | "noEmit": false 7 | }, 8 | "include": ["."], 9 | "exclude": ["../../node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main/vm/init.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { 3 | InitPlugin, 4 | PluginDefineInMain, 5 | PluginInMainArgs, 6 | } from "../../types/plugin" 7 | 8 | const openedPlugins = new Map() 9 | const plugins = new Map() 10 | 11 | const setAppMenu = ( 12 | sandboxSetMenu: (m: Electron.MenuItemConstructorOptions[]) => void 13 | ) => { 14 | sandboxSetMenu( 15 | Array.from(plugins.values()) 16 | .filter( 17 | ( 18 | plugin 19 | ): plugin is PluginDefineInMain & { 20 | appMenu: Electron.MenuItemConstructorOptions 21 | } => !!plugin.appMenu 22 | ) 23 | .map((plugin) => plugin.appMenu) 24 | ) 25 | } 26 | const showContextMenu = ( 27 | sandboxSetContextMenu: (m: Electron.MenuItemConstructorOptions[]) => void 28 | ) => { 29 | sandboxSetContextMenu( 30 | Array.from(plugins.values()) 31 | .filter( 32 | ( 33 | plugin 34 | ): plugin is PluginDefineInMain & { 35 | contextMenu: Electron.MenuItemConstructorOptions 36 | } => !!plugin.contextMenu 37 | ) 38 | .map((plugin) => plugin.contextMenu) 39 | ) 40 | } 41 | const setupModule = async ( 42 | fileName: string, 43 | mod: { default: InitPlugin } | InitPlugin, 44 | setupArgment: PluginInMainArgs 45 | ) => { 46 | const load = "default" in mod ? mod.default : mod 47 | if (load.main) { 48 | const plugin = await load.main(setupArgment) 49 | console.info( 50 | `[Plugin] 読込中: ${plugin.name} (${plugin.id}, ${plugin.version})` 51 | ) 52 | openedPlugins.set(fileName, plugin) 53 | } 54 | } 55 | const setupPlugin = async (fileName: string) => { 56 | const plugin = openedPlugins.get(fileName) 57 | if (!plugin) { 58 | return 59 | } 60 | try { 61 | await plugin.setup({ 62 | plugins: Array.from(openedPlugins.values()), 63 | }) 64 | if (plugin.appMenu) { 65 | console.info( 66 | `[Plugin] ${plugin.name}(${plugin.id}) にはアプリメニューが存在します` 67 | ) 68 | } 69 | if (plugin.contextMenu) { 70 | console.info( 71 | `[Plugin] ${plugin.name}(${plugin.id}) コンテキストメニューを読み込みました` 72 | ) 73 | } 74 | plugins.set(fileName, plugin) 75 | console.info( 76 | `[Plugin] ${fileName} を ${plugin.name}(${plugin.id}) として読み込みました` 77 | ) 78 | return `${plugin.name} (${plugin.id}, ${fileName})` 79 | } catch (error) { 80 | console.error("[Plugin] setup 中にエラーが発生しました:", plugin.id, error) 81 | try { 82 | await plugin.destroy() 83 | } catch (error) { 84 | console.error( 85 | "[Plugin] destroy 中にエラーが発生しました:", 86 | plugin.id, 87 | error 88 | ) 89 | } 90 | openedPlugins.delete(fileName) 91 | plugins.delete(fileName) 92 | } 93 | } 94 | const destroyPlugin = async (fileName: string) => { 95 | const instance = plugins.get(fileName) || openedPlugins.get(fileName) 96 | if (instance) { 97 | try { 98 | await instance.destroy() 99 | } catch (error) { 100 | console.error( 101 | "[Plugin] destroy 中にエラーが発生しました:", 102 | instance.id, 103 | error 104 | ) 105 | } 106 | plugins.delete(fileName) 107 | openedPlugins.delete(fileName) 108 | console.info(`[Plugin] ${fileName} を読み込み解除しました`) 109 | } 110 | } 111 | const getPluginDisplay = (fileName: string) => { 112 | const instance = plugins.get(fileName) || openedPlugins.get(fileName) 113 | if (!instance) { 114 | return fileName 115 | } 116 | return `${instance.name} (${instance.id}, ${fileName})` 117 | } 118 | -------------------------------------------------------------------------------- /src/main/vm/setup.ts: -------------------------------------------------------------------------------- 1 | Promise.all( 2 | Array.from(openedPlugins.keys()).map(async (fileName) => { 3 | await setupPlugin(fileName) 4 | }) 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/vm/vm.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import { 3 | InitPlugin, 4 | PluginDefineInMain, 5 | PluginInMainArgs, 6 | } from "../../types/plugin" 7 | 8 | declare global { 9 | declare var setAppMenu: () => void 10 | declare var showContextMenu: () => void 11 | declare var showContextMenu: () => void 12 | declare var setupModule: ( 13 | fileName: string, 14 | mod: { default: InitPlugin } | InitPlugin, 15 | setupArgment: PluginInMainArgs 16 | ) => Promise 17 | declare var setupPlugin: (fileName: string) => Promise 18 | declare var destroyPlugin: (fileName: string) => Promise 19 | 20 | declare var openedPlugins: Map 21 | declare var plugins: Map 22 | } 23 | -------------------------------------------------------------------------------- /src/types/contentPlayer.ts: -------------------------------------------------------------------------------- 1 | import type { Program, Service } from "../infra/mirakurun/api" 2 | 3 | export type ContentPlayerContentType = "Mirakurun" | (string & {}) 4 | 5 | export type ContentPlayerPlayingContent = { 6 | contentType: ContentPlayerContentType 7 | url: string 8 | program?: Program 9 | service?: Service 10 | } 11 | 12 | export type ContentPlayerKeyForRestoration = { 13 | contentType: "Mirakurun" 14 | serviceId: number 15 | } 16 | 17 | export type AribSubtitleData = { 18 | data: string 19 | pts: number 20 | } 21 | -------------------------------------------------------------------------------- /src/types/ipc.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindowConstructorOptions } from "electron" 2 | import type { SerializableParam } from "recoil" 3 | import { ROUTES } from "../constants/routes" 4 | import { Program } from "../infra/mirakurun/api" 5 | import { QuerySchema } from "../main/epgManager" 6 | import { ContentPlayerPlayingContent } from "./contentPlayer" 7 | import { InitialData } from "./struct" 8 | 9 | export type OpenWindowArg = { 10 | name: string 11 | isSingletone?: boolean 12 | args?: BrowserWindowConstructorOptions 13 | playingContent?: ContentPlayerPlayingContent 14 | } 15 | 16 | export type OpenBuiltinWindowArg = { 17 | name: Omit 18 | } 19 | 20 | export type OpenContentPlayerWindowArgs = { 21 | playingContent?: ContentPlayerPlayingContent 22 | isHideUntilLoaded?: boolean 23 | } 24 | 25 | export type SerializableKV = { key: string; value: SerializableParam } 26 | 27 | export type EPGManagerRegisterArg = { url: string; userAgent?: string } 28 | 29 | export type Preload = { 30 | webchimera: { 31 | setup: (args: string[]) => void 32 | isOk: () => boolean 33 | onTimeChanged: (listener: (time: number) => void) => void 34 | onLogMessage: (listener: (level: string, message: string) => void) => void 35 | onFrameReady: ( 36 | listener: ( 37 | frame: Uint8Array, 38 | width: number, 39 | height: number, 40 | uOffset: number, 41 | vOffset: number 42 | ) => void 43 | ) => void 44 | onMediaChanged: (listener: () => void) => void 45 | onEncounteredError: (listener: () => void) => void 46 | onBuffering: (listener: (percents: number) => void) => void 47 | onStopped: (listener: () => void) => void 48 | onEndReached: (listener: () => void) => void 49 | onPaused: (listener: () => void) => void 50 | onPlaying: (listener: () => void) => void 51 | onSeekableChanged: (listener: (isSeekable: boolean) => void) => void 52 | onPositionChanged: (listener: (position: number) => void) => void 53 | destroy: () => void 54 | setVolume: (volume: number) => void 55 | play: (url: string) => void 56 | togglePause: () => void 57 | stop: () => void 58 | hasVout: () => boolean 59 | isPlaying: () => boolean 60 | getSubtitleTrack: () => number 61 | setSubtitleTrack: (track: number) => void 62 | getAudioChannel: () => number 63 | setAudioChannel: (channel: number) => void 64 | setAudioTrack: (track: number) => void 65 | setPosition: (position: number) => void 66 | getAudioTracks: () => string[] 67 | setSpeed: (speed: number) => void 68 | } 69 | requestInitialData: () => Promise 70 | recoilStateUpdate: (_: SerializableKV) => Promise 71 | onRecoilStateUpdate: (listener: (arg: SerializableKV) => void) => () => void 72 | store: { 73 | set: (key: string, value: T) => void 74 | get: (key: string) => T 75 | delete: (key: string) => void 76 | openConfig: () => void 77 | } 78 | onWindowMoved: (listener: () => void) => () => void 79 | onScreenshotRequest: (listener: () => void) => () => void 80 | requestScreenshotBasePath: () => Promise 81 | updateGlobalScreenshotAccelerator: (a: string | false) => Promise 82 | setWindowButtonVisibility: (v: boolean) => void 83 | requestWindowScreenshot: (fileName: string) => Promise 84 | public: { 85 | setWindowAspect: (aspect: number) => void 86 | isDirectoryExists: (path: string) => Promise 87 | writeFile: (_: { path: string; buffer: ArrayBuffer }) => Promise 88 | writeArrayBufferToClipboard: (buffer: ArrayBuffer) => void 89 | requestDialog: ( 90 | arg: Electron.OpenDialogOptions 91 | ) => Promise 92 | requestConfirmDialog: ( 93 | message: string, 94 | buttons: string[] 95 | ) => Promise 96 | requestShellOpenPath: (path: string) => void 97 | joinPath: (...paths: string[]) => string 98 | toggleAlwaysOnTop: () => void 99 | requestAppPath: (name: string) => Promise 100 | requestCursorScreenPoint: () => Promise 101 | toggleFullScreen: () => void 102 | exitFullScreen: () => void 103 | showNotification: ( 104 | arg: Electron.NotificationConstructorOptions, 105 | path?: string 106 | ) => void 107 | showWindow: () => void 108 | setWindowTitle: (title: string) => void 109 | setWindowPosition: (x: number, y: number) => void 110 | requestWindowContentBounds: () => Promise 111 | setWindowContentBounds: (rect: Partial) => void 112 | requestOpenWindow: (_: OpenWindowArg) => Promise 113 | epgManager: { 114 | register: (_: EPGManagerRegisterArg) => Promise 115 | unregister: (url: string) => Promise 116 | query: (arg: QuerySchema) => Promise 117 | } 118 | invoke: (channel: string, ...args: unknown[]) => Promise 119 | onCustomIpcListener: ( 120 | channel: string, 121 | listener: (...args: unknown[]) => void 122 | ) => () => void 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/types/mirakurun.ts: -------------------------------------------------------------------------------- 1 | import { Service } from "../infra/mirakurun/api" 2 | 3 | export const MirakurunCompatibilityServers = { 4 | Mirakurun: "Mirakurun", 5 | Mirakc: "Mirakc", 6 | Mahiron: "Mahiron", 7 | } as const 8 | 9 | export type MirakurunCompatibilityTypes = 10 | keyof typeof MirakurunCompatibilityServers 11 | 12 | export type ServiceWithLogoData = Service & { logoData?: string } 13 | -------------------------------------------------------------------------------- /src/types/setting.ts: -------------------------------------------------------------------------------- 1 | export type MirakurunSetting = { 2 | baseUrl?: string 3 | isEnableServiceTypeFilter?: boolean 4 | userAgent?: string 5 | } 6 | 7 | export type ControllerSetting = { 8 | volumeRange: readonly [number, number] 9 | isVolumeWheelDisabled: boolean 10 | } 11 | 12 | export type SubtitleSetting = { 13 | font: string 14 | } 15 | 16 | export type ScreenshotSetting = { 17 | saveAsAFile: boolean 18 | includeSubtitle: boolean 19 | basePath?: string 20 | keepQuality: boolean 21 | } 22 | 23 | export type ExperimentalSetting = { 24 | isWindowDragMoveEnabled: boolean 25 | isVlcAvCodecHwAny: boolean 26 | vlcNetworkCaching: number 27 | isDualMonoAutoAdjustEnabled: boolean 28 | isSurroundAutoAdjustEnabeld: boolean 29 | globalScreenshotAccelerator: string | false 30 | isCodeBlack: boolean 31 | } 32 | -------------------------------------------------------------------------------- /src/types/struct.ts: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "../constants/routes" 2 | 3 | export type ObjectLiteral = Record 4 | 5 | export type Routes = keyof typeof ROUTES | (string & {}) 6 | 7 | export type PluginDatum = { 8 | filePath: string 9 | fileName: string 10 | content: string 11 | } 12 | 13 | export type InitialData = { 14 | states: ObjectLiteral 15 | pluginData: PluginDatum[] 16 | disabledPluginFileNames: string[] 17 | fonts: string[] 18 | windowId: number 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/enclosed.ts: -------------------------------------------------------------------------------- 1 | import { ENCLOSED_CHARACTERS } from "../constants/enclosed" 2 | 3 | export const convertVariationSelectedClosed = (s: string) => 4 | Array.from(s) 5 | .map((char) => 6 | ENCLOSED_CHARACTERS.includes(char) ? char + "\u{fe0e}" : char 7 | ) 8 | .join("") 9 | -------------------------------------------------------------------------------- /src/utils/mirakurun.ts: -------------------------------------------------------------------------------- 1 | import { MirakurunAPI } from "../infra/mirakurun" 2 | import { Service, ServicesApiAxiosParamCreator } from "../infra/mirakurun/api" 3 | import { MirakurunSetting } from "../types/setting" 4 | 5 | export const generateStreamUrlForMirakurun = async ( 6 | service: Service, 7 | setting: MirakurunSetting 8 | ) => { 9 | const mirakurun = new MirakurunAPI(setting) 10 | 11 | const getServiceStreamRequest = await ServicesApiAxiosParamCreator( 12 | mirakurun.getConfigure() 13 | ).getServiceStream(service.id) 14 | const requestUrl = mirakurun.baseUrl + getServiceStreamRequest.url 15 | return requestUrl 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/plugin.ts: -------------------------------------------------------------------------------- 1 | import * as $ from "zod" 2 | 3 | export const pluginValidator = $.object({ 4 | id: $.string(), 5 | name: $.string(), 6 | shortName: $.string().optional(), 7 | version: $.string(), 8 | author: $.string(), 9 | description: $.string(), 10 | url: $.string().optional(), 11 | setup: $.function(), 12 | destroy: $.function(), 13 | exposedAtoms: $.array($.any()), 14 | components: $.array($.any()), 15 | windows: $.object({}), 16 | contextMenu: $.object({}).optional(), 17 | appMenu: $.object({}).optional(), 18 | }) 19 | -------------------------------------------------------------------------------- /src/utils/recoil.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react" 2 | import { useRecoilValue } from "recoil" 3 | import type { MutableSnapshot, RecoilState } from "recoil" 4 | import { globalFontsAtom } from "../atoms/global" 5 | 6 | export const initializeState = 7 | ({ fonts }: { fonts: string[] }) => 8 | (mutableSnapShot: MutableSnapshot) => { 9 | mutableSnapShot.set(globalFontsAtom, fonts) 10 | } 11 | 12 | export const useRecoilValueRef = (s: RecoilState) => { 13 | const value = useRecoilValue(s) 14 | const ref = useRef() 15 | useEffect(() => { 16 | ref.current = value 17 | }, [value]) 18 | return [value, ref] as const 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import Store from "electron-store" 2 | import pkg from "../../package.json" 3 | 4 | const store = new Store<{}>({ 5 | // @ts-expect-error workaround for conf's Project name could not be inferred. Please specify the `projectName` option. 6 | projectName: pkg.name, 7 | }) 8 | 9 | export { store } 10 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export const tryBase64ToUint8Array = (s: string) => { 2 | try { 3 | return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) 4 | } catch (error) { 5 | console.error(error) 6 | return false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/subtitle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanvasProvider, 3 | CanvasProviderOption, 4 | CanvasProviderResult, 5 | } from "aribb24.js" 6 | import drcsReplaceMapping from "../constants/drcs-mapping.json" 7 | 8 | export const getAribb24Configuration = ( 9 | args: Partial 10 | ): CanvasProviderOption => ({ 11 | useStroke: true, 12 | keepAspectRatio: true, 13 | drcsReplacement: true, 14 | drcsReplaceMapping, 15 | ...args, 16 | }) 17 | 18 | export class ProviderCue extends VTTCue { 19 | constructor( 20 | public provider: CanvasProvider, 21 | public estimate: CanvasProviderResult, 22 | public data: Uint8Array 23 | ) { 24 | super( 25 | estimate.startTime, 26 | Number.isFinite(estimate.endTime) 27 | ? estimate.endTime 28 | : Number.MAX_SAFE_INTEGER, 29 | "" 30 | ) 31 | } 32 | } 33 | 34 | /*! 35 | high-res-texttrack.ts 36 | MIT License - Copyright (c) 2021 もにょ~ん 37 | https://github.dev/monyone/aribb24.js/blob/f3167d4b0fd82969ac4a314be57df29648637322/src/utils/high-res-texttrack.ts 38 | */ 39 | 40 | class HighResMetadataTextTrackCueList 41 | extends Array 42 | implements TextTrackCueList 43 | { 44 | public addCue(cue: TextTrackCue) { 45 | this.push(cue) 46 | } 47 | 48 | public removeCue(cue: TextTrackCue) { 49 | const index = this.findIndex((c) => c === cue) 50 | if (index < 0) { 51 | return 52 | } 53 | 54 | this.splice(index, 1) 55 | } 56 | 57 | public getCueById(id: string) { 58 | return this.find((c) => c.id === id) ?? null 59 | } 60 | } 61 | 62 | export class HighResMetadataTextTrack implements TextTrack { 63 | private all: HighResMetadataTextTrackCueList = 64 | new HighResMetadataTextTrackCueList() 65 | private active: HighResMetadataTextTrackCueList = 66 | new HighResMetadataTextTrackCueList() 67 | 68 | private readonly polling_handler = this.polling.bind(this) 69 | private polling_id: number | null = null 70 | 71 | public constructor( 72 | public internalPlayingTimeRef: React.MutableRefObject 73 | ) {} 74 | 75 | public startPolling() { 76 | this.polling_id = window.requestAnimationFrame(this.polling_handler) 77 | } 78 | 79 | public stopPolling() { 80 | if (this.polling_id == null) { 81 | return 82 | } 83 | window.cancelAnimationFrame(this.polling_id) 84 | this.polling_id = null 85 | } 86 | 87 | private polling() { 88 | const old_active = this.active 89 | const new_active = this.activeCues 90 | 91 | if (old_active.length !== new_active.length) { 92 | const event = new CustomEvent("cuechange") 93 | 94 | if (event !== null) { 95 | this.dispatchEvent(event) 96 | if (this.oncuechange) { 97 | this.oncuechange.call(this, event) 98 | } 99 | } 100 | } else { 101 | for (let i = 0; i < new_active.length; i++) { 102 | if (old_active[i] !== new_active[i]) { 103 | const event = new CustomEvent("cuechange") 104 | 105 | if (event !== null) { 106 | this.dispatchEvent(event) 107 | if (this.oncuechange) { 108 | this.oncuechange.call(this, event) 109 | } 110 | break 111 | } 112 | } 113 | } 114 | } 115 | 116 | this.polling_id = window.requestAnimationFrame(this.polling_handler) 117 | } 118 | 119 | public readonly cues: TextTrackCueList = this.all 120 | public get activeCues(): TextTrackCueList { 121 | const in_range_cues = new HighResMetadataTextTrackCueList( 122 | ...this.all.filter((cue) => { 123 | const time = this.internalPlayingTimeRef.current / 1_000 124 | return cue.startTime <= time && time <= cue.endTime 125 | }) 126 | ) 127 | 128 | in_range_cues.sort((a, b) => { 129 | if (a.startTime === b.startTime) { 130 | return -(a.endTime - b.endTime) 131 | } else { 132 | return a.startTime - b.startTime 133 | } 134 | }) 135 | 136 | this.active = in_range_cues 137 | return this.active 138 | } 139 | 140 | public getCueById(id: string) { 141 | return this.all.getCueById(id) 142 | } 143 | 144 | public addCue(cue: TextTrackCue) { 145 | this.all.addCue(cue) 146 | } 147 | public removeCue(cue: TextTrackCue) { 148 | this.all.removeCue(cue) 149 | } 150 | 151 | public oncuechange: ((this: TextTrack, ev: Event) => unknown) | null = null 152 | 153 | public readonly id: string = "" 154 | public readonly kind: TextTrackKind = "metadata" 155 | public readonly label: string = "" 156 | public readonly language: string = "ja-JP" 157 | public readonly mode: TextTrackMode = "hidden" 158 | public readonly inBandMetadataTrackDispatchType: string = "" 159 | public readonly sourceBuffer = null 160 | 161 | // for ie11 (EventTarget を継承できないため) 162 | private listeners: ((this: TextTrack, ev: Event) => unknown)[] = [] 163 | public addEventListener( 164 | type: "cuechange", 165 | listener: (this: TextTrack, ev: Event) => unknown 166 | ) { 167 | this.listeners.push(listener) 168 | } 169 | public removeEventListener( 170 | type: "cuechange", 171 | listener: (this: TextTrack, ev: Event) => unknown 172 | ) { 173 | const index = this.listeners.findIndex((cb) => cb === listener) 174 | if (index < 0) { 175 | return 176 | } 177 | this.listeners.splice(index, 1) 178 | } 179 | public dispatchEvent(ev: Event): boolean { 180 | if (ev.type !== "cuechange") { 181 | return true 182 | } 183 | this.listeners.forEach((listener) => listener.call(this, ev)) 184 | return true 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/utils/vlc.ts: -------------------------------------------------------------------------------- 1 | export const VLCLogFilter = (s: string) => { 2 | if ( 3 | // 表示遅延 4 | s.startsWith("picture is too late to be displayed") || 5 | s.startsWith("picture might be displayed late") || 6 | s.startsWith("More than") || 7 | s.startsWith("buffer too late") || 8 | s.startsWith("discontinuity received 0") || 9 | // gnutls 10 | s.startsWith("in DATA (0x00) frame of") || 11 | s.startsWith("out WINDOW_UPDATE (0x08) frame ") 12 | ) { 13 | return { category: "commonplace" } as const 14 | } else if (s.startsWith("libdvbpsi error")) { 15 | return { category: "libdvbpsi_error" } as const 16 | } else if (s.startsWith("Stream buffering done")) { 17 | return { category: "stream_buffering_done" } as const 18 | } else if (s.startsWith("Decoder wait done")) { 19 | return { category: "decoder_wait_done" } as const 20 | } else if (s.startsWith("size ") && s.includes("fps=")) { 21 | const m = s.match(/(\d+)x(\d+)\/(\d+)x(\d+)\sfps=([\d.]+)/) 22 | if (!m) return { category: "size" } as const 23 | const [, displayWidth, displayHeight, width, height, fps] = m 24 | return { 25 | category: "size", 26 | displayWidth, 27 | displayHeight, 28 | width, 29 | height, 30 | fps, 31 | } as const 32 | } else if (s.startsWith("VoutDisplayEvent 'resize'")) { 33 | const m = s.match(/VoutDisplayEvent 'resize' (\d+)x(\d+)/) 34 | if (!m) return { category: "resize" } as const 35 | const [, width, height] = m 36 | return { 37 | category: "resize", 38 | width: parseInt(width), 39 | height: parseInt(height), 40 | } as const 41 | } else if (s.startsWith("tot,")) { 42 | const tot = parseInt(s.split(",").pop() || "NaN") 43 | if (Number.isNaN(tot)) { 44 | return { category: "tot" } as const 45 | } 46 | return { 47 | category: "tot", 48 | tot: (tot - 3600 * 9 * 2) * 1000, 49 | } as const 50 | } else if (s.startsWith("arib_data")) { 51 | const m = s.match(/^arib_data \[(.+)\]\[(\d+)\]$/) 52 | if (!m) return { category: "arib_data" } as const 53 | return { 54 | category: "arib_data", 55 | data: m[1], 56 | pts: parseInt(m[2]), 57 | } as const 58 | } else if (s.startsWith("i_pcr")) { 59 | const m = s.match(/^i_pcr \[(\d+)\]\[(\d+)\]$/) 60 | if (!m) return { category: "i_pcr" } 61 | return { 62 | category: "i_pcr", 63 | i_pcr: parseInt(m[1]), 64 | pcr_i_first: parseInt(m[2]), 65 | } as const 66 | } else if (s.startsWith("arib parser was destroyed")) { 67 | return { category: "arib_parser_was_destroyed" } as const 68 | } else if (s.startsWith("VLC is unable to open the MRL")) { 69 | return { category: "unable_to_open" } as const 70 | } else if (s.includes("successfully opened") && s.includes("http")) { 71 | return { category: "successfully_opened" } as const 72 | } else if (s.startsWith("Received first picture")) { 73 | return { category: "received_first_picture" } as const 74 | } else if (s.startsWith("EsOutProgramEpg")) { 75 | return { category: "es_out_program_epg" } as const 76 | } else if (s.startsWith("PMTCallBack called for program")) { 77 | return { category: "PMTCallBack_called_for_program" } as const 78 | } else if (s.startsWith("VLC is looking for: 'f32l'")) { 79 | return { category: "looking_f32l" } as const 80 | } else if (s.startsWith("playback too late")) { 81 | return { category: "playback_too_late" } 82 | } else if (s.endsWith("3F2R/LFE->3F2R/LFE")) { 83 | return { category: "surround-to-surround" } as const 84 | } else if (s.endsWith("Stereo->Stereo")) { 85 | return { category: "stereo-to-stereo" } as const 86 | } else if (s.startsWith("end of stream")) { 87 | return { category: "end_of_stream" } as const 88 | } else if (s.startsWith("EOF reached")) { 89 | return { category: "eof_reached" } as const 90 | } else if (s.startsWith("waiting decoder fifos to empty")) { 91 | return { category: "waiting_decoder_fifos_to_empty" } as const 92 | } else if (s.startsWith("Buffering")) { 93 | const m = s.match(/Buffering (\d+)%/) 94 | if (!m) return { category: "buffering" } as const 95 | return { category: "buffering", progress: parseInt(m[1]) } as const 96 | } else if (s.startsWith("configured with")) { 97 | return { 98 | category: "configured_with", 99 | isCustomized: s.includes("vlc-miraktest"), 100 | } as const 101 | } else if (s.startsWith("looking for audio resampler module matching")) { 102 | return { 103 | category: "audio_channel_updated", 104 | } as const 105 | } else { 106 | return { category: "unknown" } as const 107 | } 108 | } 109 | 110 | export const VLCAudioChannel = { 111 | Stereo: 1, 112 | ReverseStereo: 2, 113 | Left: 3, 114 | Right: 4, 115 | Dolby: 5, 116 | } 117 | 118 | export const VLCAudioStereoChannel = { 119 | Monaural: 1, 120 | Original: 2, 121 | Stereo: 3, 122 | Headphone: 4, 123 | } 124 | 125 | export const VLCAudioChannelTranslated = [ 126 | "?", 127 | "ステレオ", 128 | "反転ステレオ", 129 | "左", 130 | "右", 131 | "ドルビー", 132 | ] 133 | 134 | export const VLCAudioChannelSurroundTranslated = [ 135 | "?", 136 | "モノラル", 137 | "オリジナル", 138 | "ステレオ", 139 | "ヘッドフォン", 140 | ] 141 | -------------------------------------------------------------------------------- /src/windows/ContentPlayer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React, { useEffect, useRef, useState } from "react" 3 | import { useRecoilValue, useSetRecoilState } from "recoil" 4 | import pkg from "../../package.json" 5 | import { 6 | contentPlayerBoundsAtom, 7 | contentPlayerTitleAtom, 8 | } from "../atoms/contentPlayer" 9 | import { PluginPositionComponents } from "../components/common/PluginPositionComponents" 10 | import { CoiledController } from "../components/contentPlayer/Controller" 11 | import { MirakurunManager } from "../components/contentPlayer/MirakurunManager" 12 | import { CoiledProgramTitleManager } from "../components/contentPlayer/ProgramTitleManager" 13 | import { CoiledSubtitleRenderer } from "../components/contentPlayer/SubtitleRenderer" 14 | import { CoiledVideoPlayer } from "../components/contentPlayer/VideoPlayer" 15 | import { CoiledEpgUpdatedObserver } from "../components/global/EpgUpdatedObserver" 16 | import { Splash } from "../components/global/Splash" 17 | 18 | export const CoiledContentPlayer: React.FC<{}> = () => { 19 | const setBounds = useSetRecoilState(contentPlayerBoundsAtom) 20 | const internalPlayingTimeRef = useRef(-1) 21 | 22 | useEffect(() => { 23 | // 16:9以下の比率になったら戻し、ウィンドウサイズを保存する 24 | let timer: NodeJS.Timeout | null = null 25 | const onResizedOrMoved = () => { 26 | if (timer) { 27 | clearTimeout(timer) 28 | } 29 | timer = setTimeout(async () => { 30 | timer = null 31 | const bounds = await window.Preload.public.requestWindowContentBounds() 32 | if (!bounds) { 33 | return 34 | } 35 | setBounds(bounds) 36 | }, 500) 37 | } 38 | window.addEventListener("resize", onResizedOrMoved) 39 | const onWindowMoved = window.Preload.onWindowMoved(() => onResizedOrMoved()) 40 | onResizedOrMoved() 41 | return () => { 42 | window.removeEventListener("resize", onResizedOrMoved) 43 | onWindowMoved() 44 | if (timer) { 45 | clearTimeout(timer) 46 | } 47 | } 48 | }, []) 49 | // タイトル 50 | const title = useRecoilValue(contentPlayerTitleAtom) 51 | useEffect(() => { 52 | if (title) { 53 | window.Preload.public.setWindowTitle(`${title} - ${pkg.productName}`) 54 | } else { 55 | window.Preload.public.setWindowTitle(pkg.productName) 56 | } 57 | }, [title]) 58 | const [isHideController, setIsHideController] = useState(false) 59 | 60 | return ( 61 | <> 62 | 63 | 64 | 65 |
75 |
78 |
82 | 83 |
84 |
88 | 89 |
90 |
103 | 107 |
108 |
112 | 113 |
114 |
127 | 130 |
131 |
135 | 136 |
137 |
141 | 142 |
143 |
154 | 155 |
156 |
157 |
158 | 159 | ) 160 | } 161 | -------------------------------------------------------------------------------- /src/windows/ProgramTable.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@headlessui/react" 2 | import clsx from "clsx" 3 | import React, { useEffect, useMemo, useState } from "react" 4 | import { useSetRecoilState, useRecoilValue } from "recoil" 5 | import pkg from "../../package.json" 6 | import { globalContentPlayerSelectedServiceFamily } from "../atoms/globalFamilies" 7 | import { globalActiveContentPlayerIdSelector } from "../atoms/globalSelectors" 8 | import { mirakurunServicesAtom } from "../atoms/mirakurun" 9 | import { mirakurunSetting } from "../atoms/settings" 10 | import { CoiledEpgUpdatedObserver } from "../components/global/EpgUpdatedObserver" 11 | import { ProgramModal } from "../components/programTable/ProgramModal" 12 | import { ScrollArea } from "../components/programTable/ScrollArea" 13 | import { WeekdaySelector } from "../components/programTable/WeekdaySelector" 14 | import { useNow } from "../hooks/date" 15 | import { Program } from "../types/plugin" 16 | 17 | export const CoiledProgramTable: React.FC<{}> = () => { 18 | const now = useNow() 19 | const [add, setAdd] = useState(0) 20 | const activePlayerId = useRecoilValue(globalActiveContentPlayerIdSelector) 21 | const setSelectedService = useSetRecoilState( 22 | globalContentPlayerSelectedServiceFamily(activePlayerId ?? 0) 23 | ) 24 | const services = useRecoilValue(mirakurunServicesAtom) 25 | const mirakurunSettingValue = useRecoilValue(mirakurunSetting) 26 | const [selectedProgram, setSelectedProgram] = useState(null) 27 | const selectedService = useMemo( 28 | () => 29 | services?.find( 30 | (service) => 31 | service.serviceId === selectedProgram?.serviceId && 32 | service.networkId === selectedProgram.networkId 33 | ), 34 | [selectedProgram] 35 | ) 36 | 37 | useEffect(() => { 38 | window.Preload.public.setWindowTitle(`番組表 - ${pkg.productName}`) 39 | }, []) 40 | 41 | return ( 42 |
52 | 53 |
64 |

65 | 番組表 66 |

67 |
68 | 69 |
70 |
71 | {mirakurunSettingValue.baseUrl ? ( 72 | 79 | ) : ( 80 |

URLが設定されていません

81 | )} 82 | setSelectedProgram(null)} 93 | > 94 | 104 | {selectedProgram && selectedService && ( 105 | 110 | )} 111 | 112 |
113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./index.html", 4 | "./src/**/*.{ts,tsx,scss}", 5 | "./node_modules/react-multi-carousel/lib/styles.css", 6 | ], 7 | future: { 8 | removeDeprecatedGapUtilities: true, 9 | purgeLayersByDefault: true, 10 | }, 11 | plugins: [ 12 | require("tailwindcss-textshadow"), 13 | require("@tailwindcss/forms"), 14 | require("tailwind-scrollbar"), 15 | ], 16 | theme: { 17 | gradientColorStops: (theme) => ({ 18 | ...theme("colors"), 19 | blackOpacity: "rgba(0, 0, 0, var(--tw-bg-opacity))", 20 | }), 21 | extend: { 22 | cursor: { 23 | none: "none", 24 | }, 25 | animation: { 26 | "ping-once": 27 | "ping 1s cubic-bezier(0, 0, 0.2, 1), hidden 1s linear 1s infinite", 28 | }, 29 | maxHeight: { 30 | halfscreen: "50vh", 31 | }, 32 | transitionProperty: { 33 | maxHeight: "max-height", 34 | width: "width", 35 | strokeDashoffset: "stroke-dashoffset", 36 | }, 37 | keyframes: { 38 | hidden: { 39 | "0%, 100%": { 40 | opacity: 0, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "./tailwind.config.js", "./*.ts", "./.eslintrc.js"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /webpack-loaders.ts: -------------------------------------------------------------------------------- 1 | import MiniCSSExtractPlugin from "mini-css-extract-plugin" 2 | import webpack from "webpack" 3 | 4 | export const babelLoaderConfiguration: (b: boolean) => webpack.RuleSetRule = ( 5 | isDev 6 | ) => ({ 7 | test: [/\.tsx?$/, /\.ts$/, /\.js$/], 8 | use: { 9 | loader: "babel-loader", 10 | options: { 11 | cacheDirectory: true, 12 | presets: [ 13 | ["@babel/preset-env", { targets: { electron: "19" } }], 14 | "@babel/preset-typescript", 15 | [ 16 | "@babel/preset-react", 17 | { 18 | runtime: "automatic", 19 | development: isDev, 20 | importSource: "@welldone-software/why-did-you-render", 21 | }, 22 | ], 23 | ], 24 | plugins: [ 25 | isDev && "react-refresh/babel", 26 | "@babel/plugin-transform-runtime", 27 | ["@babel/plugin-transform-typescript", { isTSX: true }], 28 | "@babel/plugin-transform-react-jsx", 29 | "@babel/plugin-proposal-class-properties", 30 | "@babel/plugin-transform-modules-commonjs", 31 | ].filter((s) => s), 32 | }, 33 | }, 34 | }) 35 | 36 | export const nodeConfiguration: webpack.RuleSetRule = { 37 | test: /\.node$/, 38 | loader: "node-loader", 39 | } 40 | 41 | export const scssConfiguration: webpack.RuleSetRule = { 42 | test: /\.s?css$/, 43 | use: [ 44 | { 45 | loader: MiniCSSExtractPlugin.loader, 46 | }, 47 | { 48 | loader: "css-loader", 49 | options: { 50 | importLoaders: 1, 51 | }, 52 | }, 53 | { 54 | loader: "postcss-loader", 55 | }, 56 | { 57 | loader: "sass-loader", 58 | }, 59 | ], 60 | } 61 | 62 | export const imageLoaderConfiguration: webpack.RuleSetRule = { 63 | test: /\.(gif|jpe?g|png|svg)$/, 64 | use: { 65 | loader: "url-loader", 66 | options: { 67 | name: "[name].[ext]", 68 | esModule: false, 69 | }, 70 | }, 71 | } 72 | 73 | export const assetLoaderConfiguration: webpack.RuleSetRule = { 74 | test: /\.(ttf)$/, 75 | type: "asset/resource", 76 | } 77 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin" 3 | import MiniCSSExtractPlugin from "mini-css-extract-plugin" 4 | import webpack from "webpack" 5 | import { 6 | babelLoaderConfiguration, 7 | assetLoaderConfiguration, 8 | imageLoaderConfiguration, 9 | scssConfiguration, 10 | nodeConfiguration, 11 | } from "./webpack-loaders" 12 | 13 | type MultiConfigurationFactory = ( 14 | env: string | Record | undefined, 15 | args: webpack.WebpackOptionsNormalized 16 | ) => webpack.Configuration[] 17 | 18 | const factory: MultiConfigurationFactory = (_, args) => { 19 | const isDev = args.mode !== "production" 20 | return [ 21 | { 22 | entry: path.resolve(__dirname, "./src/index.web.tsx"), 23 | 24 | output: { 25 | path: path.resolve(__dirname, "dist/"), 26 | }, 27 | 28 | target: "web", 29 | 30 | module: { 31 | rules: [ 32 | babelLoaderConfiguration(isDev), 33 | assetLoaderConfiguration, 34 | imageLoaderConfiguration, 35 | scssConfiguration, 36 | nodeConfiguration, 37 | ], 38 | }, 39 | 40 | resolve: { 41 | alias: { 42 | "react-native": "react-native-web", 43 | "react-query": "react-query/lib", 44 | }, 45 | extensions: [ 46 | ".webpack.js", 47 | ".web.ts", 48 | ".web.tsx", 49 | ".web.js", 50 | ".ts", 51 | ".tsx", 52 | ".js", 53 | ], 54 | fallback: { path: require.resolve("path-browserify") }, 55 | }, 56 | 57 | devServer: { 58 | hot: isDev, 59 | devMiddleware: { publicPath: "/dist" }, 60 | static: { 61 | directory: __dirname, 62 | watch: false, 63 | }, 64 | port: 10170, 65 | }, 66 | 67 | plugins: [ 68 | new webpack.DefinePlugin({ 69 | "process.platform": JSON.stringify(process.platform), 70 | }), 71 | // TODO: 型 'import("node_modules/tapable/tapable").SyncBailHook<[import("node_modules/webpack/types").Compilation], boolean, import("node_modules/tapable/tapable").UnsetAdditionalOptions>' を型 'import("node_modules/tapable/tapable").SyncBailHook<[import("node_modules/@types/mini-css-extract-plugin/node_modules/webpack/types").Compilation], boolean, import("node_modules/tapable/tapab...' に割り当てることはできません。ts(2322) 72 | new MiniCSSExtractPlugin() as never, 73 | isDev ? new ReactRefreshWebpackPlugin() : (undefined as never), 74 | ].filter((p: unknown) => p), 75 | }, 76 | ] 77 | } 78 | 79 | module.exports = factory 80 | -------------------------------------------------------------------------------- /webpack.main.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import webpack from "webpack" 3 | import { babelLoaderConfiguration } from "./webpack-loaders" 4 | 5 | const nodeConfiguration: webpack.RuleSetRule = { 6 | test: /\.node$/, 7 | loader: "node-loader", 8 | } 9 | 10 | const config: webpack.Configuration = { 11 | entry: { 12 | "main.electron": path.resolve(__dirname, "./src/main/index.electron.ts"), 13 | }, 14 | 15 | output: { 16 | path: path.resolve(__dirname, "dist/"), 17 | }, 18 | 19 | target: "node16", 20 | 21 | module: { 22 | rules: [babelLoaderConfiguration(false), nodeConfiguration], 23 | parser: { 24 | javascript: { commonjsMagicComments: true }, 25 | }, 26 | }, 27 | 28 | resolve: { 29 | extensions: [".ts", ".js"], 30 | }, 31 | externals: { 32 | "webchimera.js": "commonjs webchimera.js", 33 | "font-list": "commonjs font-list", 34 | electron: "commonjs electron", 35 | }, 36 | 37 | optimization: { 38 | nodeEnv: false, 39 | }, 40 | } 41 | 42 | module.exports = config 43 | --------------------------------------------------------------------------------