├── .vercelignore ├── src ├── tailwind.scss ├── assets │ └── elaina-hat-256.jpg ├── utils │ ├── wait.ts │ ├── comment.ts │ ├── genres.ts │ ├── pes.ts │ └── capture.ts ├── index.tsx ├── types │ ├── global.d.ts │ ├── setting.ts │ ├── epgstation.ts │ ├── saya.ts │ ├── struct.ts │ └── react-table-config.d.ts ├── components │ ├── global │ │ ├── NotFound.tsx │ │ ├── Loading.tsx │ │ ├── InitialSetting.tsx │ │ ├── AutoLinkedText.tsx │ │ ├── TheFooter.tsx │ │ ├── RecoilWatcher.tsx │ │ └── TheHeader.tsx │ ├── timetable │ │ ├── TimetableParts.tsx │ │ ├── Channels.tsx │ │ └── Programs.tsx │ ├── common │ │ ├── CaptureButton.tsx │ │ ├── CommentList.tsx │ │ ├── CommentPlayer.tsx │ │ └── CommentM2tsPlayer.tsx │ ├── settings │ │ ├── Saya.tsx │ │ ├── Backend.tsx │ │ └── Player.tsx │ ├── records │ │ ├── SearchModal.tsx │ │ └── PlayerController.tsx │ ├── channels │ │ └── StatsWidget.tsx │ └── top │ │ ├── Recordings.tsx │ │ └── Streams.tsx ├── hooks │ ├── backend.ts │ ├── util.ts │ ├── date.ts │ ├── saya.ts │ └── television.ts ├── layout.tsx ├── index.html ├── pages │ ├── index.tsx │ ├── settings │ │ └── index.tsx │ ├── programs │ │ └── id.tsx │ ├── timetable │ │ └── index.tsx │ ├── channels │ │ ├── index.tsx │ │ └── id.tsx │ └── records │ │ ├── index.tsx │ │ └── id.tsx ├── atoms │ ├── television.ts │ ├── setting.ts │ └── initialize.ts ├── index.scss ├── routes.tsx ├── app.tsx ├── infra │ ├── saya.ts │ └── epgstation.ts └── constants.ts ├── .husky └── pre-commit ├── .prettierrc ├── .postcssrc.json ├── .eslintrc.yml ├── tsconfig.eslint.json ├── vercel.json ├── tailwind.config.js ├── Dockerfile ├── LICENSE ├── README.md ├── .github └── workflows │ ├── docker.yml │ └── ci.yml ├── .gitignore ├── package.json └── tsconfig.json /.vercelignore: -------------------------------------------------------------------------------- 1 | dist 2 | .parce-cache 3 | parcel-bundle-reports 4 | -------------------------------------------------------------------------------- /src/tailwind.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/assets/elaina-hat-256.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ci7lus/elaina/HEAD/src/assets/elaina-hat-256.jpg -------------------------------------------------------------------------------- /src/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export const wait = (n: number) => 2 | new Promise((res) => setTimeout(() => res(), n)) 3 | -------------------------------------------------------------------------------- /.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "tailwindcss": "tailwind.config.js", 4 | "autoprefixer": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "@ci7lus/eslint-config" 4 | parserOptions: 5 | project: 6 | - "./tsconfig.eslint.json" 7 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "./tailwind.config.js"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import { App } from "./app" 4 | 5 | ReactDOM.render(, document.getElementById("app")) 6 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | interface Navigator { 3 | writeText: AsyncClipboardWriteFunction 4 | } 5 | 6 | interface Clipboard { 7 | write: AsyncClipboardWriteFunction 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | { "handle": "filesystem" }, 5 | { 6 | "src": "/(.+)", 7 | "dest": "index.html" 8 | } 9 | ], 10 | "builds": [ 11 | { 12 | "src": "package.json", 13 | "use": "@vercel/static-build" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/components/global/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@chakra-ui/react" 2 | import React from "react" 3 | 4 | export const NotFound: React.VFC<{}> = () => ( 5 |
6 | NotFound 7 |

ページが見つかりませんでした…そう、404です!

8 |
9 | ) 10 | -------------------------------------------------------------------------------- /src/hooks/backend.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from "recoil" 2 | import { backendSettingAtom } from "../atoms/setting" 3 | import { EPGStationAPI } from "../infra/epgstation" 4 | 5 | export const useBackend = () => { 6 | const backendSetting = useRecoilValue(backendSettingAtom) 7 | return new EPGStationAPI(backendSetting) 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/comment.ts: -------------------------------------------------------------------------------- 1 | export const trimCommentForFlow = (s: string) => { 2 | return s 3 | .replace(/https?:\/\/[\w!?/+\-_~;.,*&@#$%()'[\]]+\s?/g, "") // URL削除 4 | .replace(/#.+\s?/g, "") // ハッシュタグ削除 5 | .replace(/@\w+?\s?/g, "") // メンション削除 6 | .replace(/^\/nicoad.*/, "") // ニコニ広告削除 7 | .replace(/^\/\w+\s?/, "") // コマンド削除 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/util.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { useRef, useState } from "react" 3 | 4 | export const useRefState = (i: T) => { 5 | const [state, setState] = useState(i) 6 | const ref = useRef(state) 7 | useEffect(() => { 8 | ref.current = state 9 | }, [state]) 10 | 11 | return [state, setState, ref] as const 12 | } 13 | -------------------------------------------------------------------------------- /src/components/global/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@chakra-ui/react" 2 | import React from "react" 3 | 4 | export const Loading: React.VFC<{}> = () => ( 5 |
9 | 10 |
11 | ) 12 | -------------------------------------------------------------------------------- /src/types/setting.ts: -------------------------------------------------------------------------------- 1 | type UpstreamSetting = { 2 | url: string | null 3 | user: string | null 4 | pass: string | null 5 | } 6 | 7 | export type SayaSetting = UpstreamSetting 8 | 9 | export type BackendSetting = SayaSetting 10 | 11 | export type PlayerSetting = { 12 | commentDelay?: number | null 13 | recordCommentDelay?: number | null 14 | useMpegTs?: boolean | null 15 | mpegTsMode: number | null 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/genres.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 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: { 3 | enabled: process.env.NODE_ENV === "production", 4 | mode: "all", 5 | content: ["./src/**/*.html", "./src/**/*.ts", "./src/**/*.tsx"], 6 | whitelist: ["body", "html", "svg"], 7 | whitelistPatterns: [], 8 | }, 9 | future: { 10 | removeDeprecatedGapUtilities: true, 11 | purgeLayersByDefault: true, 12 | }, 13 | darkMode: "media", 14 | } 15 | -------------------------------------------------------------------------------- /src/types/epgstation.ts: -------------------------------------------------------------------------------- 1 | export type Stream = { 2 | streamId: number 3 | type: "LiveHLS" | "RecordedHLS" 4 | mode: number 5 | isEnable: boolean 6 | channelId: number 7 | name: string 8 | startAt: number 9 | endAt: number 10 | } 11 | 12 | export type ApiDocs = { 13 | components: { schemas: { ChannelType: { enum: string[] } } } 14 | } 15 | 16 | export type Config = { 17 | broadcast: { [key: string]: boolean } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/global/InitialSetting.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@chakra-ui/react" 2 | import React from "react" 3 | import { BackendSettingForm } from "../settings/Backend" 4 | 5 | export const InitialSettingPage: React.VFC<{}> = () => ( 6 |
7 | 8 | 初期設定 9 | 10 |
11 | 12 |
13 |
14 | ) 15 | -------------------------------------------------------------------------------- /src/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { TheFooter } from "./components/global/TheFooter" 3 | import { TheHeader } from "./components/global/TheHeader" 4 | 5 | export const Layout: React.FC<{}> = ({ children }) => ( 6 |
7 | 14 | ) 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | elaina 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { RecordingsWidget } from "../components/top/Recordings" 3 | import { StreamsWidget } from "../components/top/Streams" 4 | 5 | export const IndexPage: React.VFC<{}> = () => { 6 | return ( 7 |
8 |
Under development...そう、開発中です!
9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/components/timetable/TimetableParts.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import React, { memo } from "react" 3 | 4 | export const LeftTimeBar: React.VFC<{ startAtInString: string }> = memo( 5 | ({ startAtInString }) => { 6 | const startAt = dayjs(startAtInString) 7 | return ( 8 | <> 9 | {[...Array(24).keys()].map((idx) => { 10 | return ( 11 |
16 | {startAt.clone().add(idx, "hour").hour()} 17 |
18 | ) 19 | })} 20 | 21 | ) 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /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 | let interval: null | NodeJS.Timeout = null 11 | const update = () => setNow(dayjs()) 12 | let timeout: null | NodeJS.Timeout = setTimeout(() => { 13 | update() 14 | interval = setInterval(update, 60 * 1000) 15 | timeout = null 16 | }, (60 - new Date().getSeconds()) * 1000) 17 | return () => { 18 | timeout && clearTimeout(timeout) 19 | interval && clearInterval(interval) 20 | } 21 | }, []) 22 | 23 | return now 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/pes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://twitter.com/magicxqq/status/1381813912539066373 3 | * https://github.com/l3tnun/EPGStation/blob/ceaa8641e6c78ffd0e613c606c69351ff682b9c5/client/src/components/video/LiveMpegTsVideo.vue#L132-L145 4 | */ 5 | 6 | export const parseMalformedPES = (data: Uint8Array) => { 7 | /*const pes_scrambling_control = (data[0] & 0x30) >>> 4 8 | const pts_dts_flags = (data[1] & 0xc0) >>> 6*/ 9 | const pes_header_data_length = data[2] 10 | const payload_start_index = 3 + pes_header_data_length 11 | const payload_length = data.byteLength - payload_start_index 12 | const payload = data.subarray( 13 | payload_start_index, 14 | payload_start_index + payload_length 15 | ) 16 | return payload 17 | } 18 | -------------------------------------------------------------------------------- /src/components/global/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 | 12 | export const AutoLinkedText: React.FC<{ children: string }> = ({ 13 | children, 14 | }) => ( 15 | ), 19 | new HashtagMatcher("hashtag", {}, (args) => ( 20 | `https://twitter.com/hashtag/${url}`} 22 | newWindow={true} 23 | {...args} 24 | /> 25 | )), 26 | new EmailMatcher("email", {}, (args) => ( 27 | 28 | )), 29 | ]} 30 | /> 31 | ) 32 | -------------------------------------------------------------------------------- /src/atoms/television.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector } from "recoil" 2 | import { Config } from "../types/epgstation" 3 | import { Channel, Schedule } from "../types/struct" 4 | 5 | const prefix = "elaina:television" 6 | 7 | export const configAtom = atom({ 8 | key: `${prefix}.config`, 9 | default: null, 10 | }) 11 | 12 | export const channelsAtom = atom({ 13 | key: `${prefix}:channels`, 14 | default: null, 15 | }) 16 | 17 | export const schedulesAtom = atom({ 18 | key: `${prefix}:schedules`, 19 | default: null, 20 | }) 21 | 22 | export const filteredSchedulesSelector = selector({ 23 | key: `${prefix}:filtered-schedules`, 24 | get: ({ get }) => { 25 | const schedules = get(schedulesAtom) 26 | if (!schedules) return null 27 | 28 | return schedules.filter((schedule) => 0 < schedule.programs.length) 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.17.1-buster AS build 2 | 3 | # Install dependencies 4 | WORKDIR /app 5 | COPY package.json yarn.lock ./ 6 | RUN yarn 7 | 8 | # Build application 9 | COPY src/ src/ 10 | COPY .postcssrc.json tailwind.config.js tsconfig.json ./ 11 | RUN yarn run build 12 | 13 | # Static file serving 14 | FROM nginx:stable-alpine 15 | LABEL maintainer="ci7lus " 16 | 17 | ARG PORT=1234 18 | RUN { \ 19 | echo "server {"; \ 20 | echo " listen ${PORT};"; \ 21 | echo " server_name localhost;"; \ 22 | echo " root /usr/share/nginx/html;"; \ 23 | echo ""; \ 24 | echo " location / {"; \ 25 | echo " try_files \$uri \$uri/ /index.html;"; \ 26 | echo " }"; \ 27 | echo "}"; \ 28 | } > /etc/nginx/conf.d/default.conf 29 | 30 | COPY --from=build /app/dist/*.html /app/dist/*.js /app/dist/*.css /usr/share/nginx/html/ 31 | 32 | EXPOSE ${PORT} 33 | ENV NGINX_ENTRYPOINT_QUIET_LOGS=1 34 | -------------------------------------------------------------------------------- /src/types/saya.ts: -------------------------------------------------------------------------------- 1 | import { ChannelType } from "./struct" 2 | 3 | export type CommentStats = { 4 | nico?: { 5 | source: string 6 | comments: number 7 | commentsPerMinute: number 8 | viewers: number 9 | adPoints: number 10 | giftPoints: number 11 | } 12 | twitter: { source: string; comments: number; commentsPerMinute: number }[] 13 | } 14 | 15 | export type ChannelComment = { 16 | channel: { 17 | name: string 18 | type: ChannelType 19 | serviceIds: number[] 20 | nicojkId: number 21 | hasOfficialNicolive: boolean 22 | nicoliveTags: string[] 23 | nicoliveCommunityIds: string[] 24 | miyoutvId: string 25 | twitterKeywords: string[] 26 | boardId: string 27 | threadKeywords: string[] 28 | } 29 | service: { 30 | id: number 31 | name: string 32 | logoId: number | null 33 | keyId: number 34 | channel: string 35 | type: ChannelType 36 | } | null 37 | force: number 38 | last: string 39 | } 40 | -------------------------------------------------------------------------------- /src/components/global/TheFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@chakra-ui/react" 2 | import React from "react" 3 | import { ExternalLink } from "react-feather" 4 | import { Heart } from "react-feather" 5 | 6 | export const TheFooter: React.VFC<{}> = () => ( 7 |
8 |
9 |
10 |
11 | 12 | 17 | elaina 18 | 19 | 20 |  made with 21 | 22 | 23 | 24 | 25 | 26 | ...so, tawashi-desu! 27 | 28 |
29 |
30 |
31 |
32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 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. -------------------------------------------------------------------------------- /src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Heading, 3 | Tab, 4 | TabList, 5 | TabPanel, 6 | TabPanels, 7 | Tabs, 8 | } from "@chakra-ui/react" 9 | import React from "react" 10 | import { BackendSettingForm } from "../../components/settings/Backend" 11 | import { PlayerSettingForm } from "../../components/settings/Player" 12 | import { SayaSettingForm } from "../../components/settings/Saya" 13 | 14 | export const SettingsPage = () => { 15 | return ( 16 |
17 | 18 | 設定 19 | 20 | 21 | 22 | バックエンド 23 | プレイヤー 24 | 25 | 26 | 27 | 28 |
29 | 30 |
31 |
32 |  33 |
34 |
35 | 36 | 37 | 38 |
39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/saya.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { useState } from "react" 3 | import { useToasts } from "react-toast-notifications" 4 | import { useRecoilValue } from "recoil" 5 | import { sayaSettingAtom } from "../atoms/setting" 6 | import { SayaAPI } from "../infra/saya" 7 | import { ChannelComment } from "../types/saya" 8 | import { useNow } from "./date" 9 | 10 | export const useSaya = () => { 11 | const sayaSetting = useRecoilValue(sayaSettingAtom) 12 | if (!sayaSetting.url) return 13 | return new SayaAPI(sayaSetting) 14 | } 15 | 16 | export const useChannelComments = () => { 17 | const saya = useSaya() 18 | const toast = useToasts() 19 | const [channelComments, setChannelComments] = useState< 20 | ChannelComment[] | null 21 | >(null) 22 | const now = useNow() 23 | 24 | useEffect(() => { 25 | if (!saya) return 26 | saya 27 | .getChannelComments() 28 | .then((comments) => setChannelComments(comments)) 29 | .catch(() => { 30 | toast.addToast("チャンネル勢い情報の取得に失敗しました", { 31 | appearance: "error", 32 | autoDismiss: true, 33 | }) 34 | }) 35 | }, [now]) 36 | 37 | return { channelComments } 38 | } 39 | -------------------------------------------------------------------------------- /src/atoms/setting.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | import * as $ from "zod" 3 | import { BackendSetting, PlayerSetting, SayaSetting } from "../types/setting" 4 | 5 | const prefix = "elaina:setting" 6 | 7 | export const upstreamSettingParser = $.object({ 8 | url: $.string(), 9 | user: $.string().nullable(), 10 | pass: $.string().nullable(), 11 | }) 12 | 13 | export const sayaSettingAtom = atom({ 14 | key: `${prefix}:saya`, 15 | default: { 16 | url: null, 17 | user: null, 18 | pass: null, 19 | }, 20 | }) 21 | 22 | export const backendSettingAtom = atom({ 23 | key: `${prefix}:backend`, 24 | default: { 25 | url: null, 26 | user: null, 27 | pass: null, 28 | }, 29 | }) 30 | 31 | export const playerSettingAtom = atom({ 32 | key: `${prefix}:player`, 33 | default: { 34 | commentDelay: null, 35 | recordCommentDelay: 1, 36 | useMpegTs: false, 37 | mpegTsMode: 1, 38 | }, 39 | }) 40 | 41 | export const playerSettingParser = $.object({ 42 | commentDelay: $.number().nullable().optional(), 43 | recordCommentDelay: $.number().nullable().optional(), 44 | useMpegTs: $.boolean().optional(), 45 | mpegTsMode: $.number().nullable(), 46 | }) 47 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | html { 2 | @apply bg-gray-900; 3 | } 4 | 5 | video::cue { 6 | font-family: sans-serif; 7 | font-size: 110%; 8 | background-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)); 9 | } 10 | 11 | .emoji { 12 | display: inline-block; 13 | width: auto; 14 | height: 1rem; 15 | } 16 | 17 | .timetableScrollContainer { 18 | contain: paint; 19 | max-height: calc(100vh - 162px); 20 | } 21 | 22 | .scrollbar-wh-4-200-600::-webkit-scrollbar-thumb { 23 | @apply bg-gray-600; 24 | } 25 | 26 | .scrollbar-wh-4-200-600::-webkit-scrollbar { 27 | @apply bg-gray-200; 28 | @apply w-2; 29 | @apply h-2; 30 | } 31 | 32 | .scrollbar-wh-2-200-600::-webkit-scrollbar-thumb { 33 | @apply bg-gray-600; 34 | } 35 | 36 | .scrollbar-wh-2-200-600::-webkit-scrollbar { 37 | @apply bg-gray-200; 38 | @apply w-1; 39 | @apply h-1; 40 | } 41 | 42 | .align-super { 43 | vertical-align: super; 44 | } 45 | 46 | .scrollbar-w-1-200-600::-webkit-scrollbar-thumb { 47 | @apply bg-gray-600; 48 | } 49 | 50 | .scrollbar-w-1-200-600::-webkit-scrollbar { 51 | @apply bg-gray-200; 52 | width: 1px; 53 | @apply h-0; 54 | } 55 | 56 | .scrollbar-h-2-200-600::-webkit-scrollbar-thumb { 57 | @apply bg-gray-200; 58 | } 59 | 60 | .scrollbar-h-2-200-600::-webkit-scrollbar { 61 | @apply bg-gray-600; 62 | @apply w-0; 63 | @apply h-1; 64 | } 65 | 66 | .scrollbar-h-1-200-600::-webkit-scrollbar-thumb { 67 | @apply bg-gray-600; 68 | } 69 | 70 | .scrollbar-h-1-200-600::-webkit-scrollbar { 71 | @apply bg-gray-200; 72 | @apply w-0; 73 | height: 1px; 74 | } 75 | 76 | .programDescription { 77 | a { 78 | @apply text-blue-600; 79 | } 80 | } 81 | 82 | .react-contexify__item--disabled { 83 | opacity: 1 !important; 84 | } 85 | -------------------------------------------------------------------------------- /src/components/global/RecoilWatcher.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useRecoilTransactionObserver_UNSTABLE } from "recoil" 3 | import { 4 | backendSettingAtom, 5 | playerSettingAtom, 6 | playerSettingParser, 7 | sayaSettingAtom, 8 | upstreamSettingParser, 9 | } from "../../atoms/setting" 10 | 11 | export const RecoilWatcher: React.VFC<{}> = () => { 12 | useRecoilTransactionObserver_UNSTABLE(({ snapshot }) => { 13 | for (const atom of snapshot.getNodes_UNSTABLE({ isModified: true })) { 14 | switch (atom.key) { 15 | case sayaSettingAtom.key: 16 | try { 17 | const snap = snapshot.getLoadable(sayaSettingAtom).getValue() 18 | upstreamSettingParser.parse(snap) 19 | localStorage.setItem(sayaSettingAtom.key, JSON.stringify(snap)) 20 | } catch (e) { 21 | console.error(e) 22 | } 23 | break 24 | case backendSettingAtom.key: 25 | try { 26 | const snap = snapshot.getLoadable(backendSettingAtom).getValue() 27 | upstreamSettingParser.parse(snap) 28 | localStorage.setItem(backendSettingAtom.key, JSON.stringify(snap)) 29 | } catch (e) { 30 | console.error(e) 31 | } 32 | break 33 | case playerSettingAtom.key: 34 | try { 35 | const snap = snapshot.getLoadable(playerSettingAtom).getValue() 36 | playerSettingParser.parse(snap) 37 | localStorage.setItem(playerSettingAtom.key, JSON.stringify(snap)) 38 | } catch (e) { 39 | console.error(e) 40 | } 41 | break 42 | default: 43 | break 44 | } 45 | } 46 | }) 47 | return <> 48 | } 49 | -------------------------------------------------------------------------------- /src/routes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Rocon from "rocon/react" 3 | import { ChannelsPage } from "./pages/channels" 4 | import { ChannelIdPage } from "./pages/channels/id" 5 | import { IndexPage } from "./pages/index" 6 | import { ProgramIdPage } from "./pages/programs/id" 7 | import { RecordsPage } from "./pages/records" 8 | import { RecordIdPage } from "./pages/records/id" 9 | import { SettingsPage } from "./pages/settings" 10 | import { TimetablePage } from "./pages/timetable" 11 | 12 | export const programsRoute = Rocon.Path() 13 | .any("id", { 14 | action: ({ id }) => , 15 | }) 16 | .exact({ 17 | action: () =>
programs
, 18 | }) 19 | 20 | export const recordsRoute = Rocon.Path() 21 | .any("id", { 22 | action: ({ id }) => , 23 | }) 24 | .exact({ 25 | action: () => , 26 | }) 27 | 28 | export const channelsRoute = Rocon.Path() 29 | .any("id", { 30 | action: ({ id }) => , 31 | }) 32 | .exact({ 33 | action: () => , 34 | }) 35 | 36 | export const routes = Rocon.Path() 37 | .exact({ 38 | action: () => , 39 | }) 40 | .route("timetable", (route) => route.action(() => )) 41 | .route("channels", (route) => route.attach(channelsRoute)) 42 | .route("programs", (route) => route.attach(programsRoute)) 43 | .route("records", (route) => route.attach(recordsRoute)) 44 | .route("settings", (route) => route.action(() => )) 45 | 46 | export const ExactlyRoutes = Rocon.Path() 47 | .exact({ 48 | action: () => , 49 | }) 50 | .route("elaina", (route) => route.attach(routes)) 51 | -------------------------------------------------------------------------------- /src/components/common/CaptureButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { Camera, MessageSquare } from "react-feather" 3 | import { useToasts } from "react-toast-notifications" 4 | import { capturePlayer } from "../../utils/capture" 5 | 6 | export const CaptureButton: React.VFC<{ withComment: boolean }> = ({ 7 | withComment, 8 | }) => { 9 | const toast = useToasts() 10 | const [isCaptureNow, setIsCaptureNow] = useState(false) 11 | 12 | return ( 13 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elaina logo elaina 2 | 3 | 鋭意開発中 4 | 5 | ## これはなに 6 | 7 | [l3tnun/EPGStation](https://github.com/l3tnun/EPGStation) と [SlashNephy/saya](https://github.com/SlashNephy/saya) で動くコメント付き DTV 視聴 Web アプリです。 8 | 9 | [![elaina 動作イメージ](https://i.gyazo.com/a028110a6b4befb003c5f3f9d045a663.jpg)](https://gyazo.com/a028110a6b4befb003c5f3f9d045a663) 10 | 11 | ## 使用 12 | 13 | 最新バージョンを [elaina.surge.sh](http://elaina.surge.sh) にて公開しています。
14 | HTTPS は強制されませんので、使用している EPGStation の Scheme に合わせて使用してください。
15 | EPGStation は `config.yml` にて `isAllowAllCORS: true` で CORS ヘッダーを付与するように設定してください([詳細](https://github.com/l3tnun/EPGStation/blob/723dacd3f0344615c6b9e766f2f00cbc17251cd1/doc/conf-manual.md#isallowallcors))。 16 | 17 | - [HTTP (http://elaina.surge.sh)](http://elaina.surge.sh) 18 | - [HTTPS (https://elaina.surge.sh)](https://elaina.surge.sh) 19 | 20 | 必須ではありませんが、字幕の表示に [Rounded M+ 1m for ARIB](https://github.com/xtne6f/TVCaptionMod2/blob/3cc6c1767595e1973473124e892a57c7693c2154/TVCaptionMod2_Readme.txt#L49-L50) を指定しているので、フォントのインストールを推奨します。[ダウンロードはこちら](https://github.com/ci7lus/MirakTest/files/6555741/rounded-mplus-1m-arib.ttf.zip)。 21 | 22 | ## 開発 23 | 24 | ```bash 25 | yarn 26 | yarn dev 27 | # ビルド 28 | yarn build 29 | ``` 30 | 31 | 開発に使用する EPGStation は上記使用セクションの手順に従って CORS ヘッダーの付与を行ってください。 32 | 33 | ## 謝辞 34 | 35 | elaina および [SlashNephy/saya](https://github.com/SlashNephy/saya) は次のプロジェクトを参考にして実装しています。 36 | 37 | - [Chinachu/Chinachu](https://github.com/Chinachu/Chinachu) 38 | - [tsukumijima/TVRemotePlus](https://github.com/tsukumijima/TVRemotePlus) 39 | - [l3tnun/EPGStation](https://github.com/l3tnun/EPGStation) 40 | 41 | DTV 実況コミュニティの皆さまに感謝します。 42 | 43 | ## ライセンス 44 | 45 | elaina は MIT ライセンスの下で提供されます。 46 | -------------------------------------------------------------------------------- /src/atoms/initialize.ts: -------------------------------------------------------------------------------- 1 | import { MutableSnapshot } from "recoil" 2 | import { 3 | backendSettingAtom, 4 | playerSettingAtom, 5 | playerSettingParser, 6 | sayaSettingAtom, 7 | upstreamSettingParser, 8 | } from "./setting" 9 | 10 | export const initializeState = (mutableSnapShot: MutableSnapshot) => { 11 | const savedSayaSetting = localStorage.getItem(sayaSettingAtom.key) 12 | if (savedSayaSetting) { 13 | try { 14 | const parsed = upstreamSettingParser.parse(JSON.parse(savedSayaSetting)) 15 | mutableSnapShot.set(sayaSettingAtom, parsed) 16 | } catch {} 17 | } else { 18 | const url = process.env.SAYA_URL 19 | const user = process.env.SAYA_AUTH_USER || null 20 | const pass = process.env.SAYA_AUTH_PASS || null 21 | if (url) { 22 | mutableSnapShot.set(sayaSettingAtom, { 23 | url, 24 | user, 25 | pass, 26 | }) 27 | } 28 | } 29 | 30 | const savedBackendSetting = localStorage.getItem(backendSettingAtom.key) 31 | if (savedBackendSetting) { 32 | try { 33 | const parsed = upstreamSettingParser.parse( 34 | JSON.parse(savedBackendSetting) 35 | ) 36 | mutableSnapShot.set(backendSettingAtom, parsed) 37 | } catch {} 38 | } else { 39 | const url = process.env.BACKEND_URL 40 | const user = process.env.BACKEND_AUTH_USER || null 41 | const pass = process.env.BACKEND_AUTH_PASS || null 42 | if (url) { 43 | mutableSnapShot.set(backendSettingAtom, { 44 | url, 45 | user, 46 | pass, 47 | }) 48 | } 49 | } 50 | 51 | const savedPlayerSetting = localStorage.getItem(playerSettingAtom.key) 52 | if (savedPlayerSetting) { 53 | try { 54 | const parsed = playerSettingParser.parse(JSON.parse(savedPlayerSetting)) 55 | mutableSnapShot.set(playerSettingAtom, parsed) 56 | } catch {} 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/settings/Saya.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Heading, Input } from "@chakra-ui/react" 2 | import React from "react" 3 | import { useForm } from "react-hook-form" 4 | import { useToasts } from "react-toast-notifications" 5 | import { useRecoilState } from "recoil" 6 | import { sayaSettingAtom } from "../../atoms/setting" 7 | import type { SayaSetting } from "../../types/setting" 8 | 9 | export const SayaSettingForm: React.FC<{}> = () => { 10 | const toast = useToasts() 11 | const { register, handleSubmit } = useForm() 12 | const [sayaSetting, setSayaSetting] = useRecoilState(sayaSettingAtom) 13 | const onSubmit = (data: SayaSetting) => { 14 | setSayaSetting(data) 15 | toast.addToast("設定を保存しました", { 16 | appearance: "success", 17 | autoDismiss: true, 18 | }) 19 | } 20 | 21 | return ( 22 |
23 | 32 | 33 | 認証設定 34 | 35 | 44 | 54 | 57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/records/SearchModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input, Portal } from "@chakra-ui/react" 2 | import React, { useState } from "react" 3 | 4 | export const RecordSearchModal: React.VFC<{ 5 | searchTerm: string | null 6 | setSearchTerm: React.Dispatch> 7 | onClose: Function 8 | }> = ({ searchTerm, setSearchTerm, onClose }) => { 9 | const [value, setValue] = useState(searchTerm || "") 10 | const onSubmitHandle = (event: React.FormEvent) => { 11 | event.preventDefault() 12 | event.stopPropagation() 13 | setSearchTerm(value || null) 14 | onClose() 15 | } 16 | return ( 17 | 18 |
19 |
onClose()} 22 | >
23 |
24 |
25 |
26 |
検索
27 | setValue(e.target.value || "")} 32 | /> 33 |
34 | 37 | 46 |
47 |
48 |
49 |
50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/settings/Backend.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Heading, Input } from "@chakra-ui/react" 2 | import React from "react" 3 | import { useForm } from "react-hook-form" 4 | import { useToasts } from "react-toast-notifications" 5 | import { useRecoilState } from "recoil" 6 | import { backendSettingAtom } from "../../atoms/setting" 7 | import type { BackendSetting } from "../../types/setting" 8 | 9 | export const BackendSettingForm: React.FC<{}> = () => { 10 | const toast = useToasts() 11 | const { register, handleSubmit } = useForm() 12 | const [backendSetting, setBackendSetting] = useRecoilState(backendSettingAtom) 13 | const onSubmit = (data: BackendSetting) => { 14 | setBackendSetting(data) 15 | toast.addToast("設定を保存しました", { 16 | appearance: "success", 17 | autoDismiss: true, 18 | }) 19 | } 20 | 21 | return ( 22 |
23 | 32 | 33 | 認証設定 34 | 35 | 44 | 54 | 57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/timetable/Channels.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/react" 2 | import React, { memo, useState } from "react" 3 | import { ArrowContainer, Popover } from "react-tiny-popover" 4 | import { Link } from "rocon/react" 5 | import { channelsRoute } from "../../routes" 6 | import { Schedule } from "../../types/struct" 7 | 8 | export const TimetableChannel: React.VFC<{ 9 | schedule: Schedule 10 | }> = memo(({ schedule }) => { 11 | const [isOpen, setIsOpen] = useState(false) 12 | 13 | return ( 14 | setIsOpen(false)} 20 | content={({ position, childRect, popoverRect }) => { 21 | return ( 22 | 29 |
30 |
{schedule.channel.name}
31 |
32 | 37 | 38 | 39 |
40 |
41 |
42 | ) 43 | }} 44 | > 45 |
setIsOpen((isOpen) => !isOpen)} 50 | > 51 | {schedule.channel.name} 52 |
53 |
54 | ) 55 | }) 56 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - .gitignore 7 | - LICENSE 8 | - "**.md" 9 | branches-ignore: 10 | - "releases/**" 11 | - "dependabot/**" 12 | 13 | release: 14 | types: 15 | - published 16 | 17 | workflow_dispatch: 18 | 19 | env: 20 | DOCKER_BASE_NAME: ci7lus/elaina 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Checkout Repository 28 | uses: actions/checkout@v2 29 | 30 | - name: Login to DockerHub 31 | uses: docker/login-action@v1 32 | if: github.event_name != 'pull_request' && github.repository == env.DOCKER_BASE_NAME 33 | with: 34 | username: ${{ secrets.DOCKERHUB_USERNAME }} 35 | password: ${{ secrets.DOCKERHUB_TOKEN }} 36 | 37 | - name: Build & Push (master) 38 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == env.DOCKER_BASE_NAME 39 | uses: docker/build-push-action@v2 40 | with: 41 | push: ${{ github.event_name != 'pull_request' }} 42 | tags: ${{ env.DOCKER_BASE_NAME }}:latest 43 | 44 | - name: Build & Push (dev) 45 | if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && github.repository == env.DOCKER_BASE_NAME 46 | uses: docker/build-push-action@v2 47 | with: 48 | push: ${{ github.event_name != 'pull_request' }} 49 | tags: ${{ env.DOCKER_BASE_NAME }}:dev 50 | 51 | - name: Build & Push (Release) 52 | if: github.event_name == 'release' && github.repository == env.DOCKER_BASE_NAME 53 | uses: docker/build-push-action@v2 54 | with: 55 | push: true 56 | tags: ${{ env.DOCKER_BASE_NAME }}:${{ github.event.release.tag_name }} 57 | 58 | # 2FA を無効化する必要がある 59 | # https://github.com/peter-evans/dockerhub-description#action-inputs 60 | # - name: Update Docker Hub description 61 | # if: github.event_name == 'push' && github.ref == 'refs/heads/master' 62 | # uses: peter-evans/dockerhub-description@v2 63 | # with: 64 | # username: ${{ secrets.DOCKERHUB_USERNAME }} 65 | # password: ${{ secrets.DOCKERHUB_TOKEN }} 66 | # repository: ${{ env.DOCKER_BASE_NAME }} 67 | -------------------------------------------------------------------------------- /src/hooks/television.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import { useEffect, useState } from "react" 3 | import { useToasts } from "react-toast-notifications" 4 | import { useRecoilState, useRecoilValue } from "recoil" 5 | import { 6 | configAtom, 7 | channelsAtom, 8 | filteredSchedulesSelector, 9 | schedulesAtom, 10 | } from "../atoms/television" 11 | import { Program } from "../types/struct" 12 | import { useBackend } from "./backend" 13 | 14 | export const useChannels = () => { 15 | const [channels, setChannels] = useRecoilState(channelsAtom) 16 | const toast = useToasts() 17 | const backend = useBackend() 18 | 19 | useEffect(() => { 20 | backend 21 | .getChannels() 22 | .then((schedules) => setChannels(schedules)) 23 | .catch((e) => { 24 | console.error(e) 25 | toast.addToast("番組表の取得に失敗しました", { 26 | appearance: "error", 27 | autoDismiss: true, 28 | }) 29 | }) 30 | }, []) 31 | 32 | return { channels } 33 | } 34 | 35 | export const useProgram = ({ id }: { id: number }) => { 36 | const [program, setProgram] = useState(null) 37 | const backend = useBackend() 38 | 39 | useEffect(() => { 40 | backend 41 | .getProgram({ id }) 42 | .then((program) => setProgram(program)) 43 | .catch(() => setProgram(false)) 44 | }, []) 45 | 46 | return { program } 47 | } 48 | 49 | export const useSchedules = ({ startAt }: { startAt?: number }) => { 50 | const [schedules, setSchedules] = useRecoilState(schedulesAtom) 51 | const filteredSchedules = useRecoilValue(filteredSchedulesSelector) 52 | const config = useRecoilValue(configAtom) 53 | 54 | const toast = useToasts() 55 | const backend = useBackend() 56 | 57 | useEffect(() => { 58 | if (!config) return 59 | const endAt = startAt && dayjs(startAt).add(1, "day").toDate().getTime() 60 | 61 | backend 62 | .getSchedules({ 63 | startAt, 64 | endAt, 65 | types: config.broadcast, 66 | }) 67 | .then((schedules) => setSchedules(schedules)) 68 | .catch((e) => { 69 | console.error(e) 70 | toast.addToast("番組表の取得に失敗しました", { 71 | appearance: "error", 72 | autoDismiss: true, 73 | }) 74 | }) 75 | }, [startAt, config]) 76 | 77 | return { schedules, filteredSchedules } 78 | } 79 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from "@chakra-ui/react" 2 | import React, { useEffect } from "react" 3 | import { ToastProvider, useToasts } from "react-toast-notifications" 4 | import { RecoilRoot, useRecoilState, useRecoilValue } from "recoil" 5 | import { isLocationNotFoundError, RoconRoot, useRoutes } from "rocon/react" 6 | import { initializeState } from "./atoms/initialize" 7 | import { backendSettingAtom } from "./atoms/setting" 8 | import { configAtom } from "./atoms/television" 9 | import { InitialSettingPage } from "./components/global/InitialSetting" 10 | import { NotFound } from "./components/global/NotFound" 11 | import { RecoilWatcher } from "./components/global/RecoilWatcher" 12 | import { useBackend } from "./hooks/backend" 13 | import { Layout } from "./layout" 14 | import { routes } from "./routes" 15 | 16 | const UsedRoutes: React.VFC<{}> = () => { 17 | try { 18 | return useRoutes(routes) 19 | } catch (e: unknown) { 20 | if (isLocationNotFoundError(e)) { 21 | return 22 | } else { 23 | console.error(e) 24 | return <> 25 | } 26 | } 27 | } 28 | 29 | const BackendRoute: React.VFC<{}> = () => { 30 | const backend = useBackend() 31 | const toast = useToasts() 32 | const [config, setConfig] = useRecoilState(configAtom) 33 | 34 | useEffect(() => { 35 | backend 36 | .getConfig() 37 | .then((config) => setConfig(config)) 38 | .catch((e) => { 39 | console.error(e) 40 | toast.addToast("設定の取得に失敗しました", { 41 | appearance: "error", 42 | autoDismiss: true, 43 | }) 44 | }) 45 | }, []) 46 | if (!config) return 47 | return 48 | } 49 | 50 | const Routes: React.VFC<{}> = () => { 51 | const backendSetting = useRecoilValue(backendSettingAtom) 52 | 53 | if (!backendSetting.url) return 54 | return 55 | } 56 | 57 | export const App: React.VFC = () => { 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/types/struct.ts: -------------------------------------------------------------------------------- 1 | export type Program = { 2 | id: number 3 | channelId: number 4 | startAt: number 5 | endAt: number 6 | isFree: boolean 7 | name: string 8 | description?: string 9 | extended?: string 10 | genre1: number 11 | subGenre1: number 12 | genre2: number 13 | subGenre2: number 14 | genre3: number 15 | subGenre3: number 16 | videoType: "mpeg2" 17 | videoResolution: string 18 | videoStreamContent: number 19 | videoComponentType: number 20 | audioSamplingRate: number 21 | audioComponentType: number 22 | } 23 | 24 | export type ProgramRecord = { 25 | id: number 26 | ruleId: number 27 | programId: number 28 | channelId: number 29 | startAt: number 30 | endAt: number 31 | name: string 32 | description?: string 33 | extended?: string 34 | genre1: number 35 | subGenre1: number 36 | genre2: number 37 | subGenre2: number 38 | genre3: number 39 | subGenre3: number 40 | videoType: "mpeg2" 41 | videoResolution: string 42 | videoStreamContent: number 43 | videoComponentType: number 44 | audioSamplingRate: number 45 | audioComponentType: number 46 | isRecording: boolean 47 | thumbnails: [number] 48 | videoFiles: { 49 | id: number 50 | name: string 51 | type: "ts" 52 | size: number 53 | }[] 54 | dropLog: { 55 | id: number 56 | errorCnt: number 57 | dropCnt: number 58 | scramblingCnt: number 59 | } 60 | tags: { 61 | id: number 62 | name: string 63 | color: string 64 | }[] 65 | isEncoding: boolean 66 | isProtected: boolean 67 | } 68 | 69 | export type ChannelType = "GR" | "BS" | "SKY" 70 | 71 | export type Channel = { 72 | id: number 73 | serviceId: number 74 | networkId: number 75 | name: string 76 | halfWidthName: string 77 | hasLogoData: boolean 78 | channelType: ChannelType 79 | channel: string 80 | } 81 | 82 | export type Schedule = { 83 | channel: Channel 84 | programs: Program[] 85 | } 86 | 87 | export type CommentPayload = { 88 | sourceUrl: string | null 89 | source: string 90 | no: number 91 | time: number 92 | timeMs: number 93 | author: string 94 | text: string 95 | color: string 96 | type: "right" 97 | commands: [] 98 | } 99 | 100 | export type Genre = { 101 | id: number 102 | main: string 103 | sub: string 104 | count: number 105 | } 106 | -------------------------------------------------------------------------------- /.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 | .parcel-cache 116 | parcel-bundle-reports 117 | .vercel 118 | -------------------------------------------------------------------------------- /src/infra/saya.ts: -------------------------------------------------------------------------------- 1 | import querystring from "querystring" 2 | import axios from "axios" 3 | import type { ChannelComment, CommentStats } from "../types/saya" 4 | import type { SayaSetting } from "../types/setting" 5 | import { ChannelType } from "../types/struct" 6 | 7 | export class SayaAPI { 8 | public url: string 9 | public user: string | null 10 | public pass: string | null 11 | constructor({ url, user, pass }: SayaSetting) { 12 | if (!url) throw new Error("Saya url is not provided") 13 | this.url = url.endsWith("/") ? url.substr(0, url.length - 1) : url 14 | this.user = user 15 | this.pass = pass 16 | } 17 | 18 | get wsUrl() { 19 | const sayaWS = new URL( 20 | this.url.startsWith("/") 21 | ? `http://${location.hostname}${this.url}` 22 | : this.url 23 | ) 24 | sayaWS.protocol = "wss:" 25 | return sayaWS.href 26 | } 27 | get client() { 28 | return axios.create({ 29 | baseURL: this.url, 30 | headers: { 31 | ...(this.isAuthorizationEnabled 32 | ? { 33 | Authorization: this.authorizationToken, 34 | } 35 | : {}), 36 | }, 37 | timeout: 1000 * 30, 38 | }) 39 | } 40 | 41 | getLiveCommentSocketUrl({ 42 | channelType, 43 | serviceId, 44 | }: { 45 | channelType: ChannelType 46 | serviceId: number 47 | }) { 48 | return `${this.wsUrl}/comments/${channelType}_${serviceId}/live` 49 | } 50 | getRecordCommentSocketUrl({ 51 | channelType, 52 | serviceId, 53 | startAt, 54 | endAt, 55 | }: { 56 | channelType: ChannelType 57 | serviceId: number 58 | startAt: number 59 | endAt: number 60 | }) { 61 | return `${ 62 | this.wsUrl 63 | }/comments/${channelType}_${serviceId}/timeshift?${querystring.stringify({ 64 | startAt, 65 | endAt, 66 | })}` 67 | } 68 | get isAuthorizationEnabled() { 69 | return !!(this.user && this.pass) 70 | } 71 | get authorizationToken() { 72 | return `Basic ${btoa(`${this.user}:${this.pass}`)}` 73 | } 74 | async getCommentStatus(serviceId: number) { 75 | const { data } = await this.client.get( 76 | `status/comments/${serviceId}` 77 | ) 78 | return data 79 | } 80 | async getChannelComments() { 81 | const { data } = await this.client.get("comments") 82 | return data 83 | } 84 | async getChannelComment({ 85 | channelType, 86 | serviceId, 87 | }: { 88 | channelType: ChannelType 89 | serviceId: number 90 | }) { 91 | const { data } = await this.client.get( 92 | `comments/${channelType}_${serviceId}/info` 93 | ) 94 | return data 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | tags-ignore: 8 | - "**" 9 | pull_request: 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - id: yarn-cache-dir-path 26 | run: echo "::set-output name=dir::$(yarn cache dir)" 27 | - uses: actions/cache@v2 28 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 29 | with: 30 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 31 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 32 | restore-keys: | 33 | ${{ runner.os }}-yarn- 34 | - name: Install 35 | run: | 36 | yarn 37 | - name: Lint 38 | run: | 39 | yarn lint:prettier 40 | yarn lint:eslint 41 | - name: Type inspection 42 | run: | 43 | yarn tsc 44 | build: 45 | runs-on: ubuntu-latest 46 | 47 | strategy: 48 | matrix: 49 | node-version: [12.x] 50 | 51 | needs: lint 52 | 53 | steps: 54 | - uses: actions/checkout@v1 55 | - name: Use Node.js ${{ matrix.node-version }} 56 | uses: actions/setup-node@v1 57 | with: 58 | node-version: ${{ matrix.node-version }} 59 | - id: yarn-cache-dir-path 60 | run: echo "::set-output name=dir::$(yarn cache dir)" 61 | - uses: actions/cache@v2 62 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 63 | with: 64 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 65 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 66 | restore-keys: | 67 | ${{ runner.os }}-yarn- 68 | - name: Install 69 | run: | 70 | yarn 71 | - name: Build 72 | run: | 73 | yarn build 74 | - name: Upload dist artifact 75 | uses: actions/upload-artifact@v2 76 | with: 77 | name: dist 78 | path: dist 79 | - name: Prepare to deploy to surge 80 | run: | 81 | cp dist/index.html dist/200.html 82 | - name: Deploy to surge 83 | if: ${{ github.ref == 'refs/heads/master' && github.repository_owner == 'ci7lus' }} 84 | uses: dswistowski/surge-sh-action@v1 85 | with: 86 | domain: "elaina.surge.sh" 87 | project: "./dist" 88 | login: ${{ secrets.SURGE_LOGIN }} 89 | token: ${{ secrets.SURGE_TOKEN }} 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elaina", 3 | "version": "1.0.0", 4 | "description": "so, tawashi-desu!", 5 | "author": "ci7lus <7887955+ci7lus@users.noreply.github.com>", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/ci7lus/elaina.git" 10 | }, 11 | "scripts": { 12 | "dev": "parcel src/index.html", 13 | "build": "parcel build src/index.html", 14 | "build:analyze": "env PARCEL_BUNDLE_ANALYZER=1 parcel build src/index.html", 15 | "lint:prettier": "prettier --check './src/**/*.{js,ts,tsx}'", 16 | "format:prettier": "prettier --write './src/**/*.{js,ts,tsx}'", 17 | "lint:eslint": "eslint --max-warnings 0 --cache './src/**/*.{js,ts,tsx}'", 18 | "format:eslint": "eslint './src/**/*.{js,ts,tsx}' --cache --fix", 19 | "serve": "servor dist index.html 1234" 20 | }, 21 | "private": true, 22 | "dependencies": { 23 | "@chakra-ui/react": "^1.6.10", 24 | "@emotion/react": "^11.4.1", 25 | "@emotion/styled": "^11.3.0", 26 | "@neneka/dplayer": "^1.26.0-patch.0", 27 | "aribb24.js": "^1.8.8", 28 | "axios": "^0.23.0", 29 | "dayjs": "^1.10.7", 30 | "framer-motion": "^4.1.17", 31 | "history": "^5.0.1", 32 | "hls-b24.js": "^1.0.0", 33 | "interweave": "^12.9.0", 34 | "interweave-autolink": "^4.4.3", 35 | "mpegts.js": "^1.6.10", 36 | "react": "^17.0.2", 37 | "react-contexify": "^5.0.0", 38 | "react-dom": "^17.0.2", 39 | "react-feather": "^2.0.9", 40 | "react-hook-form": "^7.17.4", 41 | "react-indiana-drag-scroll": "^2.1.0", 42 | "react-table": "^7.7.0", 43 | "react-tiny-popover": "^7.0.1", 44 | "react-toast-notifications": "^2.5.1", 45 | "react-use": "^17.3.1", 46 | "recoil": "^0.4.1", 47 | "reconnecting-websocket": "^4.4.0", 48 | "rocon": "^1.2.3", 49 | "servor": "^4.0.2", 50 | "swr": "^1.0.1", 51 | "twemoji": "^13.1.0", 52 | "zod": "^3.9.8" 53 | }, 54 | "devDependencies": { 55 | "@ci7lus/eslint-config": "^1.0.1", 56 | "@parcel/transformer-sass": "^2.2.0", 57 | "@types/dplayer": "^1.25.2", 58 | "@types/react": "17.0.30", 59 | "@types/react-dom": "17.0.9", 60 | "@types/react-table": "^7.7.6", 61 | "@types/react-toast-notifications": "^2.4.1", 62 | "@types/twemoji": "^12.1.2", 63 | "autoprefixer": "^10.3.7", 64 | "husky": "^7.0.2", 65 | "lint-staged": "^11.2.3", 66 | "parcel": "^2.2.0", 67 | "postcss": "^8.3.9", 68 | "prettier": "^2.4.1", 69 | "sass": "^1.43.2", 70 | "tailwindcss": "^2.2.17", 71 | "ts-node": "^10.3.0", 72 | "typescript": "^4.5.4" 73 | }, 74 | "alias": { 75 | "dplayer": "@neneka/dplayer" 76 | }, 77 | "peerDependencies": { 78 | "eslint": "^7.32.0" 79 | }, 80 | "lint-staged": { 81 | "*.{js,ts,tsx}": "eslint --max-warnings 0 --cache", 82 | "*.{js,ts,tsx,md}": "prettier" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/global/TheHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link } from "rocon/react" 3 | import { routes } from "../../routes" 4 | 5 | export const TheHeader: React.VFC<{}> = () => { 6 | return ( 7 |
8 |
9 |
10 | 11 |
12 |
13 | {/* Icon from FontAwesome https://fontawesome.com/icons/quidditch */} 14 | 29 |
30 |
31 | elaina γ 32 |
33 |
34 | 35 |
36 | 37 |
放送中
38 | 39 | 40 |
番組表
41 | 42 | 43 |
録画済
44 | 45 |
46 |
47 |
48 |
49 | 50 |
設定
51 | 52 |
53 |
54 |
55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/components/records/PlayerController.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Slider, 3 | SliderTrack, 4 | SliderFilledTrack, 5 | SliderThumb, 6 | } from "@chakra-ui/react" 7 | import React, { memo, useState } from "react" 8 | import { FastForward, SkipBack } from "react-feather" 9 | 10 | export const PlayerController: React.VFC<{ 11 | position: number 12 | duration: number 13 | seek: (n: number) => unknown 14 | isSeeking: boolean 15 | }> = memo(({ position, duration, seek, isSeeking }) => { 16 | const [isPreview, setIsPreview] = useState(false) 17 | const [previewPosition, setPreviewPosition] = useState(0) 18 | return ( 19 |
20 | 27 | 35 | 43 | {isPreview ? ( 44 |
45 | {Math.floor(previewPosition / 60) 46 | .toString() 47 | .padStart(2, "0")} 48 | :{(previewPosition % 60).toString().padStart(2, "0")} 49 |
50 | ) : ( 51 |
52 | {Math.floor(position / 60) 53 | .toString() 54 | .padStart(2, "0")} 55 | :{(position % 60).toString().padStart(2, "0")} 56 |
57 | )} 58 | { 63 | const { left, width } = event.currentTarget.getBoundingClientRect() 64 | const pos = event.pageX - left - window.pageXOffset 65 | const seekTo = Math.max(Math.round((pos / width) * duration), 0) 66 | seek(seekTo) 67 | setIsPreview(false) 68 | }} 69 | onMouseEnter={() => { 70 | !isSeeking && setIsPreview(true) 71 | }} 72 | onMouseMove={(event) => { 73 | const { left, width } = event.currentTarget.getBoundingClientRect() 74 | const pos = event.pageX - left - window.pageXOffset 75 | const seekTo = Math.max(Math.round((pos / width) * duration), 0) 76 | setPreviewPosition(seekTo) 77 | }} 78 | onMouseLeave={() => { 79 | setIsPreview(false) 80 | }} 81 | value={position} 82 | focusThumbOnChange={false} 83 | > 84 | 87 | 90 | 91 | 92 | 93 |
94 | ) 95 | }) 96 | -------------------------------------------------------------------------------- /src/pages/programs/id.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import React from "react" 3 | import { Link } from "rocon/react" 4 | import { AutoLinkedText } from "../../components/global/AutoLinkedText" 5 | import { Loading } from "../../components/global/Loading" 6 | import { NotFound } from "../../components/global/NotFound" 7 | import { Genre, SubGenre } from "../../constants" 8 | import { useNow } from "../../hooks/date" 9 | import { useChannels, useProgram } from "../../hooks/television" 10 | import { channelsRoute } from "../../routes" 11 | 12 | export const ProgramIdPage: React.FC<{ id: string }> = ({ id }) => { 13 | const pid = parseInt(id) 14 | const { program } = useProgram({ id: pid }) 15 | const { channels } = useChannels() 16 | const channel = 17 | program && 18 | channels && 19 | channels.find((channel) => channel.id === program.channelId) 20 | const now = useNow() 21 | 22 | if (program === null) return 23 | if (program === false) return 24 | 25 | const startAt = dayjs(program.startAt) 26 | const diff = startAt.diff(now, "minute") 27 | const endAt = dayjs(program.endAt) 28 | const isNow = now.isBefore(endAt) && diff <= 0 29 | const duration = (program.endAt - program.startAt) / 1000 30 | 31 | const genre1 = Genre[program.genre1] 32 | const subGenre1 = genre1 && SubGenre[program.genre1][program.subGenre1] 33 | const genre2 = Genre[program.genre2] 34 | const subGenre2 = genre2 && SubGenre[program.genre2][program.subGenre2] 35 | const genre3 = Genre[program.genre3] 36 | const subGenre3 = genre3 && SubGenre[program.genre3][program.subGenre3] 37 | 38 | return ( 39 |
40 |
41 |
{program.name}
42 |
43 | {startAt.format("MM/DD HH:mm")} 44 | 45 | [{Math.abs(diff)}分{0 < diff ? "後" : "前"}] 46 | 47 | - {endAt.format("HH:mm")} 48 | ({duration / 60}分間) 49 |
50 |
51 | {[ 52 | [genre1, subGenre1], 53 | [genre2, subGenre2], 54 | [genre3, subGenre3], 55 | ] 56 | .filter(([genre]) => !!genre) 57 | .map(([genre, subGenre]) => ( 58 |

59 | {genre} 60 | {subGenre && ` / ${subGenre}`} 61 |

62 | ))} 63 |
64 |
65 | 66 | {[program.description, program.extended] 67 | .filter((s) => !!s) 68 | .join("\n\n")} 69 | 70 |
71 |
72 |
73 |
74 | {channel ? ( 75 |
76 |
{channel.name}
77 |
78 | 82 | 85 | 86 |
87 |
88 | ) : ( 89 |
サービス不明
90 | )} 91 |
92 |
93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/components/settings/Player.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | NumberDecrementStepper, 4 | NumberIncrementStepper, 5 | NumberInput, 6 | NumberInputField, 7 | NumberInputStepper, 8 | Switch, 9 | } from "@chakra-ui/react" 10 | import React, { useState } from "react" 11 | import { useToasts } from "react-toast-notifications" 12 | import { useRecoilState } from "recoil" 13 | import { playerSettingAtom } from "../../atoms/setting" 14 | 15 | export const PlayerSettingForm: React.VFC<{}> = () => { 16 | const toast = useToasts() 17 | const [playerSetting, setPlayerSetting] = useRecoilState(playerSettingAtom) 18 | const [commentDelay, setCommentDelay] = useState(playerSetting.commentDelay) 19 | const [recordCommentDelay, setRecordCommentDelay] = useState( 20 | playerSetting.recordCommentDelay 21 | ) 22 | const [useMpegTs, setUseMpegTs] = useState(playerSetting.useMpegTs) 23 | const [mpegTsMode, setMpegTsMode] = useState(playerSetting.mpegTsMode) 24 | const onSubmit = (e: React.FormEvent) => { 25 | e.preventDefault() 26 | setPlayerSetting({ 27 | commentDelay, 28 | recordCommentDelay, 29 | useMpegTs, 30 | mpegTsMode, 31 | }) 32 | toast.addToast("設定を保存しました", { 33 | appearance: "success", 34 | autoDismiss: true, 35 | }) 36 | } 37 | 38 | return ( 39 |
40 |
41 | 57 |
58 |
59 | 75 |
76 |
77 | 90 |
91 | {useMpegTs && ( 92 |
93 | 109 |
110 | )} 111 | 114 |
115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /src/components/channels/StatsWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@chakra-ui/react" 2 | import dayjs from "dayjs" 3 | import React, { useEffect, useState } from "react" 4 | import { RefreshCw } from "react-feather" 5 | import { useNow } from "../../hooks/date" 6 | import { useSaya } from "../../hooks/saya" 7 | import { CommentStats } from "../../types/saya" 8 | 9 | export const StatsWidget: React.VFC<{ 10 | serviceId: number 11 | socket: React.MutableRefObject 12 | }> = ({ serviceId, socket }) => { 13 | const saya = useSaya() 14 | const [updated, setUpdated] = useState(null) 15 | const [loading, setLoading] = useState(true) 16 | const [stats, setStats] = useState(null) 17 | const reload = () => { 18 | setLoading(true) 19 | if (!saya) return 20 | saya 21 | .getCommentStatus(serviceId) 22 | .then((stats) => { 23 | setStats(stats) 24 | setUpdated(dayjs().format("HH:mm:ss")) 25 | }) 26 | .finally(() => setLoading(false)) 27 | } 28 | const now = useNow() 29 | useEffect(() => { 30 | reload() 31 | }, [socket.current, now]) 32 | 33 | return ( 34 |
35 |
36 |
コメントソース{updated && ` (${updated})`}
37 | 45 |
46 | {stats === null ? ( 47 |
48 | {loading ? ( 49 | 50 | ) : ( 51 |

読み込みに失敗しました

52 | )} 53 |
54 | ) : ( 55 |
56 | {stats.nico ? ( 57 |
58 | 67 |
68 |
来場{stats.nico.viewers.toLocaleString()}人
69 |
{stats.nico.comments.toLocaleString()}コメント
70 |
71 | {stats.nico.commentsPerMinute.toLocaleString()}コメント/分 72 |
73 | {0 < stats.nico.adPoints && ( 74 |
{stats.nico.adPoints.toLocaleString()}広告pt
75 | )} 76 | {0 < stats.nico.giftPoints && ( 77 |
{stats.nico.giftPoints.toLocaleString()}ギフトpt
78 | )} 79 |
80 |
81 | ) : ( 82 |
83 | 対象の生放送がありません 84 |
85 | )} 86 | {stats.twitter.map((twitter, idx) => ( 87 |
88 | 99 |
100 |
{twitter.comments.toLocaleString()}コメント
101 |
102 | {twitter.commentsPerMinute.toLocaleString()}コメント/分 103 |
104 |
105 |
106 | ))} 107 |
108 | )} 109 |
110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/types/react-table-config.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseColumnOrderInstanceProps, 3 | UseColumnOrderState, 4 | UseExpandedHooks, 5 | UseExpandedInstanceProps, 6 | UseExpandedOptions, 7 | UseExpandedRowProps, 8 | UseExpandedState, 9 | UseFiltersColumnOptions, 10 | UseFiltersColumnProps, 11 | UseFiltersInstanceProps, 12 | UseFiltersOptions, 13 | UseFiltersState, 14 | UseGlobalFiltersColumnOptions, 15 | UseGlobalFiltersInstanceProps, 16 | UseGlobalFiltersOptions, 17 | UseGlobalFiltersState, 18 | UseGroupByCellProps, 19 | UseGroupByColumnOptions, 20 | UseGroupByColumnProps, 21 | UseGroupByHooks, 22 | UseGroupByInstanceProps, 23 | UseGroupByOptions, 24 | UseGroupByRowProps, 25 | UseGroupByState, 26 | UsePaginationInstanceProps, 27 | UsePaginationOptions, 28 | UsePaginationState, 29 | UseResizeColumnsColumnOptions, 30 | UseResizeColumnsColumnProps, 31 | UseResizeColumnsOptions, 32 | UseResizeColumnsState, 33 | UseRowSelectHooks, 34 | UseRowSelectInstanceProps, 35 | UseRowSelectOptions, 36 | UseRowSelectRowProps, 37 | UseRowSelectState, 38 | UseRowStateCellProps, 39 | UseRowStateInstanceProps, 40 | UseRowStateOptions, 41 | UseRowStateRowProps, 42 | UseRowStateState, 43 | UseSortByColumnOptions, 44 | UseSortByColumnProps, 45 | UseSortByHooks, 46 | UseSortByInstanceProps, 47 | UseSortByOptions, 48 | UseSortByState, 49 | } from "react-table" 50 | 51 | declare module "react-table" { 52 | // take this file as-is, or comment out the sections that don't apply to your plugin configuration 53 | 54 | export interface TableOptions< 55 | D extends Record 56 | > extends UseExpandedOptions, 57 | UseFiltersOptions, 58 | UseGlobalFiltersOptions, 59 | UseGroupByOptions, 60 | UsePaginationOptions, 61 | UseResizeColumnsOptions, 62 | UseRowSelectOptions, 63 | UseRowStateOptions, 64 | UseSortByOptions, 65 | // note that having Record here allows you to add anything to the options, this matches the spirit of the 66 | // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your 67 | // feature set, this is a safe default. 68 | Record {} 69 | 70 | export interface Hooks< 71 | D extends Record = Record 72 | > extends UseExpandedHooks, 73 | UseGroupByHooks, 74 | UseRowSelectHooks, 75 | UseSortByHooks {} 76 | 77 | export interface TableInstance< 78 | D extends Record = Record 79 | > extends UseColumnOrderInstanceProps, 80 | UseExpandedInstanceProps, 81 | UseFiltersInstanceProps, 82 | UseGlobalFiltersInstanceProps, 83 | UseGroupByInstanceProps, 84 | UsePaginationInstanceProps, 85 | UseRowSelectInstanceProps, 86 | UseRowStateInstanceProps, 87 | UseSortByInstanceProps {} 88 | 89 | export interface TableState< 90 | D extends Record = Record 91 | > extends UseColumnOrderState, 92 | UseExpandedState, 93 | UseFiltersState, 94 | UseGlobalFiltersState, 95 | UseGroupByState, 96 | UsePaginationState, 97 | UseResizeColumnsState, 98 | UseRowSelectState, 99 | UseRowStateState, 100 | UseSortByState {} 101 | 102 | export interface ColumnInterface< 103 | D extends Record = Record 104 | > extends UseFiltersColumnOptions, 105 | UseGlobalFiltersColumnOptions, 106 | UseGroupByColumnOptions, 107 | UseResizeColumnsColumnOptions, 108 | UseSortByColumnOptions {} 109 | 110 | export interface ColumnInstance< 111 | D extends Record = Record 112 | > extends UseFiltersColumnProps, 113 | UseGroupByColumnProps, 114 | UseResizeColumnsColumnProps, 115 | UseSortByColumnProps {} 116 | 117 | export interface Cell< 118 | D extends Record = Record 119 | > extends UseGroupByCellProps, 120 | UseRowStateCellProps {} 121 | 122 | export interface Row< 123 | D extends Record = Record 124 | > extends UseExpandedRowProps, 125 | UseGroupByRowProps, 126 | UseRowSelectRowProps, 127 | UseRowStateRowProps {} 128 | } 129 | -------------------------------------------------------------------------------- /src/components/top/Recordings.tsx: -------------------------------------------------------------------------------- 1 | import { Progress, Spinner } from "@chakra-ui/react" 2 | import dayjs from "dayjs" 3 | import React, { useState } from "react" 4 | import { useEffect } from "react" 5 | import { RefreshCw } from "react-feather" 6 | import { Link, useNavigate } from "rocon/react" 7 | import { useBackend } from "../../hooks/backend" 8 | import { useNow } from "../../hooks/date" 9 | import { useChannels } from "../../hooks/television" 10 | import { channelsRoute, programsRoute } from "../../routes" 11 | import { ProgramRecord } from "../../types/struct" 12 | 13 | export const RecordingsWidget: React.VFC<{}> = () => { 14 | const [loading, setLoading] = useState(true) 15 | const [recordings, setRecordings] = useState(null) 16 | const backend = useBackend() 17 | const reload = () => { 18 | setLoading(true) 19 | backend 20 | .getRecordings({}) 21 | .then((recordings) => { 22 | setRecordings(recordings) 23 | }) 24 | .finally(() => setLoading(false)) 25 | } 26 | useEffect(() => reload(), []) 27 | const now = useNow() 28 | const { channels } = useChannels() 29 | const navigate = useNavigate() 30 | 31 | return ( 32 |
33 |
34 |
録画中
35 | 44 |
45 | {!recordings || recordings.length === 0 ? ( 46 |
47 | {loading ? ( 48 | 49 | ) : ( 50 |

51 | {recordings === null 52 | ? "読み込みに失敗しました" 53 | : "録画中の番組はありません"} 54 |

55 | )} 56 |
57 | ) : ( 58 |
59 | {recordings.map((recording) => { 60 | const channel = channels?.find( 61 | (channel) => channel.id === recording.channelId 62 | ) 63 | const duration = (recording.endAt - recording.startAt) / 1000 / 60 64 | const diff = now.diff(recording.startAt, "minute") 65 | return ( 66 | 71 |
72 |

73 | 81 | ● 82 | 83 | {recording.name} 84 |

85 |

86 | {`${dayjs(recording.startAt).format("HH:mm")} [${Math.abs( 87 | diff 88 | )}分前] - ${dayjs(recording.endAt).format( 89 | "HH:mm" 90 | )} (${duration}分間) / `} 91 | {channel ? ( 92 | 104 | ) : ( 105 | 不明 106 | )} 107 |

108 |

{recording.description}

109 | 114 |
115 | 116 | ) 117 | })} 118 |
119 | )} 120 |
121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /src/components/common/CommentList.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import React, { memo, useRef, useState } from "react" 3 | import { 4 | Item, 5 | ItemParams, 6 | Menu, 7 | Separator, 8 | useContextMenu, 9 | animation, 10 | } from "react-contexify" 11 | import { useToasts } from "react-toast-notifications" 12 | import { useDebounce } from "react-use" 13 | import twemoji from "twemoji" 14 | import { CommentPayload } from "../../types/struct" 15 | import "react-contexify/dist/ReactContexify.css" 16 | 17 | const menuId = "comments-menu" 18 | 19 | const Comment: React.VFC<{ 20 | comment: CommentPayload 21 | setComment: React.Dispatch> 22 | }> = memo(({ comment, setComment }) => { 23 | const time = dayjs(comment.time * 1000 + comment.timeMs) 24 | 25 | const { show } = useContextMenu({ 26 | id: menuId, 27 | }) 28 | const displayMenu = (e: React.MouseEvent) => { 29 | setComment(comment) 30 | show(e, { props: comment }) 31 | } 32 | return ( 33 |
38 |

42 | {time.format("mm:ss")} 43 |

44 |

49 |
50 | ) 51 | }) 52 | 53 | export const CommentList: React.VFC<{ 54 | comments: CommentPayload[] 55 | isAutoScrollEnabled: boolean 56 | }> = ({ comments, isAutoScrollEnabled }) => { 57 | const ref = useRef(null) 58 | useDebounce( 59 | () => { 60 | if (ref.current && isAutoScrollEnabled === true) { 61 | ref.current.scrollTo({ 62 | top: ref.current.scrollHeight, 63 | behavior: "smooth", 64 | }) 65 | } 66 | }, 67 | 50, 68 | [comments] 69 | ) 70 | 71 | const toast = useToasts() 72 | 73 | const [ctxComment, setCtxComment] = useState(null) 74 | 75 | async function handleItemClick({ 76 | event, 77 | props, 78 | }: ItemParams) { 79 | if (!props) return 80 | switch (event.currentTarget.id) { 81 | case "copyComment": 82 | try { 83 | await navigator.clipboard.writeText(props.text) 84 | toast.addToast("コメントをコピーしました", { 85 | appearance: "success", 86 | autoDismiss: true, 87 | }) 88 | } catch (error) { 89 | console.error(error) 90 | } 91 | break 92 | case "copyAuthor": 93 | try { 94 | await navigator.clipboard.writeText(props.author) 95 | toast.addToast("オーサーをコピーしました", { 96 | appearance: "success", 97 | autoDismiss: true, 98 | }) 99 | } catch (error) { 100 | console.error(error) 101 | } 102 | break 103 | case "copyTime": 104 | try { 105 | await navigator.clipboard.writeText( 106 | dayjs(props.time * 1000 + props.timeMs).format() 107 | ) 108 | toast.addToast("投稿時間をコピーしました", { 109 | appearance: "success", 110 | autoDismiss: true, 111 | }) 112 | } catch (error) { 113 | console.error(error) 114 | } 115 | break 116 | } 117 | } 118 | 119 | return ( 120 |
124 | {comments.map((i, idx) => ( 125 | 126 | ))} 127 | 132 | 133 |

139 |
140 | 141 | 142 | {ctxComment?.author} 143 | 144 | 145 | {ctxComment && 146 | dayjs(ctxComment.time * 1000 + ctxComment.timeMs).format()} 147 | 148 | 149 | {ctxComment && 150 | (ctxComment?.sourceUrl ? ( 151 | 152 | {ctxComment.source} 153 | 154 | ) : ( 155 | ctxComment.source 156 | ))} 157 | 158 |
159 |
160 | ) 161 | } 162 | -------------------------------------------------------------------------------- /src/components/top/Streams.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@chakra-ui/react" 2 | import dayjs from "dayjs" 3 | import React, { useState } from "react" 4 | import { useEffect } from "react" 5 | import { RefreshCw } from "react-feather" 6 | import { useToasts } from "react-toast-notifications" 7 | import { Link } from "rocon/react" 8 | import { useBackend } from "../../hooks/backend" 9 | import { useChannels } from "../../hooks/television" 10 | import { channelsRoute } from "../../routes" 11 | import { Stream } from "../../types/epgstation" 12 | 13 | export const StreamsWidget: React.VFC<{}> = () => { 14 | const [loading, setLoading] = useState(true) 15 | const [streams, setStreams] = useState(null) 16 | const backend = useBackend() 17 | const toast = useToasts() 18 | const reload = () => { 19 | setLoading(true) 20 | backend 21 | .getStreams() 22 | .then((streams) => { 23 | setStreams(streams) 24 | }) 25 | .finally(() => setLoading(false)) 26 | } 27 | useEffect(() => reload(), []) 28 | const { channels } = useChannels() 29 | 30 | return ( 31 |
32 |
33 |
ストリーム一覧
34 | 43 |
44 | {!streams || streams.length === 0 ? ( 45 |
46 | {loading ? ( 47 | 48 | ) : ( 49 |

50 | {streams === null 51 | ? "読み込みに失敗しました" 52 | : "アクティブなストリームはありません"} 53 |

54 | )} 55 |
56 | ) : ( 57 |
58 | {streams.map((stream) => { 59 | const channel = channels?.find( 60 | (channel) => channel.id === stream.channelId 61 | ) 62 | return ( 63 |
67 |

68 | 76 | ● 77 | 78 | {stream.name} 79 |

80 |

81 | {`${dayjs(stream.startAt).format( 82 | "YYYY/MM/DD HH:mm" 83 | )} - ${dayjs(stream.endAt).format("HH:mm")} (${ 84 | (stream.endAt - stream.startAt) / 1000 / 60 85 | }分間) / `} 86 | {channel ? ( 87 | 92 | {channel.name} 93 | 94 | ) : ( 95 | 不明 96 | )} 97 |

98 |

99 | {`${stream.type} - ストリーム: ${stream.streamId} - モード: ${stream.mode}`} 100 |

101 |
102 | 129 |
130 |
131 | ) 132 | })} 133 |
134 | )} 135 |
136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "esnext", 11 | "dom" 12 | ] /* Specify library files to be included in the compilation. */, 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./dist" /* Redirect output structure to the directory. */, 21 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 22 | // "composite": true, /* Enable project compilation */ 23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | "noEmit": true /* Do not emit outputs. */, 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true /* Enable all strict type-checking options. */, 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 39 | 40 | /* Additional Checks */ 41 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 45 | 46 | /* Module Resolution Options */ 47 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true /* Skip type checking of declaration files. */, 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 71 | "resolveJsonModule": true 72 | }, 73 | "include": ["src"], 74 | "exclude": ["node_modules"] 75 | } 76 | -------------------------------------------------------------------------------- /src/infra/epgstation.ts: -------------------------------------------------------------------------------- 1 | import querystring from "querystring" 2 | import axios from "axios" 3 | import dayjs from "dayjs" 4 | import { ApiDocs, Config, Stream } from "../types/epgstation" 5 | import type { BackendSetting } from "../types/setting" 6 | import type { ProgramRecord, Channel, Schedule, Program } from "../types/struct" 7 | 8 | export class EPGStationAPI { 9 | public url: string 10 | public user: string | null 11 | public pass: string | null 12 | constructor({ url, user, pass }: BackendSetting) { 13 | if (!url) throw new Error("EPGStation url is not provided") 14 | this.url = url.endsWith("/") ? url.substr(0, url.length - 1) : url 15 | this.user = user 16 | this.pass = pass 17 | } 18 | 19 | get client() { 20 | return axios.create({ 21 | baseURL: this.url, 22 | headers: { 23 | ...(this.isAuthorizationEnabled 24 | ? { 25 | Authorization: this.authorizationToken, 26 | } 27 | : {}), 28 | }, 29 | timeout: 1000 * 30, 30 | }) 31 | } 32 | async getApiDocs() { 33 | const { data } = await this.client.get("api/docs") 34 | return data 35 | } 36 | async getConfig() { 37 | const { data } = await this.client.get("api/config") 38 | return data 39 | } 40 | async startChannelHlsStream({ id, mode = 0 }: { id: number; mode?: number }) { 41 | const { data } = await this.client.get<{ streamId: number }>( 42 | `${this.url}/api/streams/live/${id}/hls?${querystring.stringify({ 43 | mode, 44 | })}` 45 | ) 46 | return data.streamId 47 | } 48 | async getStreams() { 49 | const { data } = await this.client.get<{ items: Stream[] }>("api/streams", { 50 | params: { 51 | isHalfWidth: true, 52 | }, 53 | }) 54 | return data.items 55 | } 56 | async keepStream({ id }: { id: number }) { 57 | await this.client.put(`api/streams/${id}/keep`, {}) 58 | } 59 | async dropStream({ id }: { id: number }) { 60 | await this.client.delete(`api/streams/${id}`) 61 | } 62 | getHlsStreamUrl({ id }: { id: number }) { 63 | return `${this.url}/streamfiles/stream${id}.m3u8` 64 | } 65 | getM2tsStreamUrl({ id, mode = 0 }: { id: number; mode?: number }) { 66 | return `${this.url}/api/streams/live/${id}/m2ts?${querystring.stringify({ 67 | mode, 68 | })}` 69 | } 70 | async startRecordHlsStream({ 71 | id, 72 | ss = 0, 73 | mode = 0, 74 | }: { 75 | id: number 76 | ss?: number 77 | mode?: number 78 | }) { 79 | const { data } = await this.client.get<{ streamId: number }>( 80 | `${this.url}/api/streams/recorded/${id}/hls?${querystring.stringify({ 81 | mode, 82 | ss, 83 | })}` 84 | ) 85 | return data.streamId 86 | } 87 | getChannelLogoUrl({ id }: { id: number }) { 88 | return `${this.url}/channels/${id}/logo` 89 | } 90 | get isAuthorizationEnabled() { 91 | return !!(this.user && this.pass) 92 | } 93 | get authorizationToken() { 94 | return `Basic ${btoa(`${this.user}:${this.pass}`)}` 95 | } 96 | async getChannels() { 97 | const { data } = await this.client.get("api/channels") 98 | return data.map((channel) => ({ 99 | ...channel, 100 | name: channel.halfWidthName.trim() ? channel.halfWidthName : channel.name, 101 | })) 102 | } 103 | async getProgram({ id }: { id: number }) { 104 | const { data } = await this.client.get( 105 | `api/schedules/detail/${id}`, 106 | { 107 | params: { 108 | isHalfWidth: true, 109 | }, 110 | } 111 | ) 112 | return data 113 | } 114 | async getSchedules({ 115 | startAt = dayjs().toDate().getTime(), 116 | endAt = dayjs().add(1, "day").toDate().getTime(), 117 | types = { 118 | GR: true, 119 | BS: true, 120 | CS: true, 121 | SKY: true, 122 | }, 123 | }: { 124 | startAt?: number 125 | endAt?: number 126 | types?: { [key: string]: boolean } 127 | }) { 128 | const { data } = await this.client.get("api/schedules", { 129 | params: { 130 | isHalfWidth: true, 131 | startAt, 132 | endAt, 133 | ...types, 134 | }, 135 | }) 136 | return data 137 | } 138 | async getChannelSchedules({ 139 | channelId, 140 | startAt = dayjs().toDate().getTime(), 141 | days, 142 | }: { 143 | channelId: number 144 | startAt?: number 145 | days: number 146 | }) { 147 | const { data } = await this.client.get<[Schedule]>( 148 | `api/schedules/${channelId}`, 149 | { 150 | params: { 151 | isHalfWidth: true, 152 | startAt, 153 | days, 154 | }, 155 | } 156 | ) 157 | return data[0] 158 | } 159 | async getRecords({ 160 | offset = 0, 161 | limit = 24, 162 | isReverse, 163 | ruleId, 164 | channelId, 165 | genre, 166 | keyword, 167 | hasOriginalFile, 168 | }: { 169 | offset?: number 170 | limit?: number 171 | isReverse?: boolean 172 | ruleId?: number 173 | channelId?: number 174 | genre?: number 175 | keyword?: string 176 | hasOriginalFile?: boolean 177 | }) { 178 | const { data } = await this.client.get<{ 179 | records: ProgramRecord[] 180 | total: number 181 | }>("api/recorded", { 182 | params: { 183 | isHalfWidth: true, 184 | offset, 185 | limit, 186 | isReverse, 187 | ruleId, 188 | channelId, 189 | genre, 190 | keyword, 191 | hasOriginalFile, 192 | }, 193 | }) 194 | return data 195 | } 196 | async getRecord({ id }: { id: number }) { 197 | const { data } = await this.client.get( 198 | `api/recorded/${id}`, 199 | { 200 | params: { isHalfWidth: true }, 201 | } 202 | ) 203 | return data 204 | } 205 | async getRecordings({ offset, limit }: { offset?: number; limit?: number }) { 206 | const { data } = await this.client.get<{ records: ProgramRecord[] }>( 207 | "api/recording", 208 | { params: { isHalfWidth: true, offset, limit } } 209 | ) 210 | return data.records 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/pages/timetable/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@chakra-ui/react" 2 | import React, { useEffect, useRef, useState } from "react" 3 | import ScrollContainer from "react-indiana-drag-scroll" 4 | import { useThrottleFn } from "react-use" 5 | import { Loading } from "../../components/global/Loading" 6 | import { TimetableChannel } from "../../components/timetable/Channels" 7 | import { TimetableProgramList } from "../../components/timetable/Programs" 8 | import { LeftTimeBar } from "../../components/timetable/TimetableParts" 9 | import { useNow } from "../../hooks/date" 10 | import { useSchedules } from "../../hooks/television" 11 | 12 | export const TimetablePage: React.VFC<{}> = () => { 13 | const now = useNow() 14 | const [add, setAdd] = useState(0) 15 | const startAt = now.clone().add(add, "day").startOf("hour") 16 | const startAtInString = startAt.format() 17 | const scrollRef = useRef(null) 18 | const [clientLeft, setClientLeft] = useState(0) 19 | const [clientTop, setClientTop] = useState(0) 20 | const [clientWidth, setClientWidth] = useState(0) 21 | const [clientHeight, setClientHeight] = useState(0) 22 | const client = useThrottleFn( 23 | (clientLeft, clientTop, clientWidth, clientHeight) => ({ 24 | left: clientLeft, 25 | top: clientTop, 26 | width: Math.max(clientWidth, scrollRef.current?.clientWidth || 0), 27 | height: Math.max(clientHeight, scrollRef.current?.clientHeight || 0), 28 | }), 29 | 200, 30 | [clientLeft, clientTop, clientWidth, clientHeight] 31 | ) 32 | 33 | const timeBarPosition = (now.minute() / 60) * 180 34 | 35 | const { filteredSchedules } = useSchedules({ 36 | startAt: startAt.toDate().getTime(), 37 | }) 38 | 39 | const onResize = () => { 40 | const el = scrollRef.current 41 | if (!el) return 42 | setClientWidth(el.clientWidth || 0) 43 | setClientHeight(el.clientHeight || 0) 44 | } 45 | 46 | const onScroll = () => { 47 | const el = scrollRef.current 48 | if (!el) return 49 | setClientLeft(el.scrollLeft) 50 | setClientTop(el.scrollTop) 51 | onResize() 52 | } 53 | 54 | useEffect(() => { 55 | onResize() 56 | window.addEventListener("resize", onResize) 57 | 58 | return () => { 59 | window.removeEventListener("resize", onResize) 60 | } 61 | }, []) 62 | return ( 63 |
64 |
65 |
66 | 67 | 番組表 68 | 69 | 73 | {[...Array(7).keys()].map((i) => { 74 | const date = now.clone().add(i, "day") 75 | const weekday = date.format("dd") 76 | const color = 77 | weekday === "日" 78 | ? "text-red-400" 79 | : weekday === "土" 80 | ? "text-blue-400" 81 | : "" 82 | return ( 83 | 96 | ) 97 | })} 98 | 99 |
100 |
101 |
102 |
109 | {filteredSchedules && 110 | filteredSchedules.map((schedule) => ( 111 | 112 | ))} 113 |
114 |
115 | 123 | {filteredSchedules && client ? ( 124 |
132 |
133 |
134 | 139 |
140 |
144 | 145 |
146 |
154 |
155 |
156 | ) : ( 157 | 158 | )} 159 | 160 |
161 | ) 162 | } 163 | -------------------------------------------------------------------------------- /src/pages/channels/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Progress, 3 | Tab, 4 | TabList, 5 | TabPanel, 6 | TabPanels, 7 | Tabs, 8 | } from "@chakra-ui/react" 9 | import dayjs from "dayjs" 10 | import React from "react" 11 | import { ChevronsRight } from "react-feather" 12 | import { Link } from "rocon/react" 13 | import { Genre, SubGenre } from "../../constants" 14 | import { useNow } from "../../hooks/date" 15 | import { useChannelComments } from "../../hooks/saya" 16 | import { useSchedules } from "../../hooks/television" 17 | import { channelsRoute } from "../../routes" 18 | import { genreColors } from "../../utils/genres" 19 | 20 | export const ChannelsPage: React.VFC<{}> = () => { 21 | const now = useNow() 22 | const startAt = now.clone().startOf("hour") 23 | 24 | const { filteredSchedules } = useSchedules({ 25 | startAt: startAt.toDate().getTime(), 26 | }) 27 | const channelTypes = Array.from( 28 | new Set(filteredSchedules?.map((schedule) => schedule.channel.channelType)) 29 | ) 30 | 31 | const { channelComments } = useChannelComments() 32 | 33 | const _selected = { color: "white", bg: "blue.400" } 34 | 35 | return ( 36 |
37 | 38 | 39 | {channelTypes.map((channelType) => ( 40 | 41 | {channelType} 42 | 43 | ))} 44 | 45 | 46 | {channelTypes.map((channelType) => ( 47 | 48 | {filteredSchedules 49 | ?.filter( 50 | (schedule) => schedule.channel.channelType === channelType 51 | ) 52 | .map((schedule) => { 53 | const channel = schedule.channel 54 | const programs = schedule.programs 55 | .filter((program) => now.isBefore(program.endAt)) 56 | .sort((a, b) => (b.startAt < a.startAt ? 1 : -1)) 57 | const _program = programs.shift() 58 | const program = 59 | _program && now.isAfter(_program.startAt) ? _program : null 60 | const nextProgram = 61 | program === null ? _program : programs.shift() 62 | const programDiff = 63 | (program && now.diff(program.startAt, "minute")) || 0 64 | const genre = program && Genre[program.genre1] 65 | const subGenre = 66 | program && 67 | genre && 68 | SubGenre[program.genre1][program?.subGenre1] 69 | const duration = 70 | (program && 71 | (program.endAt - program.startAt) / 1000 / 60) || 72 | 1 73 | const programColor = program 74 | ? genre 75 | ? genreColors[genre] 76 | : "" 77 | : "bg-gray-200" 78 | const channelComment = channelComments?.find((ch) => 79 | ch.channel.serviceIds.includes(channel.serviceId) 80 | ) 81 | return ( 82 | 87 |
90 |

{channel.name}

91 | {program ? ( 92 |
93 |

{program.name}

94 |

{`${dayjs( 95 | program.startAt 96 | ).format("HH:mm")} [${Math.abs( 97 | programDiff 98 | )}分前] - ${dayjs(program.endAt).format( 99 | "HH:mm" 100 | )} (${duration}分間)${ 101 | genre 102 | ? ` - ${genre}${ 103 | subGenre ? ` / ${subGenre}` : "" 104 | }` 105 | : "" 106 | }`}

107 |

{program.description}

108 | 113 |
114 | ) : ( 115 |

116 | 放送中の番組はありません 117 |

118 | )} 119 |
120 | Next 121 | 122 | {nextProgram 123 | ? `${dayjs(nextProgram.startAt).format( 124 | "HH:mm" 125 | )} [${Math.abs( 126 | now.diff(nextProgram.startAt, "minute") 127 | )}分後] - ${nextProgram.name}` 128 | : "なし"} 129 |
130 | {channelComment && channelComment.last && ( 131 |
132 | {channelComment.last} 133 |
134 | )} 135 |
136 | 137 | ) 138 | })} 139 |
140 | ))} 141 |
142 |
143 |
144 | ) 145 | } 146 | -------------------------------------------------------------------------------- /src/components/common/CommentPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@chakra-ui/react" 2 | import * as aribb24 from "aribb24.js" 3 | // eslint-disable-next-line import/no-unresolved 4 | import DPlayer, { DPlayerVideo, DPlayerEvents } from "dplayer" 5 | import Hls from "hls-b24.js" 6 | import React, { useEffect, useRef } from "react" 7 | import { useUpdateEffect } from "react-use" 8 | import { useBackend } from "../../hooks/backend" 9 | import { CommentPayload } from "../../types/struct" 10 | import { trimCommentForFlow } from "../../utils/comment" 11 | 12 | export const CommentPlayer: React.VFC<{ 13 | hlsUrl: string | null 14 | comment: CommentPayload | null 15 | isLive: boolean 16 | isAutoPlay: boolean 17 | isLoading?: boolean 18 | onPositionChange?: React.Dispatch> 19 | onPauseChange?: (b: boolean) => unknown 20 | }> = ({ 21 | hlsUrl, 22 | comment, 23 | isLive, 24 | isAutoPlay, 25 | isLoading, 26 | onPositionChange, 27 | onPauseChange, 28 | }) => { 29 | const backend = useBackend() 30 | const hlsInstance = useRef(null) 31 | const isPlaying = useRef(false) 32 | const videoPayload: (hlsUrl: string) => DPlayerVideo = (hlsUrl) => ({ 33 | type: "customHls", 34 | url: hlsUrl, 35 | customType: { 36 | customHls: (video: HTMLVideoElement, player: DPlayer) => { 37 | if (hlsInstance.current) { 38 | hlsInstance.current.destroy() 39 | hlsInstance.current = null 40 | } 41 | const hls = new Hls() 42 | 43 | if (backend.isAuthorizationEnabled) { 44 | hls.config.xhrSetup = (xhr) => { 45 | xhr.setRequestHeader("Authorization", backend.authorizationToken) 46 | } 47 | } 48 | hls.config.autoStartLoad = isLive 49 | 50 | hls.loadSource(video.src) 51 | hls.attachMedia(video) 52 | 53 | const b24Renderer = new aribb24.CanvasRenderer({ 54 | keepAspectRatio: true, 55 | normalFont: "'Rounded M+ 1m for ARIB'", 56 | gaijiFont: "'Rounded M+ 1m for ARIB'", 57 | drcsReplacement: true, 58 | }) 59 | b24Renderer.attachMedia(video) 60 | b24Renderer.show() 61 | 62 | player.on("subtitle_show" as DPlayerEvents.subtitle_show, () => { 63 | b24Renderer.show() 64 | }) 65 | player.on("subtitle_hide" as DPlayerEvents.subtitle_hide, () => { 66 | b24Renderer.hide() 67 | }) 68 | hls.on(Hls.Events.FRAG_PARSING_PRIVATE_DATA, (event, data) => { 69 | for (const sample of data.samples) { 70 | b24Renderer.pushData(sample.pid, sample.data, sample.pts) 71 | } 72 | }) 73 | hls.on(Hls.Events.DESTROYING, () => { 74 | b24Renderer.dispose() 75 | }) 76 | 77 | player.on("pause" as DPlayerEvents.pause, () => { 78 | !isLive && hls.stopLoad() 79 | onPauseChange && onPauseChange(true) 80 | isPlaying.current = false 81 | }) 82 | player.on("play" as DPlayerEvents.play, () => { 83 | !isLive && hls.startLoad() 84 | onPauseChange && onPauseChange(false) 85 | isPlaying.current = true 86 | }) 87 | player.on("waiting" as DPlayerEvents.stalled, () => { 88 | onPauseChange && onPauseChange(true) 89 | isPlaying.current = false 90 | }) 91 | player.on("canplay" as DPlayerEvents.canplay, () => { 92 | onPauseChange && onPauseChange(false) 93 | isPlaying.current = true 94 | }) 95 | 96 | player.on("destroy" as DPlayerEvents.destroy, () => { 97 | hls.destroy() 98 | }) 99 | 100 | hlsInstance.current = hls 101 | }, 102 | }, 103 | }) 104 | const danmaku = { 105 | id: "elaina", 106 | user: "elaina", 107 | api: "", 108 | bottom: "10%", 109 | unlimited: true, 110 | } 111 | const playerWrapRef = useRef(null) 112 | const dplayerElementRef = useRef(null) 113 | const player = useRef() 114 | 115 | useUpdateEffect(() => { 116 | if (!player.current || !hlsUrl) return 117 | player.current.pause() 118 | player.current.switchVideo(videoPayload(hlsUrl), danmaku) 119 | player.current.play() 120 | }, [hlsUrl]) 121 | 122 | useEffect(() => { 123 | if (!player.current || !comment || player.current.video.paused === true) 124 | return 125 | const commentText = trimCommentForFlow(comment.text) 126 | if (commentText.trim().length === 0) return 127 | const payload = { ...comment, text: commentText } 128 | player.current.danmaku.draw(payload) 129 | }, [comment]) 130 | 131 | useEffect(() => { 132 | if (!hlsUrl) return 133 | const playerInstance = new DPlayer({ 134 | container: dplayerElementRef.current, 135 | live: isLive, 136 | autoplay: isAutoPlay, 137 | screenshot: true, 138 | video: videoPayload(hlsUrl), 139 | danmaku, 140 | lang: "ja-jp", 141 | pictureInPicture: true, 142 | airplay: true, 143 | subtitle: { 144 | type: "webvtt", 145 | fontSize: "20px", 146 | color: "#fff", 147 | bottom: "40px", 148 | // TODO: Typing correctly 149 | } as never, 150 | apiBackend: { 151 | read: (option) => { 152 | option.success([{}]) 153 | }, 154 | send: (option, item, callback) => { 155 | callback() 156 | }, 157 | }, 158 | contextmenu: [], 159 | }) 160 | 161 | player.current = playerInstance 162 | hlsInstance.current?.stopLoad() 163 | 164 | const timer = setInterval(() => { 165 | if (isPlaying.current) { 166 | onPositionChange && onPositionChange((n) => n + 1) 167 | } 168 | }, 1000) 169 | 170 | return () => { 171 | hlsInstance.current?.destroy() 172 | player.current?.destroy() 173 | player.current = null 174 | clearInterval(timer) 175 | } 176 | }, [hlsUrl]) 177 | return ( 178 |
183 | {hlsUrl && ( 184 |
185 |
186 |
187 | )} 188 | {(!hlsUrl || isLoading) && ( 189 |
190 | 191 |
192 | )} 193 |
194 | ) 195 | } 196 | -------------------------------------------------------------------------------- /src/utils/capture.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * From ZenzaWatch & TVRemotePlus 3 | * https://greasyfork.org/ja/scripts/367968-zenzawatch-dev%E7%89%88 4 | * https://github.com/tsukumijima/TVRemotePlus 5 | */ 6 | 7 | export const capturePlayer = async ({ 8 | withComment, 9 | }: { 10 | withComment: boolean 11 | }) => { 12 | const video = document.querySelector( 13 | "video.dplayer-video-current" 14 | ) 15 | const danmaku = document.querySelectorAll(".dplayer-danmaku-move") 16 | 17 | let html = document.querySelector(".dplayer-danmaku")?.outerHTML 18 | 19 | if (!video || !danmaku || !html) throw new Error() 20 | 21 | for (let i = 0; i < danmaku.length; i++) { 22 | const position = 23 | danmaku[i].getBoundingClientRect().left - 24 | video.getBoundingClientRect().left 25 | html = html.replace( 26 | /transform: translateX\(.*?\);/, 27 | "left: " + position + "px;" 28 | ) 29 | } 30 | // twemoji 対処 31 | html = html.replace(//g, (_, match) => match) 32 | 33 | let scale = 1 34 | const minHeight = 1080 35 | 36 | let width = Math.max(video.videoWidth, (video.videoHeight * 16) / 9) 37 | let height = video.videoHeight 38 | if (height < minHeight) { 39 | scale = Math.floor(minHeight / height) 40 | width *= scale 41 | height *= scale 42 | } 43 | 44 | const canvas = document.createElement("canvas") 45 | const canvasContext = canvas.getContext("2d", { alpha: false }) 46 | if (!canvasContext) throw new Error("canvasContext") 47 | canvas.width = width 48 | canvas.height = height 49 | const videoCanvas = videoToCanvas(video) 50 | canvasContext.fillStyle = "rgb(0, 0, 0)" 51 | canvasContext.fillRect(0, 0, width, height) 52 | canvasContext.drawImage( 53 | videoCanvas, 54 | (width - video.videoWidth * scale) / 2, 55 | (height - video.videoHeight * scale) / 2, 56 | video.videoWidth * scale, 57 | video.videoHeight * scale 58 | ) 59 | if (withComment) { 60 | const { canvas } = await htmlToCanvas(html, width, height, scale) 61 | canvasContext.drawImage(canvas, 0, 0, width, height) 62 | } 63 | document 64 | .querySelectorAll(".dplayer-video-wrap > canvas") 65 | .forEach((canvas) => { 66 | canvasContext.drawImage(canvas, 0, 0, width, height) 67 | }) 68 | return { canvas } 69 | } 70 | 71 | const videoToCanvas = (video: HTMLVideoElement) => { 72 | const canvas = document.createElement("canvas") 73 | const caption = document.querySelector( 74 | "div.dplayer-video-wrap > canvas" 75 | ) 76 | canvas.width = video.videoWidth 77 | canvas.height = video.videoHeight 78 | 79 | const context = canvas.getContext("2d") 80 | if (!context) throw new Error("canvasContext") 81 | 82 | context.drawImage(video, 0, 0, canvas.width, canvas.height) 83 | if (caption) { 84 | context.drawImage(caption, 0, 0, canvas.width, canvas.height) 85 | } 86 | 87 | return canvas 88 | } 89 | 90 | const htmlToSvg = ( 91 | html: string, 92 | width: number, 93 | height: number, 94 | scale: number 95 | ) => { 96 | const data = 97 | ` 100 | 101 |
102 | 145 | ${html} 146 |
147 |
148 |
`.trim() 149 | const svg = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data) 150 | return { svg, data } 151 | } 152 | 153 | const htmlToCanvas = async ( 154 | html: string, 155 | width: number, 156 | height: number, 157 | scale: number 158 | ) => { 159 | const imageW = (height * 16) / 9 160 | const imageH = (imageW * 9) / 16 161 | const { svg } = htmlToSvg(html, 640, 360 + 20, scale) 162 | const canvas = document.createElement("canvas") 163 | const context = canvas.getContext("2d") 164 | if (!context) throw new Error("canvasContext") 165 | canvas.width = width 166 | canvas.height = height 167 | const img = new Image() 168 | img.width = 640 169 | img.height = 360 170 | await new Promise((res, rej) => { 171 | img.onload = () => { 172 | context.drawImage( 173 | img, 174 | (width - imageW) / 2, 175 | (height - imageH) / 2 - 20, 176 | imageW, 177 | imageH + 40 178 | ) 179 | res() 180 | } 181 | img.onerror = (err) => rej(err) 182 | img.src = svg 183 | }) 184 | 185 | return { canvas, img } 186 | } 187 | -------------------------------------------------------------------------------- /src/components/timetable/Programs.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import React, { memo, useState } from "react" 3 | import { ArrowContainer, Popover } from "react-tiny-popover" 4 | import { Link } from "rocon/react" 5 | import { Genre, SubGenre } from "../../constants" 6 | import { programsRoute } from "../../routes" 7 | import { Channel, Program, Schedule } from "../../types/struct" 8 | import { genreColors } from "../../utils/genres" 9 | 10 | export const ProgramItem: React.VFC<{ 11 | channel: Channel 12 | program: Program 13 | channelCol: number 14 | top: number 15 | height: number 16 | }> = memo(({ channel, program, channelCol, top, height }) => { 17 | const startAt = dayjs(program.startAt) 18 | const remain = startAt.diff(dayjs(), "minute") 19 | const duration = (program.endAt - program.startAt) / 1000 20 | 21 | const genre = Genre[program.genre1] 22 | const subGenre = genre && SubGenre[program.genre1][program.subGenre1] 23 | const genreColor = 24 | genre && ((subGenre && genreColors[subGenre]) || genreColors[genre]) 25 | 26 | const [isOpen, setIsOpen] = useState(false) 27 | 28 | return ( 29 | setIsOpen(false)} 35 | content={({ position, childRect, popoverRect }) => { 36 | return ( 37 | 44 |
45 |
46 |
47 | 48 | {startAt.format("MM/DD HH:mm")} 49 | 50 | 51 | +{duration / 60}min 52 | 53 |
54 |
55 | 56 | {Math.abs(remain)}分{0 < remain ? "後" : "前"} 57 | 58 | {channel && {channel.name}} 59 |
60 |
61 |
62 | {genre && ( 63 | 68 | {genre} 69 | {subGenre && ` / ${subGenre}`} 70 | 71 | )} 72 | {program.name} 73 |
74 | {program.description && 100 < program.description.length 75 | ? program.description.substring(0, 100) + "..." 76 | : program.description} 77 |
78 |
{program.id}
79 | 83 | 86 | 87 |
88 |
89 |
90 | ) 91 | }} 92 | > 93 |
setIsOpen((isOpen) => !isOpen)} 95 | style={{ 96 | top: `${Math.max(top, 0)}px`, 97 | left: `${channelCol * 9}rem`, 98 | height: `${0 < top ? height : height + top}px`, 99 | }} 100 | className={`absolute truncate w-36 ${ 101 | genreColor || "bg-gray-100" 102 | } border border-gray-400 cursor-pointer select-none`} 103 | title={[program.name, program.description] 104 | .filter((s) => !!s) 105 | .join("\n\n")} 106 | > 107 |

108 | {startAt.format("HH:mm")} {program.name} 109 |

110 |

116 | {program.description} 117 |

118 |
119 |
120 | ) 121 | }) 122 | 123 | export const ChannelProgramList: React.VFC<{ 124 | programs: Program[] 125 | channel: Channel 126 | startAtInString: string 127 | channelCol: number 128 | client: { left: number; top: number; width: number; height: number } 129 | }> = memo(({ programs, channel, startAtInString, channelCol, client }) => { 130 | const startAt = dayjs(startAtInString) 131 | return ( 132 | 133 | {(programs || []) 134 | .filter( 135 | (program) => 136 | 0 < dayjs(program.endAt).diff(startAt, "minute") && 137 | dayjs(program.startAt).diff(startAt, "minute") <= 4320 138 | ) 139 | .map((program) => { 140 | const start = dayjs(program.startAt) 141 | const diffInMinutes = start.diff(startAt, "minute") 142 | const top = (diffInMinutes / 60) * 180 143 | const height = ((program.endAt - program.startAt) / 1000 / 3600) * 180 144 | const bottom = top + height 145 | if ( 146 | bottom < client.top - 180 || 147 | client.top + client.height + 180 < top 148 | ) { 149 | return 150 | } 151 | return ( 152 | 160 | ) 161 | })} 162 | 163 | ) 164 | }) 165 | 166 | export const TimetableProgramList: React.VFC<{ 167 | schedules: Schedule[] 168 | startAtInString: string 169 | client: { left: number; top: number; width: number; height: number } 170 | }> = memo(({ schedules, startAtInString, client }) => { 171 | return ( 172 | <> 173 | {schedules.map((schedule, idx) => { 174 | const leftPos = idx * 144 175 | const rightPos = leftPos + 144 176 | 177 | if ( 178 | rightPos < client.left - 144 || 179 | client.left + client.width + 144 < leftPos 180 | ) { 181 | return 182 | } 183 | 184 | return ( 185 | 193 | ) 194 | })} 195 | 196 | ) 197 | }) 198 | -------------------------------------------------------------------------------- /src/pages/records/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Spinner } from "@chakra-ui/react" 2 | import dayjs from "dayjs" 3 | import React, { useEffect, useMemo, useState } from "react" 4 | import { ArrowLeft, ArrowRight, Search } from "react-feather" 5 | import { useTable, usePagination, Column, useGlobalFilter } from "react-table" 6 | import { useToasts } from "react-toast-notifications" 7 | import { Link } from "rocon/react" 8 | import { RecordSearchModal } from "../../components/records/SearchModal" 9 | import { useBackend } from "../../hooks/backend" 10 | import { useChannels } from "../../hooks/television" 11 | import { recordsRoute } from "../../routes" 12 | import type { ProgramRecord } from "../../types/struct" 13 | 14 | export const RecordsPage: React.VFC<{}> = () => { 15 | const backend = useBackend() 16 | const toast = useToasts() 17 | const [_records, setRecords] = useState(null) 18 | const records = useMemo(() => _records || [], [_records]) 19 | const { channels } = useChannels() 20 | 21 | const columns: Column[] = useMemo( 22 | () => [ 23 | { 24 | id: "channel", 25 | Header: "放送局", 26 | accessor: (record: ProgramRecord) => 27 | channels && 28 | channels.find((channel) => record.channelId === channel.id)?.name, 29 | }, 30 | { 31 | id: "name", 32 | Header: "番組名", 33 | accessor: (record: ProgramRecord) => record.name, 34 | }, 35 | { 36 | id: "startAt", 37 | Header: "放送日時", 38 | accessor: (record: ProgramRecord) => 39 | dayjs(record.startAt).format("YYYY/MM/DD HH:mm"), 40 | }, 41 | { 42 | id: "duration", 43 | Header: "長さ", 44 | accessor: (record: ProgramRecord) => 45 | (record.endAt - record.startAt) / 1000 / 60, 46 | Cell: ({ value }: { value: number }) => `${value}m`, 47 | }, 48 | ], 49 | [channels] 50 | ) 51 | 52 | const [total, setTotal] = useState(null) 53 | const [_pageSize, setPageSize] = useState(20) 54 | 55 | const { 56 | getTableProps, 57 | getTableBodyProps, 58 | headerGroups, 59 | prepareRow, 60 | page, 61 | pageOptions, 62 | state: { pageIndex, pageSize, globalFilter }, 63 | previousPage, 64 | nextPage, 65 | canPreviousPage, 66 | canNextPage, 67 | gotoPage, 68 | setGlobalFilter, 69 | } = useTable( 70 | { 71 | columns, 72 | data: records || [], 73 | initialState: { pageSize: 20 }, 74 | manualPagination: true, 75 | manualGlobalFilter: true, 76 | pageCount: Math.ceil((total || 0) / _pageSize), 77 | }, 78 | useGlobalFilter, 79 | usePagination 80 | ) 81 | 82 | const [isSearchModalOpen, setIsSearchModalOpen] = useState(false) 83 | 84 | const [searchTerm, setSearchTerm] = useState(null) 85 | 86 | useEffect(() => { 87 | setPageSize(pageSize) 88 | backend 89 | .getRecords({ 90 | offset: pageSize * pageIndex, 91 | limit: pageSize, 92 | keyword: globalFilter || undefined, 93 | }) 94 | .then(({ records, total }) => { 95 | setTotal(total) 96 | setRecords(records) 97 | }) 98 | .catch(() => 99 | toast.addToast("録画番組の取得に失敗しました", { 100 | appearance: "error", 101 | autoDismiss: true, 102 | }) 103 | ) 104 | }, [pageIndex, pageSize, globalFilter]) 105 | useEffect(() => { 106 | gotoPage(0) 107 | setGlobalFilter(searchTerm) 108 | }, [searchTerm]) 109 | return ( 110 | <> 111 |
112 |
113 | 114 | 録画番組 115 | 116 | 123 |
124 |
125 |
126 | {_records === null || !channels ? ( 127 |
131 | 132 |
133 | ) : ( 134 |
135 |
136 | {headerGroups.map((headerGroup) => ( 137 |
141 | {headerGroup.headers.map((column, idx, columns) => ( 142 |
148 | {column.render("Header")} 149 |
150 | ))} 151 |
152 | ))} 153 |
154 |
155 | {page.map((row) => { 156 | prepareRow(row) 157 | return ( 158 | !!s) 169 | .join("\n\n")} 170 | {...row.getRowProps()} 171 | > 172 | {row.cells.map((cell, idx) => { 173 | return ( 174 | 180 | {cell.render("Cell")} 181 | 182 | ) 183 | })} 184 | 185 | ) 186 | })} 187 |
188 |
189 | )} 190 |
191 |
192 | 202 |
203 | {pageIndex + 1}/{pageOptions.length || 1} 204 |
205 | 213 |
214 | {isSearchModalOpen && ( 215 | setIsSearchModalOpen(false)} 219 | /> 220 | )} 221 | 222 | ) 223 | } 224 | -------------------------------------------------------------------------------- /src/components/common/CommentM2tsPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@chakra-ui/react" 2 | import * as aribb24 from "aribb24.js" 3 | // eslint-disable-next-line import/no-unresolved 4 | import DPlayer, { DPlayerVideo, DPlayerEvents } from "dplayer" 5 | import mpegts from "mpegts.js" 6 | import React, { useEffect, useRef } from "react" 7 | import { useUpdateEffect } from "react-use" 8 | import { useBackend } from "../../hooks/backend" 9 | import { CommentPayload } from "../../types/struct" 10 | import { trimCommentForFlow } from "../../utils/comment" 11 | import { parseMalformedPES } from "../../utils/pes" 12 | 13 | export const CommentM2tsPlayer: React.VFC<{ 14 | liveUrl: string | null 15 | comment: CommentPayload | null 16 | isLive: boolean 17 | isAutoPlay: boolean 18 | isLoading?: boolean 19 | onPositionChange?: React.Dispatch> 20 | onPauseChange?: (b: boolean) => unknown 21 | }> = ({ 22 | liveUrl, 23 | comment, 24 | isLive, 25 | isAutoPlay, 26 | isLoading, 27 | onPositionChange, 28 | onPauseChange, 29 | }) => { 30 | const backend = useBackend() 31 | const mpegTsInstance = useRef(null) 32 | const isPlaying = useRef(false) 33 | const videoPayload: (hlsUrl: string) => DPlayerVideo = (liveUrl) => ({ 34 | type: "customHls", 35 | url: liveUrl, 36 | customType: { 37 | customHls: (video: HTMLVideoElement, player: DPlayer) => { 38 | if (mpegTsInstance.current) { 39 | mpegTsInstance.current.destroy() 40 | mpegTsInstance.current = null 41 | } 42 | const mpegtsPlayer = mpegts.createPlayer( 43 | { 44 | type: "mpegts", 45 | isLive: true, 46 | url: video.src, 47 | }, 48 | { 49 | isLive: true, 50 | enableWorker: true, 51 | headers: backend.isAuthorizationEnabled 52 | ? { 53 | Authorization: backend.authorizationToken, 54 | } 55 | : {}, 56 | } 57 | ) 58 | 59 | mpegtsPlayer.attachMediaElement(video) 60 | mpegtsPlayer.load() 61 | mpegtsPlayer.play() 62 | 63 | const b24Renderer = new aribb24.CanvasRenderer({ 64 | keepAspectRatio: true, 65 | normalFont: "'Rounded M+ 1m for ARIB'", 66 | gaijiFont: "'Rounded M+ 1m for ARIB'", 67 | drcsReplacement: true, 68 | data_identifier: 0x80, 69 | }) 70 | b24Renderer.attachMedia(video) 71 | b24Renderer.show() 72 | const superimposeRenderer = new aribb24.CanvasRenderer({ 73 | keepAspectRatio: true, 74 | normalFont: "'Rounded M+ 1m for ARIB'", 75 | gaijiFont: "'Rounded M+ 1m for ARIB'", 76 | drcsReplacement: true, 77 | data_identifier: 0x81, 78 | }) 79 | superimposeRenderer.attachMedia(video) 80 | superimposeRenderer.show() 81 | 82 | player.on("subtitle_show" as DPlayerEvents.subtitle_show, () => { 83 | b24Renderer.show() 84 | superimposeRenderer.show() 85 | }) 86 | player.on("subtitle_hide" as DPlayerEvents.subtitle_hide, () => { 87 | b24Renderer.hide() 88 | superimposeRenderer.hide() 89 | }) 90 | /** 91 | * https://github.com/l3tnun/EPGStation/blob/master/client/src/components/video/LiveMpegTsVideo.vue#L112-L127 92 | */ 93 | mpegtsPlayer.on(mpegts.Events.PES_PRIVATE_DATA_ARRIVED, (data) => { 94 | if (data.stream_id === 0xbd && data.data[0] === 0x80) { 95 | // private_stream_1, caption 96 | b24Renderer.pushData(data.pid, data.data, data.pts / 1000) 97 | } else if (data.stream_id === 0xbf) { 98 | // private_stream_2, superimpose 99 | let payload = data.data 100 | if (payload[0] !== 0x81) { 101 | payload = parseMalformedPES(data.data) 102 | } 103 | if (payload[0] !== 0x81) { 104 | return 105 | } 106 | superimposeRenderer.pushData( 107 | data.pid, 108 | payload, 109 | data.nearest_pts / 1000 110 | ) 111 | } 112 | }) 113 | /*hls.on(Hls.Events.DESTROYING, () => { 114 | b24Renderer.dispose() 115 | })*/ 116 | 117 | player.on("pause" as DPlayerEvents.pause, () => { 118 | onPauseChange && onPauseChange(true) 119 | isPlaying.current = false 120 | }) 121 | player.on("play" as DPlayerEvents.play, () => { 122 | onPauseChange && onPauseChange(false) 123 | isPlaying.current = true 124 | }) 125 | player.on("waiting" as DPlayerEvents.stalled, () => { 126 | onPauseChange && onPauseChange(true) 127 | isPlaying.current = false 128 | }) 129 | player.on("canplay" as DPlayerEvents.canplay, () => { 130 | onPauseChange && onPauseChange(false) 131 | isPlaying.current = true 132 | }) 133 | 134 | player.on("destroy" as DPlayerEvents.destroy, () => { 135 | mpegtsPlayer.destroy() 136 | }) 137 | 138 | mpegTsInstance.current = mpegtsPlayer 139 | }, 140 | }, 141 | }) 142 | const danmaku = { 143 | id: "elaina", 144 | user: "elaina", 145 | api: "", 146 | bottom: "10%", 147 | unlimited: true, 148 | } 149 | const playerWrapRef = useRef(null) 150 | const dplayerElementRef = useRef(null) 151 | const player = useRef() 152 | 153 | useUpdateEffect(() => { 154 | if (!player.current || !liveUrl) return 155 | player.current.pause() 156 | player.current.switchVideo(videoPayload(liveUrl), danmaku) 157 | player.current.play() 158 | }, [liveUrl]) 159 | 160 | useEffect(() => { 161 | if (!player.current || !comment || player.current.video.paused === true) 162 | return 163 | const commentText = trimCommentForFlow(comment.text) 164 | if (commentText.trim().length === 0) return 165 | const payload = { ...comment, text: commentText } 166 | player.current.danmaku.draw(payload) 167 | }, [comment]) 168 | 169 | useEffect(() => { 170 | if (!liveUrl) return 171 | const playerInstance = new DPlayer({ 172 | container: dplayerElementRef.current, 173 | live: isLive, 174 | autoplay: isAutoPlay, 175 | screenshot: true, 176 | video: videoPayload(liveUrl), 177 | danmaku, 178 | lang: "ja-jp", 179 | pictureInPicture: true, 180 | airplay: true, 181 | subtitle: { 182 | type: "webvtt", 183 | fontSize: "20px", 184 | color: "#fff", 185 | bottom: "40px", 186 | // TODO: Typing correctly 187 | } as never, 188 | apiBackend: { 189 | read: (option) => { 190 | option.success([{}]) 191 | }, 192 | send: (option, item, callback) => { 193 | callback() 194 | }, 195 | }, 196 | contextmenu: [], 197 | }) 198 | 199 | player.current = playerInstance 200 | 201 | const timer = setInterval(() => { 202 | if (isPlaying.current) { 203 | onPositionChange && onPositionChange((n) => n + 1) 204 | } 205 | }, 1000) 206 | 207 | return () => { 208 | mpegTsInstance.current?.destroy() 209 | player.current?.destroy() 210 | player.current = null 211 | clearInterval(timer) 212 | } 213 | }, [liveUrl]) 214 | return ( 215 |
220 | {liveUrl && ( 221 |
222 |
223 |
224 | )} 225 | {(!liveUrl || isLoading) && ( 226 |
227 | 228 |
229 | )} 230 |
231 | ) 232 | } 233 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * l3tnun/EPGStation 3 | * https://github.com/l3tnun/EPGStation/blob/322fe463791de9d99818e56850b1e0195500ca10/client/src/lib/event.ts 4 | * MIT License 5 | * Copyright (c) 2017 l3tnun 6 | */ 7 | 8 | export const Genre: { [key: number]: string } = { 9 | 0x0: "ニュース・報道", 10 | 0x1: "スポーツ", 11 | 0x2: "情報・ワイドショー", 12 | 0x3: "ドラマ", 13 | 0x4: "音楽", 14 | 0x5: "バラエティ", 15 | 0x6: "映画", 16 | 0x7: "アニメ・特撮", 17 | 0x8: "ドキュメンタリー・教養", 18 | 0x9: "劇場・公演", 19 | 0xa: "趣味・教育", 20 | 0xb: "福祉", 21 | 0xc: "予備", 22 | 0xd: "予備", 23 | 0xe: "拡張", 24 | 0xf: "その他", 25 | } 26 | 27 | export const SubGenre: { [key: number]: { [key: number]: string } } = { 28 | 0x0: { 29 | 0x0: "定時・総合", 30 | 0x1: "天気", 31 | 0x2: "特集・ドキュメント", 32 | 0x3: "政治・国会", 33 | 0x4: "経済・市況", 34 | 0x5: "海外・国際", 35 | 0x6: "解説", 36 | 0x7: "討論・会談", 37 | 0x8: "報道特番", 38 | 0x9: "ローカル・地域", 39 | 0xa: "交通", 40 | 0xb: "", 41 | 0xc: "", 42 | 0xd: "", 43 | 0xe: "", 44 | 0xf: "その他", 45 | }, 46 | 0x1: { 47 | 0x0: "スポーツニュース", 48 | 0x1: "野球", 49 | 0x2: "サッカー", 50 | 0x3: "ゴルフ", 51 | 0x4: "その他の球技", 52 | 0x5: "相撲・格闘技", 53 | 0x6: "オリンピック・国際大会", 54 | 0x7: "マラソン・陸上・水泳", 55 | 0x8: "モータースポーツ", 56 | 0x9: "マリン・ウィンタースポーツ", 57 | 0xa: "競馬・公営競技", 58 | 0xb: "", 59 | 0xc: "", 60 | 0xd: "", 61 | 0xe: "", 62 | 0xf: "その他", 63 | }, 64 | 0x2: { 65 | 0x0: "芸能・ワイドショー", 66 | 0x1: "ファッション", 67 | 0x2: "暮らし・住まい", 68 | 0x3: "健康・医療", 69 | 0x4: "ショッピング・通販", 70 | 0x5: "グルメ・料理", 71 | 0x6: "イベント", 72 | 0x7: "番組紹介・お知らせ", 73 | 0x8: "", 74 | 0x9: "", 75 | 0xa: "", 76 | 0xb: "", 77 | 0xc: "", 78 | 0xd: "", 79 | 0xe: "", 80 | 0xf: "その他", 81 | }, 82 | 0x3: { 83 | 0x0: "国内ドラマ", 84 | 0x1: "海外ドラマ", 85 | 0x2: "時代劇", 86 | 0x3: "", 87 | 0x4: "", 88 | 0x5: "", 89 | 0x6: "", 90 | 0x7: "", 91 | 0x8: "", 92 | 0x9: "", 93 | 0xa: "", 94 | 0xb: "", 95 | 0xc: "", 96 | 0xd: "", 97 | 0xe: "", 98 | 0xf: "その他", 99 | }, 100 | 0x4: { 101 | 0x0: "国内ロック・ポップス", 102 | 0x1: "海外ロック・ポップス", 103 | 0x2: "クラシック・オペラ", 104 | 0x3: "ジャズ・フュージョン", 105 | 0x4: "歌謡曲・演歌", 106 | 0x5: "ライブ・コンサート", 107 | 0x6: "ランキング・リクエスト", 108 | 0x7: "カラオケ・のど自慢", 109 | 0x8: "民謡・邦楽", 110 | 0x9: "童謡・キッズ", 111 | 0xa: "民族音楽・ワールドミュージック", 112 | 0xb: "", 113 | 0xc: "", 114 | 0xd: "", 115 | 0xe: "", 116 | 0xf: "その他", 117 | }, 118 | 0x5: { 119 | 0x0: "クイズ", 120 | 0x1: "ゲーム", 121 | 0x2: "トークバラエティ", 122 | 0x3: "お笑い・コメディ", 123 | 0x4: "音楽バラエティ", 124 | 0x5: "旅バラエティ", 125 | 0x6: "料理バラエティ", 126 | 0x7: "", 127 | 0x8: "", 128 | 0x9: "", 129 | 0xa: "", 130 | 0xb: "", 131 | 0xc: "", 132 | 0xd: "", 133 | 0xe: "", 134 | 0xf: "その他", 135 | }, 136 | 0x6: { 137 | 0x0: "洋画", 138 | 0x1: "邦画", 139 | 0x2: "アニメ", 140 | 0x3: "", 141 | 0x4: "", 142 | 0x5: "", 143 | 0x6: "", 144 | 0x7: "", 145 | 0x8: "", 146 | 0x9: "", 147 | 0xa: "", 148 | 0xb: "", 149 | 0xc: "", 150 | 0xd: "", 151 | 0xe: "", 152 | 0xf: "その他", 153 | }, 154 | 0x7: { 155 | 0x0: "国内アニメ", 156 | 0x1: "海外アニメ", 157 | 0x2: "特撮", 158 | 0x3: "", 159 | 0x4: "", 160 | 0x5: "", 161 | 0x6: "", 162 | 0x7: "", 163 | 0x8: "", 164 | 0x9: "", 165 | 0xa: "", 166 | 0xb: "", 167 | 0xc: "", 168 | 0xd: "", 169 | 0xe: "", 170 | 0xf: "その他", 171 | }, 172 | 0x8: { 173 | 0x0: "社会・時事", 174 | 0x1: "歴史・紀行", 175 | 0x2: "自然・動物・環境", 176 | 0x3: "宇宙・科学・医学", 177 | 0x4: "カルチャー・伝統文化", 178 | 0x5: "文学・文芸", 179 | 0x6: "スポーツ", 180 | 0x7: "ドキュメンタリー全般", 181 | 0x8: "インタビュー・討論", 182 | 0x9: "", 183 | 0xa: "", 184 | 0xb: "", 185 | 0xc: "", 186 | 0xd: "", 187 | 0xe: "", 188 | 0xf: "その他", 189 | }, 190 | 0x9: { 191 | 0x0: "現代劇・新劇", 192 | 0x1: "ミュージカル", 193 | 0x2: "ダンス・バレエ", 194 | 0x3: "落語・演芸", 195 | 0x4: "歌舞伎・古典", 196 | 0x5: "", 197 | 0x6: "", 198 | 0x7: "", 199 | 0x8: "", 200 | 0x9: "", 201 | 0xa: "", 202 | 0xb: "", 203 | 0xc: "", 204 | 0xd: "", 205 | 0xe: "", 206 | 0xf: "その他", 207 | }, 208 | 0xa: { 209 | 0x0: "旅・釣り・アウトドア", 210 | 0x1: "園芸・ペット・手芸", 211 | 0x2: "音楽・美術・工芸", 212 | 0x3: "囲碁・将棋", 213 | 0x4: "麻雀・パチンコ", 214 | 0x5: "車・オートバイ", 215 | 0x6: "コンピュータ・TVゲーム", 216 | 0x7: "会話・語学", 217 | 0x8: "幼児・小学生", 218 | 0x9: "中学生・高校生", 219 | 0xa: "大学生・受験", 220 | 0xb: "生涯教育・資格", 221 | 0xc: "教育問題", 222 | 0xd: "", 223 | 0xe: "", 224 | 0xf: "その他", 225 | }, 226 | 0xb: { 227 | 0x0: "高齢者", 228 | 0x1: "障害者", 229 | 0x2: "社会福祉", 230 | 0x3: "ボランティア", 231 | 0x4: "手話", 232 | 0x5: "文字(字幕)", 233 | 0x6: "音声解説", 234 | 0x7: "", 235 | 0x8: "", 236 | 0x9: "", 237 | 0xa: "", 238 | 0xb: "", 239 | 0xc: "", 240 | 0xd: "", 241 | 0xe: "", 242 | 0xf: "その他", 243 | }, 244 | 0xc: { 245 | 0x0: "", 246 | 0x1: "", 247 | 0x2: "", 248 | 0x3: "", 249 | 0x4: "", 250 | 0x5: "", 251 | 0x6: "", 252 | 0x7: "", 253 | 0x8: "", 254 | 0x9: "", 255 | 0xa: "", 256 | 0xb: "", 257 | 0xc: "", 258 | 0xd: "", 259 | 0xe: "", 260 | 0xf: "", 261 | }, 262 | 0xd: { 263 | 0x0: "", 264 | 0x1: "", 265 | 0x2: "", 266 | 0x3: "", 267 | 0x4: "", 268 | 0x5: "", 269 | 0x6: "", 270 | 0x7: "", 271 | 0x8: "", 272 | 0x9: "", 273 | 0xa: "", 274 | 0xb: "", 275 | 0xc: "", 276 | 0xd: "", 277 | 0xe: "", 278 | 0xf: "", 279 | }, 280 | 0xe: { 281 | 0x0: "BS/地上デジタル放送用番組付属情報", 282 | 0x1: "広帯域 CS デジタル放送用拡張", 283 | 0x2: "", 284 | 0x3: "サーバー型番組付属情報", 285 | 0x4: "IP 放送用番組付属情報", 286 | 0x5: "", 287 | 0x6: "", 288 | 0x7: "", 289 | 0x8: "", 290 | 0x9: "", 291 | 0xa: "", 292 | 0xb: "", 293 | 0xc: "", 294 | 0xd: "", 295 | 0xe: "", 296 | 0xf: "", 297 | }, 298 | 0xf: { 299 | 0x0: "", 300 | 0x1: "", 301 | 0x2: "", 302 | 0x3: "", 303 | 0x4: "", 304 | 0x5: "", 305 | 0x6: "", 306 | 0x7: "", 307 | 0x8: "", 308 | 0x9: "", 309 | 0xa: "", 310 | 0xb: "", 311 | 0xc: "", 312 | 0xd: "", 313 | 0xe: "", 314 | 0xf: "その他", 315 | }, 316 | } 317 | 318 | export const VideoComponentType = { 319 | 0x01: "480i(525i), アスペクト比4:3", 320 | 0x02: "480i(525i), アスペクト比16:9 パンベクトルあり", 321 | 0x03: "480i(525i), アスペクト比16:9 パンベクトルなし", 322 | 0x04: "480i(525i), アスペクト比 > 16:9", 323 | 0x83: "4320p, アスペクト比16:9", 324 | 0x91: "2160p, アスペクト比4:3", 325 | 0x92: "2160p, アスペクト比16:9 パンベクトルあり", 326 | 0x93: "2160p, アスペクト比16:9 パンベクトルなし", 327 | 0x94: "2160p, アスペクト比 > 16:9", 328 | 0xa1: "480p(525p), アスペクト比4:3", 329 | 0xa2: "480p(525p), アスペクト比16:9 パンベクトルあり", 330 | 0xa3: "480p(525p), アスペクト比16:9 パンベクトルなし", 331 | 0xa4: "480p(525p), アスペクト比 > 16:9", 332 | 0xb1: "1080i(1125i), アスペクト比4:3", 333 | 0xb2: "1080i(1125i), アスペクト比16:9 パンベクトルあり", 334 | 0xb3: "1080i(1125i), アスペクト比16:9 パンベクトルなし", 335 | 0xb4: "1080i(1125i), アスペクト比 > 16:9", 336 | 0xc1: "720p(750p), アスペクト比4:3", 337 | 0xc2: "720p(750p), アスペクト比16:9 パンベクトルあり", 338 | 0xc3: "720p(750p), アスペクト比16:9 パンベクトルなし", 339 | 0xc4: "720p(750p), アスペクト比 > 16:9", 340 | 0xd1: "240p アスペクト比4:3", 341 | 0xd2: "240p アスペクト比16:9 パンベクトルあり", 342 | 0xd3: "240p アスペクト比16:9 パンベクトルなし", 343 | 0xd4: "240p アスペクト比 > 16:9", 344 | 0xe1: "1080p(1125p), アスペクト比4:3", 345 | 0xe2: "1080p(1125p), アスペクト比16:9 パンベクトルあり", 346 | 0xe3: "1080p(1125p), アスペクト比16:9 パンベクトルなし", 347 | 0xe4: "1080p(1125p), アスペクト比 > 16:9", 348 | 0xf1: "180p アスペクト比4:3", 349 | 0xf2: "180p アスペクト比16:9 パンベクトルあり", 350 | 0xf3: "180p アスペクト比16:9 パンベクトルなし", 351 | 0xf4: "180p アスペクト比 > 16:9", 352 | } 353 | 354 | export const AudioComponentType = { 355 | 0b00000: "将来使用のためリザーブ", 356 | 0b00001: "1/0モード(シングルモノ)", 357 | 0b00010: "1/0 + 1/0モード(デュアルモノ)", 358 | 0b00011: "2/0モード(ステレオ)", 359 | 0b00100: "2/1モード", 360 | 0b00101: "3/0モード", 361 | 0b00110: "2/2モード", 362 | 0b00111: "3/1モード", 363 | 0b01000: "3/2モード", 364 | 0b01001: "3/2 + LFEモード(3/2.1モード)", 365 | 0b01010: "3/3.1モード", 366 | 0b01011: "2/0/0-2/0/2-0.1モード", 367 | 0b01100: "5/2.1モード", 368 | 0b01101: "3/2/2.1モード", 369 | 0b01110: "2/0/0-3/0/2-0.1モード", 370 | 0b01111: "0/2/0-3/0/2-0.1モード", 371 | 0b10000: "2/0/0-3/2/3-0.2モード", 372 | 0b10001: "3/3/3-5/2/3-3/0/0.2モード", 373 | 0b10010: "将来使用のためリザーブ", 374 | 0b10011: "将来使用のためリザーブ", 375 | 0b10100: "将来使用のためリザーブ", 376 | 0b10101: "将来使用のためリザーブ", 377 | 0b10110: "将来使用のためリザーブ", 378 | 0b10111: "将来使用のためリザーブ", 379 | 0b11000: "将来使用のためリザーブ", 380 | 0b11001: "将来使用のためリザーブ", 381 | 0b11010: "将来使用のためリザーブ", 382 | 0b11011: "将来使用のためリザーブ", 383 | 0b11100: "将来使用のためリザーブ", 384 | 0b11101: "将来使用のためリザーブ", 385 | 0b11110: "将来使用のためリザーブ", 386 | 0b11111: "将来使用のためリザーブ", 387 | } 388 | 389 | export const AudioSamplingRate = { 390 | 16000: "16kHz", 391 | 22050: "22.05kHz", 392 | 24000: "24kHz", 393 | 32000: "32kHz", 394 | 44100: "44.1kHz", 395 | 48000: "48kHz", 396 | } 397 | -------------------------------------------------------------------------------- /src/pages/records/id.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" 3 | import { ChevronsDown, RefreshCw } from "react-feather" 4 | import { useToasts } from "react-toast-notifications" 5 | import { useDebounce, useUpdateEffect } from "react-use" 6 | import { useRecoilValue } from "recoil" 7 | import ReconnectingWebSocket from "reconnecting-websocket" 8 | import { playerSettingAtom } from "../../atoms/setting" 9 | import { CaptureButton } from "../../components/common/CaptureButton" 10 | import { CommentList } from "../../components/common/CommentList" 11 | import { CommentPlayer } from "../../components/common/CommentPlayer" 12 | import { AutoLinkedText } from "../../components/global/AutoLinkedText" 13 | import { Loading } from "../../components/global/Loading" 14 | import { NotFound } from "../../components/global/NotFound" 15 | import { PlayerController } from "../../components/records/PlayerController" 16 | import { useBackend } from "../../hooks/backend" 17 | import { useSaya } from "../../hooks/saya" 18 | import { useChannels } from "../../hooks/television" 19 | import { useRefState } from "../../hooks/util" 20 | import { CommentPayload, ProgramRecord } from "../../types/struct" 21 | import { wait } from "../../utils/wait" 22 | 23 | export const RecordIdPage: React.FC<{ id: string }> = ({ id }) => { 24 | const saya = useSaya() 25 | const backend = useBackend() 26 | const pid = parseInt(id) 27 | const toast = useToasts() 28 | const { channels } = useChannels() 29 | const [record, setRecord] = useState(null) 30 | const channel = useMemo( 31 | () => 32 | record && 33 | channels && 34 | channels.find((channel) => channel.id === record.channelId), 35 | [record] 36 | ) 37 | 38 | const isMounting = useRef(true) 39 | 40 | useEffect(() => { 41 | backend 42 | .getRecord({ id: pid }) 43 | .then((record) => setRecord(record)) 44 | .catch((error) => { 45 | console.error(error) 46 | setRecord(false) 47 | }) 48 | isMounting.current = true 49 | return () => { 50 | isMounting.current = false 51 | } 52 | }, []) 53 | 54 | const [currentStreamId, setCurrentStream, currentStreamRef] = useRefState(-1) 55 | const [hlsUrl, setHlsUrl] = useState(null) 56 | useUpdateEffect(() => { 57 | setHlsUrl(backend.getHlsStreamUrl({ id: currentStreamId })) 58 | }, [currentStreamId]) 59 | 60 | const playerSetting = useRecoilValue(playerSettingAtom) 61 | 62 | const [comments, setComments] = useState([]) 63 | const [comment, setComment] = useState(null) 64 | 65 | useDebounce( 66 | () => { 67 | if (100 < comments.length) { 68 | setComments((comments) => comments.splice(comments.length - 100)) 69 | } 70 | }, 71 | 50, 72 | [comments] 73 | ) 74 | 75 | const [position, setPosition, positionRef] = useRefState(0) 76 | const socket = useRef() 77 | 78 | const syncToPosition = () => 79 | send({ 80 | action: "Sync", 81 | seconds: Math.max( 82 | positionRef.current - (playerSetting.recordCommentDelay ?? 1), 83 | 0 84 | ), 85 | }) 86 | 87 | const [isLoading, setIsLoading, isLoadingRef] = useRefState(false) 88 | 89 | const claimRecordStream = useCallback( 90 | async (ss: number) => { 91 | if (!record || isLoadingRef.current) return 92 | setIsLoading(true) 93 | try { 94 | const streamId = await backend.startRecordHlsStream({ 95 | id: record.videoFiles[0].id, 96 | ss, 97 | }) 98 | while (isMounting.current) { 99 | const streams = await backend.getStreams() 100 | const stream = streams.find((stream) => stream.streamId === streamId) 101 | if (stream) { 102 | await backend.keepStream({ id: streamId }) 103 | if (stream.isEnable === true) break 104 | } 105 | await wait(1000) 106 | } 107 | setCurrentStream(streamId) 108 | ;(async () => { 109 | while (isMounting.current) { 110 | await backend.keepStream({ id: streamId }) 111 | if (streamId !== currentStreamRef.current) break 112 | await wait(1000) 113 | } 114 | await backend.dropStream({ id: streamId }) 115 | })() 116 | } catch (error) { 117 | toast.addToast("番組ストリームの読み込みに失敗しました", { 118 | appearance: "error", 119 | autoDismiss: true, 120 | }) 121 | return Promise.reject(error) 122 | } finally { 123 | setIsLoading(false) 124 | } 125 | }, 126 | [record] 127 | ) 128 | 129 | useUpdateEffect(() => { 130 | if (!record || !channel) return 131 | let s: ReconnectingWebSocket 132 | if (saya) { 133 | const wsUrl = saya.getRecordCommentSocketUrl({ 134 | channelType: channel.channelType, 135 | serviceId: channel.serviceId, 136 | startAt: record.startAt / 1000, 137 | endAt: record.endAt / 1000, 138 | }) 139 | 140 | let isFirst = true 141 | s = new ReconnectingWebSocket(wsUrl) 142 | s.reconnect() 143 | s.addEventListener("message", (e) => { 144 | const payload: CommentPayload = JSON.parse(e.data) 145 | setComment(payload) 146 | setComments((comments) => [...comments, payload]) 147 | }) 148 | s.addEventListener("open", () => { 149 | if (isFirst) { 150 | isFirst = false 151 | return 152 | } 153 | syncToPosition() 154 | send({ action: "Ready" }) 155 | }) 156 | socket.current = s 157 | } 158 | 159 | claimRecordStream(positionRef.current).then(() => { 160 | send({ action: "Ready" }) 161 | }) 162 | 163 | return () => { 164 | s?.close() 165 | } 166 | }, [record, channel]) 167 | 168 | const playerContainerRef = useRef(null) 169 | const [commentsHeight, setCommentsHeight] = useState( 170 | playerContainerRef.current?.clientHeight 171 | ) 172 | useEffect(() => { 173 | const onResize = () => { 174 | setCommentsHeight(playerContainerRef.current?.clientHeight) 175 | } 176 | onResize() 177 | window.addEventListener("resize", onResize) 178 | return () => { 179 | window.removeEventListener("resize", onResize) 180 | } 181 | }, [playerContainerRef.current]) 182 | 183 | const send = (arg: Object) => { 184 | if (!socket.current) return 185 | socket.current.send(JSON.stringify(arg)) 186 | } 187 | 188 | const [isPaused, setIsPaused] = useState(false) 189 | useEffect(() => { 190 | if (isPaused) { 191 | send({ action: "Pause" }) 192 | } else { 193 | syncToPosition() 194 | send({ action: "Resume" }) 195 | } 196 | }, [isPaused]) 197 | 198 | const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true) 199 | 200 | const seek = useCallback( 201 | async (s: number) => { 202 | if (!record || isLoadingRef.current) return 203 | try { 204 | setIsLoading(true) 205 | await claimRecordStream(s) 206 | setPosition(s) 207 | } catch (error) { 208 | console.error(error) 209 | } finally { 210 | setIsLoading(false) 211 | } 212 | }, 213 | [record] 214 | ) 215 | 216 | if (record === false) return 217 | if (record === null || !channel) return 218 | 219 | const duration = (record.endAt - record.startAt) / 1000 220 | 221 | return ( 222 |
223 |
224 |
225 | 234 | 240 |
241 |
245 |
246 | 247 | 248 | 257 | 270 | {/*`*/} 273 |
274 | 278 |
279 |
280 |
281 |
282 |
{record.name}
283 |
284 | {`${dayjs(record.startAt).format("YYYY/MM/DD HH:mm")} - ${dayjs( 285 | record.endAt 286 | ).format("HH:mm")} (${duration / 60}分間) / ${channel.name}`} 287 |
288 |
289 | 290 | {[record.description, record.extended] 291 | .filter((s) => !!s) 292 | .join("\n\n")} 293 | 294 |
295 |
296 |
297 |
298 | ) 299 | } 300 | -------------------------------------------------------------------------------- /src/pages/channels/id.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@chakra-ui/react" 2 | import dayjs from "dayjs" 3 | import React, { useCallback, useEffect, useRef, useState } from "react" 4 | import { ChevronsDown, ChevronsRight, RefreshCw } from "react-feather" 5 | import { useDebounce, useUpdateEffect } from "react-use" 6 | import { useRecoilValue } from "recoil" 7 | import ReconnectingWebSocket from "reconnecting-websocket" 8 | import { playerSettingAtom } from "../../atoms/setting" 9 | import { CaptureButton } from "../../components/common/CaptureButton" 10 | import { CommentList } from "../../components/common/CommentList" 11 | import { CommentM2tsPlayer } from "../../components/common/CommentM2tsPlayer" 12 | import { CommentPlayer } from "../../components/common/CommentPlayer" 13 | import { AutoLinkedText } from "../../components/global/AutoLinkedText" 14 | import { Loading } from "../../components/global/Loading" 15 | import { NotFound } from "../../components/global/NotFound" 16 | import { useBackend } from "../../hooks/backend" 17 | import { useNow } from "../../hooks/date" 18 | import { useSaya } from "../../hooks/saya" 19 | import { useRefState } from "../../hooks/util" 20 | import { CommentPayload, Program, Schedule } from "../../types/struct" 21 | import { wait } from "../../utils/wait" 22 | 23 | export const ChannelIdPage: React.FC<{ id: string }> = ({ id }) => { 24 | const saya = useSaya() 25 | const backend = useBackend() 26 | const sid = parseInt(id) 27 | const [schedule, setSchedule] = useState(null) 28 | 29 | const [currentStreamId, setCurrentStream, currentStreamRef] = useRefState(-1) 30 | const [liveUrl, setLiveUrl] = useState(null) 31 | useUpdateEffect(() => { 32 | setLiveUrl(backend.getHlsStreamUrl({ id: currentStreamId })) 33 | }, [currentStreamId]) 34 | 35 | const isMounting = useRef(true) 36 | 37 | useEffect(() => { 38 | backend 39 | .getChannelSchedules({ channelId: sid, days: 1 }) 40 | .then((schedule) => setSchedule(schedule)) 41 | .catch(() => setSchedule(false)) 42 | isMounting.current = true 43 | return () => { 44 | isMounting.current = false 45 | } 46 | }, []) 47 | 48 | const now = useNow() 49 | const [onGoingProgram, setOnGoingProgram] = useState(null) 50 | const [nextProgram, setNextProgram] = useState(null) 51 | const onGoingProgramDiff = 52 | onGoingProgram && dayjs(onGoingProgram.startAt).diff(now, "minute") 53 | 54 | useEffect(() => { 55 | if (!schedule) return 56 | const futurePrograms = schedule.programs 57 | .filter((p) => p.startAt && dayjs(p.endAt).isAfter(now)) 58 | .sort((a, b) => (b.startAt < a.startAt ? 1 : -1)) 59 | setOnGoingProgram(futurePrograms.shift() || null) 60 | 61 | setNextProgram(futurePrograms.shift() || null) 62 | }, [schedule, now]) 63 | 64 | const playerSetting = useRecoilValue(playerSettingAtom) 65 | 66 | const [comments, setComments] = useState([]) 67 | const [comment, setComment] = useState(null) 68 | 69 | useDebounce( 70 | () => { 71 | if (100 < comments.length) { 72 | setComments((comments) => comments.splice(comments.length - 100)) 73 | } 74 | }, 75 | 50, 76 | [comments] 77 | ) 78 | 79 | const [isLoading, setIsLoading, isLoadingRef] = useRefState(false) 80 | 81 | const claimChannelStream = useCallback(async () => { 82 | if (!schedule || isLoadingRef.current) return 83 | try { 84 | setIsLoading(true) 85 | const streamId = await backend.startChannelHlsStream({ 86 | id: schedule.channel.id, 87 | }) 88 | while (isMounting.current) { 89 | const streams = await backend.getStreams() 90 | const stream = streams.find((stream) => stream.streamId === streamId) 91 | if (stream) { 92 | await backend.keepStream({ id: streamId }) 93 | if (stream.isEnable === true) break 94 | } 95 | await wait(1000) 96 | } 97 | setCurrentStream(streamId) 98 | ;(async () => { 99 | while (isMounting.current) { 100 | await backend.keepStream({ id: streamId }) 101 | if (streamId !== currentStreamRef.current) break 102 | await wait(1000) 103 | } 104 | await backend.dropStream({ id: streamId }) 105 | })() 106 | } catch (error) { 107 | console.error(error) 108 | return Promise.reject(error) 109 | } finally { 110 | setIsLoading(false) 111 | } 112 | }, [schedule]) 113 | 114 | const socket = useRef() 115 | 116 | useEffect(() => { 117 | if (!schedule) return 118 | let s: ReconnectingWebSocket 119 | if (saya) { 120 | const wsUrl = saya.getLiveCommentSocketUrl({ 121 | channelType: schedule.channel.channelType, 122 | serviceId: schedule.channel.serviceId, 123 | }) 124 | s = new ReconnectingWebSocket(wsUrl) 125 | s.addEventListener("message", (e) => { 126 | const payload: CommentPayload = JSON.parse(e.data) 127 | setTimeout(() => { 128 | setComment(payload) 129 | setComments((comments) => [...comments, payload]) 130 | }, Math.abs((playerSetting.commentDelay || 0) * 1000 - payload.timeMs || 0)) 131 | }) 132 | socket.current = s 133 | } 134 | 135 | if (playerSetting.useMpegTs && playerSetting.mpegTsMode !== null) { 136 | setLiveUrl( 137 | backend.getM2tsStreamUrl({ 138 | id: schedule.channel.id, 139 | mode: playerSetting.mpegTsMode, 140 | }) 141 | ) 142 | } else { 143 | claimChannelStream() 144 | } 145 | return () => { 146 | s?.close() 147 | } 148 | }, [schedule]) 149 | 150 | useEffect(() => { 151 | if (!socket.current) return 152 | const timer = setInterval(() => { 153 | socket.current?.send(JSON.stringify({ type: "pong" })) 154 | }, 5000) 155 | return () => { 156 | clearInterval(timer) 157 | } 158 | }, [socket.current]) 159 | 160 | const playerContainerRef = useRef(null) 161 | const [commentsHeight, setCommentsHeight] = useState( 162 | playerContainerRef.current?.clientHeight 163 | ) 164 | useEffect(() => { 165 | const onResize = () => { 166 | setCommentsHeight(playerContainerRef.current?.clientHeight) 167 | } 168 | onResize() 169 | window.addEventListener("resize", onResize) 170 | return () => { 171 | window.removeEventListener("resize", onResize) 172 | } 173 | }, [playerContainerRef.current]) 174 | 175 | const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true) 176 | 177 | if (schedule === null) return 178 | if (schedule === false) return 179 | 180 | return ( 181 |
182 |
183 |
184 | {playerSetting.useMpegTs ? ( 185 | 192 | ) : ( 193 | 200 | )} 201 |
202 |
206 |
207 | 208 | 209 | 216 | 229 | {/*``*/} 232 |
233 | 237 |
238 |
239 |
240 |
241 |
242 | 243 | {onGoingProgram ? onGoingProgram.name : "."} 244 | 245 |
246 |
247 | 250 | {onGoingProgram && onGoingProgramDiff !== null 251 | ? `${dayjs(onGoingProgram.startAt).format("HH:mm")} [${Math.abs( 252 | onGoingProgramDiff 253 | )}分${0 < onGoingProgramDiff ? "後" : "前"}] - ${dayjs( 254 | onGoingProgram.endAt 255 | ).format("HH:mm")} (${ 256 | (onGoingProgram.endAt - onGoingProgram.startAt) / 1000 / 60 257 | }分間) / ${schedule.channel.name}` 258 | : "."} 259 | 260 |
261 |
262 | Next 263 | 264 | {nextProgram ? ( 265 | nextProgram.name 266 | ) : ( 267 | 不明 268 | )} 269 |
270 |
271 | 272 | {[onGoingProgram?.description, onGoingProgram?.extended] 273 | .filter((s) => !!s) 274 | .join("\n\n")} 275 | 276 |
277 |
278 | {/*
279 | 280 |
*/} 281 |
282 |
283 | ) 284 | } 285 | --------------------------------------------------------------------------------