First commit

This commit is contained in:
CyberRex
2026-07-31 14:39:33 +09:00
commit 38670efd46
103 changed files with 22514 additions and 0 deletions

3335
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
web/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "mirakurun-rs-web",
"private": true,
"scripts": {
"build": "webpack --mode production",
"build:development": "webpack --mode development",
"typecheck": "tsc --project src/tsconfig.json --noEmit"
},
"dependencies": {
"@blueprintjs/core": "^5.17.6",
"@blueprintjs/icons": "^5.20.0",
"buffer": "^6.0.3",
"eventemitter3": "4.0.7",
"ip-num": "1.3.4",
"jsonrpc2-ws": "1.0.0-beta23",
"luxon": "^3.6.1",
"normalize.css": "^8.0.1",
"process": "^0.11.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-indiana-drag-scroll": "^2.2.1",
"react-router-dom": "7.18.2",
"sift": "15.1.3"
},
"devDependencies": {
"@types/luxon": "^3.6.2",
"@types/node": "22",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/ws": "^7.4.7",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "5.2.7",
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"style-loader": "^2.0.0",
"ts-loader": "9.5.2",
"typescript": "5.7",
"webpack": "^5.107.0",
"webpack-cli": "^6.0.1"
},
"overrides": {
"uuid": "11.1.1"
}
}

View File

@@ -0,0 +1,33 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-date-time-range
position: relative
display: inline-block
span.relative
opacity: 0.7
span.progress
position: absolute
display: block
left: 0
right: 0
bottom: 0
height: 16%
opacity: 0.7
background-color: rgba(colors.$black, 0.25)
span
position: absolute
top: 0
left: 0
bottom: 0
width: 0
transition: width 0.5s ease
background-color: colors.$orange4
.bp5-dark &
background-color: rgba(colors.$white, 0.25)
span
background-color: colors.$orange5

View File

@@ -0,0 +1,105 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { DateTime } from "luxon";
import { inRange } from "../modules/common";
import { clearSchedule, setSchedule } from "../modules/at";
import "./DateTimeRange.sass";
type DateTimeRangeProps = {
start: number;
end?: number;
};
export const DateTimeRange: React.FC<DateTimeRangeProps> = ({ start, end }) => {
console.debug("components", "DateTimeRange");
const [update, setUpdate] = useState(0);
const nowDate = DateTime.now();
const startDate = DateTime.fromMillis(start);
const endDate = end ? DateTime.fromMillis(end) : undefined;
const durationS = endDate ? endDate.diff(startDate, "seconds").seconds : 0;
const deltaS = nowDate.diff(startDate, "seconds").seconds;
const progress = end && inRange(nowDate, startDate, endDate) ? deltaS / durationS : undefined;
const relative = progress ? "放送中" : `@${startDate.toRelative({ style: "narrow" })}`;
useEffect(() => {
const schedules: ReturnType<typeof setSchedule>[] = [];
if (nowDate <= startDate) {
schedules.push(setSchedule(startDate.toMillis(), () => setUpdate(Date.now())));
}
if (nowDate <= endDate) {
schedules.push(setSchedule(endDate.toMillis(), () => setUpdate(Date.now())));
}
return () => {
for (const id of schedules) {
clearSchedule(id);
}
};
}, [start, end]);
useEffect(() => {
let ms = 10000;
if (!progress) {
const diff = Math.abs(deltaS);
if (diff < 15) {
ms = 1000;
} else if (diff < 30) {
ms = 5000;
} else if (diff < 60) {
ms = 10000;
} else if (diff < 60 * 2) {
ms = 20000;
} else if (diff < 60 * 5) {
ms = 30000;
} else if (diff < 60 * 60) {
ms = 60000;
} else {
ms = 180000;
}
}
const timeoutId = setTimeout(() => setUpdate(Date.now()), ms);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [update]);
return (
<span className="component-date-time-range" title={startDate.toISO()}>
{startDate.toFormat("M/d (ccc) HH:mm")}
&nbsp;&nbsp;
{end && <>
{endDate.toFormat("HH:mm")}
&nbsp;({durationS / 60})
</>}
&nbsp;
<span className="relative">{relative}</span>
{progress && (
<span className="progress">
<span style={{ width: `${progress * 100}%` }} />
</span>
)}
</span>
);
};

View File

@@ -0,0 +1,333 @@
@use "sass:color"
@use "~@blueprintjs/colors/lib/scss/colors"
$header-height: 40px
$timescale-width: 24px
$block-width: 170px
$block-height: 240px
$timetable-border-color: colors.$gray5
.component-epg-table
position: absolute
top: 0
right: 0
bottom: 0
left: 0
background: colors.$light-gray2
.hide
opacity: 0
pointer-events: none
> *
position: absolute
> button.bp5-button
z-index: 2
backdrop-filter: blur(8px) brightness(1.1)
border-color: colors.$gray1 !important
transition: all 0.1s ease 0s
box-shadow: 0 0 0 1px rgba(colors.$white, 0.5) !important
color: colors.$black !important
.bp5-icon > svg:not([fill])
color: colors.$gray1 !important
&.jump-to-timeline
right: 25px
bottom: 25px
> .header
z-index: 1
top: 0
right: 0
left: 0
height: $header-height
white-space: nowrap
overflow: hidden
padding-left: $timescale-width
margin-left: 0px // for scroll
background: colors.$dark-gray5
.bp5-dark &
background: colors.$dark-gray1
.epg-table-header-item
vertical-align: top
display: inline-flex
align-items: center
overflow: hidden
font-size: 13px
font-weight: 500
line-height: $header-height
width: $block-width
height: $header-height
padding: 0 5px
color: colors.$light-gray4
&.date
font-weight: 600
.bp5-dark &
color: colors.$light-gray3
&:last-child
margin-right: 24px
&:not(.loading)
opacity: 0
animation: 0.4s ease 0.2s 1 normal forwards running fade-in
&:hover
background: color.adjust(colors.$dark-gray5, $lightness: -8%)
.bp5-dark &
background: color.adjust(colors.$dark-gray1, $lightness: 7%)
> img,
> div.img
width: 32px
height: 18px
margin-right: 5px
border-radius: 1px
filter: saturate(80%)
&:hover:not(.loading) > img
filter: none
> span
text-overflow: ellipsis
font-feature-settings: "palt" 1
overflow: hidden
&.bp5-skeleton
display: inline-block
width: 100px
height: 14px
> .timescale
z-index: 1
top: $header-height
right: 0
bottom: 0
left: 0
overflow: hidden
user-select: none
pointer-events: none
margin-top: 0px // for scroll
.timeline
position: absolute
top: -2px
right: 0
left: calc($timescale-width)
height: 2px
opacity: 0.5
pointer-events: all
box-shadow: 0 0 4px colors.$gray3
background: colors.$gray4
&.today
background: colors.$orange5
&,
> .clock
transition: all 0.4s ease 4s
&:hover,
&.show,
&:hover > .clock,
&.show > .clock
opacity: 1
transition: opacity 0.1s linear 0s
> .clock
position: absolute
top: -8px
left: 0
padding: 0 8px
line-height: 18px
font-size: 13px
font-weight: 500
background: inherit
opacity: 0
pointer-events: none
color: colors.$black
.timescale-item
height: $block-height
border-bottom: 1px dashed rgba($timetable-border-color, 0.5)
> div
width: $timescale-width
height: calc(100% + 1px)
padding-top: 6px
writing-mode: vertical-rl
text-orientation: sideways
font-size: 13px
font-weight: 400
letter-spacing: 0.1em
line-height: $timescale-width
pointer-events: all
border-bottom: 1px solid colors.$gray3
color: colors.$light-gray5
background: colors.$black
&:first-child > div
border-top: 1px solid colors.$gray3
// todo: to variables
&.hour-0 > div,
&.hour-1 > div,
&.hour-2 > div
background: rgb(0,51,127)
&.hour-3 > div,
&.hour-4 > div,
&.hour-5 > div
background: rgb(0,102,127)
&.hour-6 > div,
&.hour-7 > div,
&.hour-8 > div
background: rgb(0,127,102)
&.hour-9 > div,
&.hour-10 > div,
&.hour-11 > div
background: rgb(102,127,0)
&.hour-12 > div,
&.hour-13 > div,
&.hour-14 > div
background: rgb(127,102,0)
&.hour-15 > div,
&.hour-16 > div,
&.hour-17 > div
background: rgb(127,51,0)
&.hour-18 > div,
&.hour-19 > div,
&.hour-20 > div
background: rgb(127,0,102)
&.hour-21 > div,
&.hour-22 > div,
&.hour-23 > div
background: rgb(102,0,127)
> .timetable
display: flex
position: absolute
top: $header-height
left: $timescale-width
right: 0
bottom: 0
overflow: auto
> .bp5-spinner
position: absolute
top: calc(50% - 20px)
left: calc(50% - 20px)
opacity: 0
.bp5-spinner-track
stroke: rgba(95, 107, 124, 0.2)
.bp5-spinner-head
stroke: rgba(95, 107, 124, 0.8)
.timetable-col
position: relative
width: $block-width // for skeleton
flex-shrink: 0
overflow: hidden
border-right: 1px solid $timetable-border-color
background: colors.$light-gray3
opacity: 0
animation: 0.2s ease 0.1s 1 normal forwards running fade-in
button.timetable-cell
position: absolute
width: 100%
border: 0 transparent
text-align: left
padding: 0
overflow: hidden
border-bottom: 1px solid $timetable-border-color
background: #fff
color: colors.$dark-gray2
&.no-data
cursor: default
&:last-child
height: auto !important
bottom: 0
&:not(.no-data):hover
filter: brightness(0.97)
&.bp5-active
z-index: 2
box-shadow: inset 0 0 0 4px rgba(colors.$black, 0.15)
&.event-group-shared
color: colors.$blue2
&.event-group-shared,
&.no-data
opacity: 0.45
> div
position: absolute
top: 8px
right: 8px
bottom: 8px
left: 8px
line-height: 16px
font-size: 13px
word-break: break-all
overflow: hidden
time
margin-right: 4px
font-size: 10px
font-weight: 700
vertical-align: top
color: colors.$gray2
.description
margin-top: 4px
font-size: 12px
font-weight: 400
font-feature-settings: "palt" 1, "pwid" 1
line-height: 1.5
color: colors.$dark-gray5
.component-program-genres
margin-top: 4px
.caution
padding: 0
background: none
color: colors.$orange4
&.short > div
position: relative
top: auto
right: auto
bottom: auto
left: auto
margin: 0 8px
white-space: nowrap
font-size: 11px
&.x-short > div
top: 0
font-size: 10px
line-height: 10px
time
vertical-align: inherit
&.xx-short > div
> *
display: none
&.long > div
word-break: normal

View File

@@ -0,0 +1,654 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useRef } from "react";
import ScrollContainer from "react-indiana-drag-scroll";
import { Button, Spinner, NonIdealState } from "@blueprintjs/core";
import { DateTime } from "luxon";
import sift, { Query } from "sift";
import { LazyCaller } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import * as regexp from "../modules/regexp";
import { GenreUN2Map } from "../modules/constants";
import { Error, ChannelType, Service, Program, ProgramGenre } from "../../../api.d";
import { ProgramTitle } from "./ProgramTitle";
import { ProgramPopover } from "./ProgramPopover";
import { ProgramGenres } from "./ProgramGenres";
import "./EPGTable.sass";
const scrollState = {
left: {
GR: -1,
BS: -1,
CS: -1,
SKY: -1,
},
top: -1,
};
interface Dimensions {
// headerHeight: number;
// timescaleWidth: number;
timescaleHeight: number;
blockWidth: number;
// blockHeight: number;
scaleFactor: number;
}
type EPGTableProps = {
date: DateTime;
channelType?: ChannelType;
globalServiceId?: number;
defaultProgramId?: number;
defaultTime?: number;
};
export const EPGTable: React.FC<EPGTableProps> = ({ date, channelType, globalServiceId, defaultProgramId, defaultTime }) => {
console.debug("components", "EPGTable");
const startTime = date.toMillis();
const headerRef = useRef<HTMLDivElement>();
const timescaleRef = useRef<HTMLDivElement>();
const timelineRef = useRef<HTMLDivElement>();
const clockRef = useRef<HTMLDivElement>();
const timetableRef = useRef<HTMLDivElement>();
const headerItemRef = useRef<HTMLDivElement>();
const timescaleItemRef = useRef<HTMLDivElement>();
const jumpToTimelineRef = useRef<HTMLButtonElement>();
const [reload, setReload] = useState(0); // リロード用
const [dimensions, setDimensions] = useState<Dimensions>(null);
const [error, setError] = useState<Error>(null);
// null = loading, [] = empty
const [programId, setProgramId] = useState<number>(defaultProgramId || null);
const [time, setTime] = useState<number>(defaultTime || null);
const [services, setServices] = useState<Service[]>(null);
const [serviceItems, setServiceItems] = useState<JSX.Element[]>(null);
const [timetableCols, setTimetableCols] = useState<JSX.Element[]>(null);
if (globalServiceId) {
// 週間番組表
if (services) {
ui.setTitle(services[0]?.name);
} else if (error) {
ui.setTitle("エラー");
}
}
useEffect(() => {
const onUpdated = () => {
setReload(Date.now());
}
const onUpdatedLazy = new LazyCaller(0, 1000, onUpdated);
state.on("services", onUpdatedLazy.caller);
state.on("programs", onUpdatedLazy.caller);
state.subscribePrograms(true);
return () => {
state.off("services", onUpdatedLazy.caller);
state.off("programs", onUpdatedLazy.caller);
onUpdatedLazy.destroy();
}
}, []);
useEffect(() => {
return () => {
setError(null);
};
}, [state.location, reload]);
useEffect(() => {
return () => {
setServices(null);
setServiceItems(null);
};
}, [channelType]);
useEffect(() => {
return () => {
setTimetableCols(null);
};
}, [channelType]);
// 採寸
useEffect(() => {
const timescale = timescaleRef.current;
const headerItem = headerItemRef.current;
const timescaleItem = timescaleItemRef.current;
// 採寸
setDimensions({
// headerHeight: headerItem.offsetHeight,
// timescaleWidth: timescaleItem.offsetWidth,
timescaleHeight: timescale.scrollHeight,
blockWidth: headerItem.offsetWidth,
// blockHeight: timescaleItem.offsetHeight,
scaleFactor: timescaleItem.offsetHeight / 60,
});
}, []);
// スクロール連動・現在時刻・現在線の描画
useEffect(() => {
if (!dimensions) {
return;
}
const header = headerRef.current;
const timescale = timescaleRef.current;
const timeline = timelineRef.current;
const clock = clockRef.current;
const timetable = timetableRef.current;
const jumpToTimeline = jumpToTimelineRef.current;
if (!timetableCols) {
return;
}
const getPosition = () => Math.floor((Date.now() - state.todayTime) / 1000 / 60 * dimensions.scaleFactor);
// スクロール連動
const onScroll = () => {
scrollState.left[channelType] = header.scrollLeft = timetable.scrollLeft;
scrollState.top = timescale.scrollTop = timetable.scrollTop;
};
timetable.addEventListener("scroll", onScroll);
// 現在時刻を表示する
timeline.style.opacity = "";
let showClockTimeout: ReturnType<typeof setTimeout>;
const showClock = () => {
showClockTimeout = setTimeout(() => timeline.classList.remove("show"), 1000);
timeline.classList.add("show");
};
showClock();
// スクロール初期位置セット
timetable.scrollLeft = Math.round(
scrollState.left[channelType] > -1
? scrollState.left[channelType]
: 0
);
if (time) {
// スクロール時間指定
const position = Math.floor(time / 1000 / 60 * dimensions.scaleFactor);
timetable.scrollTop = Math.round(position - timetable.clientHeight / 4);
setTime(null);
} else {
// 現在時刻
timetable.scrollTop = Math.round(
scrollState.top > -1
? scrollState.top
: (getPosition() - timetable.clientHeight / 4)
);
}
// 現在線の描画
const updateTimeline = () => {
const position = getPosition();
const { scrollTop, clientHeight } = timetable;
// 色切り替え
if (state.todayTime === startTime) {
// 今日
timeline.classList.add("today");
} else {
// 今日じゃない
timeline.classList.remove("today");
}
// 現在線位置
timeline.style.top = `${position}px`;
// 時刻表示
const clockText = DateTime.now().toFormat("HH:mm");
if (clock.innerText !== clockText) {
clock.innerText = clockText;
showClock();
}
// ボタン表示
if (position > scrollTop && position < scrollTop + clientHeight) {
jumpToTimeline.classList.add("hide");
} else {
jumpToTimeline.classList.remove("hide");
}
};
const updateTimelineInterval = setInterval(updateTimeline, 1500);
updateTimeline();
return () => {
timetable.removeEventListener("scroll", onScroll);
clearInterval(updateTimelineInterval);
clearTimeout(showClockTimeout);
};
}, [startTime, timetableCols]);
// サービス一覧の取得
useEffect(() => {
if (!dimensions) {
return;
}
if (globalServiceId) {
// 週間番組表
const _service = state.services.find(s => s.id === globalServiceId);
if (!_service) {
setError({ code: 404, reason: "サービスが見つかりません" });
return;
}
setServices([_service]);
return;
}
// 全体番組表
const _services = state.services
.filter(s => s.type === 1)
.filter(s => channelType ? s.channel.type === channelType : true);
// ソート
_services.sort((a, b) => {
if (a.remoteControlKeyId && b.remoteControlKeyId) {
return a.remoteControlKeyId - b.remoteControlKeyId;
}
if (a.remoteControlKeyId && !b.remoteControlKeyId) {
return -1;
}
if (!a.remoteControlKeyId && b.remoteControlKeyId) {
return 1;
}
return a.id - b.id;
});
setServices(_services);
}, [channelType, dimensions, reload]);
// 番組一覧
useEffect(() => {
if (!services || services.length === 0) {
return;
}
console.debug("EPGTable", "services", services);
const query: Query<Program> = {
startAt: {
$gte: startTime - 60 * 60 * 2 * 1000,
$lt: startTime + 60 * 60 * 28 * 1000
}
};
if (channelType) {
query.serviceId = { $in: services.map(s => s.serviceId) };
} else if (globalServiceId) {
query.serviceId = services[0].serviceId;
query.startAt["$lt"] = startTime + 60 * 60 * 24 * 8 * 1000;
}
const filteredPrograms = state.programs.filter(sift(query));
console.debug("EPGTable", "filteredPrograms", filteredPrograms);
const programMap = new Map<string, Program>(); // イベントグループ検索用
const _serviceItems: JSX.Element[] = [];
const cols: JSX.Element[] = [];
// サービスごとにループ
for (let i = 0; i < services.length; i++) {
const service = services[i];
const servicePrograms: Program[] = [];
let count = 0;
// サービス絞り込み
for (const program of filteredPrograms) {
if (program.serviceId !== service.serviceId || program.networkId !== service.networkId) {
continue;
}
if (program.relatedItems?.filter(item => item.type === "shared").length !== 1) {
// オリジナルイベントのみをカウント
count++;
// イベントグループ被参照対象
programMap.set(`${program.serviceId}.${program.eventId}`, program);
}
servicePrograms.push(program);
}
if (count === 0) {
continue;
}
// ソート
servicePrograms.sort((a, b) => {
return a.startAt - b.startAt;
});
if (!globalServiceId) {
// 全体番組表
let className = "epg-table-header-item";
_serviceItems.push(
<button className={className} key={service.id}
onClick={() => {
state.navigate(`/epg/services/${service.id}?date=${date.toISODate()}`);
}}
>
{service.hasLogoData && <img src={`/api/services/${service.id}/logo`} />}
<span>{service.name}</span>
</button>
);
} else {
// 週間番組表
for (let i = 0; i < 8; i++) {
const cur = date.plus({ days: i });
_serviceItems.push(
<button className="epg-table-header-item date" key={`${service.id}-${i}`}
onClick={() => {
state.navigate(`/epg?type=${service.channel.type}&date=${cur.toISODate()}`);
}}
>
<span>{cur.toFormat("M月d日ccc")}</span>
</button>
);
}
}
// 放送終了ダミーデータ挿入
const last = servicePrograms[servicePrograms.length - 1];
servicePrograms.push({
id: last.id + 0.1,
eventId: last.eventId + 0.1,
serviceId: last.serviceId,
networkId: last.networkId,
startAt: last.startAt + last.duration,
duration: 60 * 15 * 1000,
isFree: false,
name: service.epgReady ? "(放送休止・未定)" : "(未受信)",
description: "no-data",
genres: [],
});
const cells: JSX.Element[] = [];
// 週間番組表用
const splitIndexes: number[] = [];
const maxHeight = 60 * 24 * dimensions.scaleFactor;
let topOffset = 0;
for (let i = 0; i < servicePrograms.length; i++) {
let program = { ...servicePrograms[i] };
let className = "timetable-cell";
const prev = servicePrograms[i - 1];
if (prev && (prev.startAt + prev.duration) !== program.startAt) {
// 放送未定ダミーデータ挿入
program = {
id: prev.id + 0.1,
eventId: prev.eventId + 0.1,
serviceId: prev.serviceId,
networkId: prev.networkId,
startAt: prev.startAt + prev.duration,
duration: program.startAt - (prev.startAt + prev.duration),
isFree: false,
name: service.epgReady ? "(放送休止・未定)" : "(未受信)",
description: "no-data",
genres: [],
};
servicePrograms.splice(i, 0, program);
}
if (program.description === "no-data") {
className += " no-data";
program.description = undefined;
}
const programStartTime = program.startAt;
const programStartDate = new Date(program.startAt);
let top = Math.floor((programStartTime - startTime) / 1000 / 60 * dimensions.scaleFactor) + topOffset;
let height = Math.floor(program.duration / 1000 / 60 * dimensions.scaleFactor);
if (globalServiceId) {
// 週間番組表用
if (top + height >= maxHeight) {
// 日付跨ぎ
splitIndexes.push(i + 1);
topOffset -= maxHeight;
// 日付の最後の番組を24時の位置に合わせる
height -= top + height - maxHeight;
// 分割
servicePrograms.splice(i, 0, {
...program,
});
}
}
if (top < 0) {
// 日付の最初の番組を0時の位置に合わせる
height += top;
top = 0;
}
const isShort = height <= 40;
if (isShort) {
className += " short";
if (height <= 16) {
className += " x-short";
}
if (height <= 10) {
className += " xx-short";
}
} else {
if (height >= 240) {
className += " long";
}
}
if (program.relatedItems && program.relatedItems.filter(item => item.type === "shared").length === 1) {
className += " event-group-shared";
const ref = programMap.get(`${program.relatedItems[0].serviceId}.${program.relatedItems[0].eventId}`)
if (ref) {
program.name = program.name || ref.name;
program.genres = program.genres || ref.genres;
program.description = program.description || "(イベント共有)";
}
}
const cautions: ProgramGenre[] = [];
if (program.genres && program.genres[0]) {
className += ` bg-genre-lv1-${program.genres[0].lv1}`;
for (const genre of program.genres) {
const un2Text = GenreUN2Map[(genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2];
if (un2Text) {
cautions.push(genre);
}
}
}
const defaultIsOpen = programId === program.id;
if (defaultIsOpen) {
setProgramId(null);
}
cells.push(
<ProgramPopover
key={`event-${program.eventId}-${program.startAt}`}
className={className}
program={program}
defaultIsOpen={defaultIsOpen}
renderTarget={({ isOpen, ...props }) => (
<button style={{ top, height }} {...props}>
<div>
<time dateTime={programStartDate.toISOString()}>{programStartDate.getMinutes()}</time>
<ProgramTitle program={program} />
{!isShort && program.description && (
<div className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</div>
)}
{!isShort && cautions.length > 0 && <ProgramGenres genres={cautions} />}
</div>
</button>
)}
/>
);
}
if (!globalServiceId) {
// 全体番組表
cols.push(
<div key={service.id}
className="timetable-col"
style={{ width: dimensions.blockWidth, height: dimensions.timescaleHeight }}
>
{cells}
</div>
);
} else {
// 週間番組表
for (let i = 0; i < splitIndexes.length; i++) {
cols.push(
<div key={`${service.id}-${i}`}
className="timetable-col"
style={{ width: dimensions.blockWidth, height: maxHeight }}
>
{cells.slice(splitIndexes[i - 1] || 0, splitIndexes[i] || cells.length)}
</div>
);
}
}
}
setServiceItems(_serviceItems);
setTimetableCols(cols);
}, [startTime, services]);
const timescaleDateShort = date.toFormat("M/d(ccc)");
const timescaleDateExtended = date.plus({ days: 1 }).toFormat("M/d(ccc)");
return (
<div className="component-epg-table">
<div className="header" ref={headerRef}>
{!serviceItems && !error && <>
<div className="epg-table-header-item loading" ref={headerItemRef}><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
</>}
{serviceItems}
</div>
<div className="timescale" ref={timescaleRef}>
<div className="timeline" ref={timelineRef}>
<div className="clock" ref={clockRef}>00:00</div>
</div>
<div className="timescale-item hour-0" ref={timescaleItemRef}><div>{timescaleDateShort} 0</div></div>
<div className="timescale-item hour-1"><div>1</div></div>
<div className="timescale-item hour-2"><div>2</div></div>
<div className="timescale-item hour-3"><div>{timescaleDateShort} 3</div></div>
<div className="timescale-item hour-4"><div>4</div></div>
<div className="timescale-item hour-5"><div>5</div></div>
<div className="timescale-item hour-6"><div>{timescaleDateShort} 6</div></div>
<div className="timescale-item hour-7"><div>7</div></div>
<div className="timescale-item hour-8"><div>8</div></div>
<div className="timescale-item hour-9"><div>{timescaleDateShort} 9</div></div>
<div className="timescale-item hour-10"><div>10</div></div>
<div className="timescale-item hour-11"><div>11</div></div>
<div className="timescale-item hour-12"><div>{timescaleDateShort} 12</div></div>
<div className="timescale-item hour-13"><div>13</div></div>
<div className="timescale-item hour-14"><div>14</div></div>
<div className="timescale-item hour-15"><div>{timescaleDateShort} 15</div></div>
<div className="timescale-item hour-16"><div>16</div></div>
<div className="timescale-item hour-17"><div>17</div></div>
<div className="timescale-item hour-18"><div>{timescaleDateShort} 18</div></div>
<div className="timescale-item hour-19"><div>19</div></div>
<div className="timescale-item hour-20"><div>20</div></div>
<div className="timescale-item hour-21"><div>{timescaleDateShort} 21</div></div>
<div className="timescale-item hour-22"><div>22</div></div>
<div className="timescale-item hour-23"><div>23</div></div>
<div className="timescale-item hour-0"><div>{timescaleDateExtended} 0 (24)</div></div>
<div className="timescale-item hour-1"><div>1 (25)</div></div>
<div className="timescale-item hour-2"><div>2 (26)</div></div>
<div className="timescale-item hour-3"><div>{timescaleDateExtended} 3 (27)</div></div>
{timetableCols && <div className="timescale-item reserve"><div></div></div>}
</div>
<Button variant="outlined" className="jump-to-timeline hide" ref={jumpToTimelineRef} icon="selection"
text="現在時刻へ"
onClick={() => {
ui.blur();
jumpToTimelineRef.current.classList.add("hide");
const { clientHeight } = timetableRef.current;
const { offsetTop } = timelineRef.current;
timetableRef.current.scrollTop = offsetTop - clientHeight / 4;
}}
/>
<ScrollContainer className="timetable" innerRef={timetableRef} hideScrollbars={false}
onClick={(a) => {
ui.blur();
}}
>
{!error && timetableCols === null
? <Spinner intent="none" size={40} />
: timetableCols
}
</ScrollContainer>
{(state.programs.length === 0 || state.services.length === 0) && !error && <>
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="データを待機しています..."
/>
</> || services?.length === 0 && !error && <>
<NonIdealState
icon="satellite"
title="放送サービスなし"
description="指定された放送波のチャンネルが見つかりません"
/>
</> || timetableCols?.length === 0 && !error && <>
<NonIdealState
icon="satellite"
title="放送イベントなし"
description="指定された日付と放送波の番組情報が見つかりません"
/>
</>}
{error && <>
<NonIdealState
icon="warning-sign"
title={`${error.code} Error`}
description={error.reason || "エラーが発生しました"}
/>
</>}
</div>
);
};

View File

@@ -0,0 +1,82 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "../vars"
.component-nav.bp5-navbar
> .bp5-navbar-group
> img.product-icon
width: 28px
height: 28px
margin-right: 10px
> .bp5-navbar-heading.product-name
font-size: 18px
font-weight: 300
> .version
font-size: 10px
font-weight: 400
top: -1em
color: vars.$theme-light-primary
.bp5-dark &
color: vars.$theme-dark-primary
> .bp5-input-group
.bp5-input
&:not(:hover,:focus)
box-shadow: none
> .bp5-button
span.badge
margin-left: 5px
font-size: 11px
color: vars.$theme-light-primary
.bp5-dark &
color: vars.$theme-dark-primary
> .bp5-button.active
border-bottom-right-radius: 0
border-bottom-left-radius: 0
box-shadow: 0 2px vars.$theme-light-primary
.bp5-dark &
box-shadow: 0 2px vars.$theme-dark-primary
//&:hover:not(.bp5-popover-target)
// background: none
// cursor: default
// responsive
.component-nav.bp5-navbar
> .bp5-navbar-group.bp5-align-left
.bp5-navbar-heading
@media (max-width: 800px)
display: none
.bp5-input-group
width: 200px
@media (max-width: 1000px)
width: 180px
@media (max-width: 450px)
width: 130px
> .bp5-navbar-group.bp5-align-right
@media (max-width: 950px)
button
.bp5-icon:not(:last-child)
margin: 0 -7px
.bp5-button-text
display: none
@media (max-width: 950px) and (min-width: 600px)
button:hover
.bp5-icon:not(:last-child)
margin: 0 7px 0 0
.bp5-button-text
display: block

169
web/src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,169 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState, useCallback } from "react";
import { Alignment, Button, ButtonProps, Navbar, Menu, MenuItem, MenuDivider, Popover, PopoverTargetProps } from "@blueprintjs/core";
import { state } from "../modules/state";
import { useLocalStorageState } from "../hooks/useWebStorageState";
import { VersionStatus } from "./VersionStatus";
import { Restart } from "./Restart";
import "./Nav.sass";
type NavProps = {
pathLv1: string;
};
export const Nav: React.FC<NavProps> = ({ pathLv1 }) => {
console.debug("components", "Nav", pathLv1);
const { navigate, searchParams } = state;
const query = searchParams.get("q") || null;
const [icon, setIcon] = useState<string>(state.statusIconSrc);
useEffect(() => {
const onStatusIconKey = () => {
console.log("Nav", "onStatusIconKey", state.statusIconKey, state.statusIconSrc);
setIcon(state.statusIconSrc);
};
state.on("statusIconKey", onStatusIconKey);
return () => {
state.off("statusIconKey", onStatusIconKey);
};
}, []);
const [version, setVersion] = useState<string>(state.version);
useEffect(() => {
const onVersion = () => {
console.log("Nav", "onVersion", state.version);
setVersion(state.version);
};
state.on("version", onVersion);
return () => {
state.off("version", onVersion);
};
}, []);
const [dark, setDark] = useLocalStorageState<boolean>("dark", true);
useEffect(() => {
document.body.classList.toggle("bp5-dark", dark);
}, [dark]);
const getNavbarButtonProps = useCallback((name: string, className = "") => {
const props: ButtonProps = {
onClick: () => {
state.navigate("/" + name);
}
};
if (name === pathLv1) {
props.className = `${className} active`.trim();
}
return props;
}, [pathLv1]);
const [searchQuery, setSearchQuery] = useState<string>(query || "");
const executeSearch = useCallback(() => {
state.navigate(`/epg/search?q=${encodeURIComponent(searchQuery.trim())}`);
}, [searchQuery]);
const [runningJobs, setRunningJobs] = useState<number>(state.jobs.filter((job) => job.status === "running").length);
const [restartDialogOpen, setRestartDialogOpen] = useState<boolean>(false);
useEffect(() => {
const onJobs = () => {
setRunningJobs(state.jobs.filter((job) => job.status === "running").length);
};
state.on("jobs", onJobs);
return () => {
state.off("jobs", onJobs);
};
}, []);
return (
<Navbar className="component-nav">
<Navbar.Group align={Alignment.START}>
<img className="product-icon" src={icon} alt={state.statusName} />
<Navbar.Heading className="product-name">
Mirakurun
<sup className="version">{version}</sup>
</Navbar.Heading>
<div className="bp5-input-group">
<span className="bp5-icon bp5-icon-search"></span>
<input
type="text"
className="bp5-input"
placeholder="番組検索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
executeSearch();
}
}}
/>
<Button
variant="minimal"
className="bp5-intent-primary"
icon="arrow-right"
title="検索"
onClick={executeSearch}
/>
</div>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button variant="minimal" {...getNavbarButtonProps("")} icon="home" title="Home" text="Home" />
<Button variant="minimal" {...getNavbarButtonProps("epg")} icon="timeline-events" title="EPG" text="EPG" />
<Button variant="minimal" {...getNavbarButtonProps("jobs")} icon="ninja" title="ジョブ" text={
<>
{runningJobs !== 0 && <span className="badge">{runningJobs}</span>}
</>
} />
<Navbar.Divider />
<Button variant="minimal" {...getNavbarButtonProps("logs")} icon="pulse" title="ログ" />
<Popover
minimal
interactionKind="hover"
placement="bottom-end"
modifiers={{ offset: { enabled: true } }}
content={
<Menu>
{dark
? <MenuItem icon="flash" text="ライトテーマ" onClick={() => { setDark(false); }} />
: <MenuItem icon="moon" text="ダークテーマ" onClick={() => { setDark(true); }} />
}
<MenuDivider />
<MenuItem onClick={() => { state.navigate("/config/server"); }} icon="wrench" text="サーバー設定" />
<MenuItem onClick={() => { state.navigate("/config/tuners"); }} icon="wrench" text="チューナー設定" />
<MenuItem onClick={() => { state.navigate("/config/channels"); }} icon="wrench" text="チャンネル設定" />
<MenuDivider />
<MenuItem onClick={() => { window.open("/api/debug", "_blank"); }} icon="document" text="API Docs" />
<MenuDivider />
<MenuItem onClick={() => { state.navigate("/about"); }} icon="info-sign" textClassName="product-name" text={`Mirakurun ${version} について`} />
<VersionStatus asMenuItem />
<MenuItem icon="power" intent="danger" text="再起動..." onClick={() => setRestartDialogOpen(true)} />
</Menu>
}
renderTarget={({ isOpen, ref, ...props }: PopoverTargetProps) => (
<Button {...props} active={isOpen} ref={ref} variant="minimal" icon="cog" />
)}
/>
<Restart isOpen={restartDialogOpen} onClose={() => setRestartDialogOpen(false)} />
</Navbar.Group>
</Navbar>
);
};

View File

@@ -0,0 +1,23 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-av-info
display: flex
flex-wrap: wrap
gap: 5px
font-size: 10px
font-weight: 600
color: colors.$gray1
span
padding: 2px 4px
border-radius: 2px
border: 1px solid
&.video
color: colors.$gold4
&.type
text-transform: uppercase
&.audio
color: colors.$vermilion4

View File

@@ -0,0 +1,63 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { langMap, audioModeMap } from "../modules/constants";
import { ProgramVideo, ProgramAudio } from "../../../api.d";;
import "./ProgramAVInfo.sass";
type ProgramAVInfoProps = {
video: ProgramVideo;
audios: ProgramAudio[];
};
export const ProgramAVInfo: React.FC<ProgramAVInfoProps> = ({ video, audios }) => {
// console.debug("components", "ProgramAVInfo");
const labels: JSX.Element[] = [];
if (video) {
if (video.type !== "mpeg2") {
labels.push(<span key="video.type" className="video type">{video.type}</span>);
}
labels.push(<span key="video.resolution" className="video resolution">{video.resolution}</span>);
}
if (audios) {
let count = 0;
for (const audio of audios) {
const trackPrefix = count === 0 ? "主" : "副";
const type8 = audio.componentType.toString(2).padStart(8, "0");
const mode = audioModeMap[type8.slice(-5)] || "不明なモード";
const lang = audio.langs.map(lang => langMap[lang]).join("");
labels.push(
<span key={`audios.${count}`} className="audio">
{audios.length > 1 && <>{trackPrefix}:&nbsp;</>}
{mode}
{lang !== "日本語" && <>&nbsp;/&nbsp;{lang}</>}
</span>
);
count++;
}
}
return (
<div className="component-program-av-info">
{labels}
</div>
);
};

View File

@@ -0,0 +1,44 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-card-base
> div:not(:last-child):not(:first-child)
margin: 10px 0
.component-service-link
margin-bottom: 10px
p.title
margin-top: 0
.component-program-title
font-size: 14px
line-height: 17px
p.datetime
font-size: 12px
font-weight: 600
color: colors.$gray2
.bp5-dark &
color: colors.$gray4
p.description
font-size: 13px
font-feature-settings: "palt" 1
.bp5-dark &
color: colors.$light-gray2
.actions
margin-top: 15px
> div
display: flex
column-gap: 5px
margin-top: 10px
> a.more
flex-grow: 2
> button
width: 100%

View File

@@ -0,0 +1,120 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { getGlobalServiceId } from "../modules/common";
import { setSchedule, clearSchedule } from "../modules/at";
import * as regexp from "../modules/regexp";
import { Program } from "../../../api.d";
import { ServiceLink } from "./ServiceLink";
import { ProgramTitle } from "./ProgramTitle";
import { DateTimeRange } from "./DateTimeRange";
import { ProgramGenres } from "./ProgramGenres";
import { ProgramAVInfo } from "./ProgramAVInfo";
import { WatchButton } from "./WatchButton";
import "./ProgramCardBase.sass";
type ProgramCardBaseProps = {
program: Program;
noAVInfo?: boolean;
noActions?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
export const ProgramCardBase: React.FC<ProgramCardBaseProps> = ({ program, noAVInfo, noActions, ...props }) => {
console.debug("components", "ProgramCardBase", program);
const now = Date.now();
const endAt = program.startAt + program.duration;
const isDummy = !Number.isInteger(program.id);
const date = DateTime.fromMillis(program.startAt).set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
const time = DateTime.fromMillis(program.startAt).diff(date).toMillis();
const timeForServiceLink = (time > (1000 * 60 * 60 * 24 - 1000 * 60 * 5)) ? 1 : time;
const [isOnAir, setIsOnAir] = useState(!isDummy && program.startAt <= now && endAt >= now);
useEffect(() => {
if (isDummy || noActions) {
return;
}
const schedules: ReturnType<typeof setSchedule>[] = [];
if (now <= program.startAt) {
schedules.push(setSchedule(program.startAt, () => setIsOnAir(true)));
}
if (now <= endAt) {
schedules.push(setSchedule(endAt, () => setIsOnAir(false)));
}
return () => {
for (const id of schedules) {
clearSchedule(id);
}
};
}, [program]);
return (
<div className="component-program-card-base" {...props}>
<ServiceLink
globalId={getGlobalServiceId(program.networkId, program.serviceId)}
date={date.toISODate()}
time={timeForServiceLink}
/>
<p className="title">
{noActions && (
<Link to={`/epg/programs/${program.id}`}>
<ProgramTitle program={program} />
</Link>
)}
{!noActions && (
<ProgramTitle program={program} />
)}
</p>
<p className="datetime">
<DateTimeRange start={program.startAt} end={endAt} />
</p>
{program.description && <p className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</p>}
{program.genres?.length > 0 && (
<ProgramGenres genres={program.genres} />
)}
{!noAVInfo && (program.video || program.audios) && (
<ProgramAVInfo video={program.video} audios={program.audios} />
)}
{!noActions && !isDummy && (
<div className="actions">
<div>
<Link className="more" to={`/epg/programs/${program.id}`}>
<Button variant="outlined" intent="primary" icon="arrow-right" text="番組詳細" />
</Link>
{(isOnAir) && (
<WatchButton variant="outlined" popoverPlacement="top-start" globalServiceId={getGlobalServiceId(program.networkId, program.serviceId)} />
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,20 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "../vars"
.component-program-genres
display: flex
flex-wrap: wrap
gap: 5px
font-size: 11px
font-weight: 600
font-feature-settings: "palt" 1
color: #000
span
padding: 2px 4px
border-radius: 2px
filter: vars.$invert-filter
&.caution
filter: none
background: colors.$orange5

View File

@@ -0,0 +1,60 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { Genre1Map, Genre2Map, GenreUN1Map, GenreUN2Map } from "../modules/constants";
import { ProgramGenre } from "../../../api.d";
import "./ProgramGenres.sass";
type ProgramGenresProps = {
genres: ProgramGenre[];
};
export const ProgramGenres: React.FC<ProgramGenresProps> = ({ genres }) => {
// console.debug("components", "ProgramGenres");
const lv1Set = new Set<number>();
const labels: JSX.Element[] = [];
for (const genre of genres) {
const lv1Text = Genre1Map[genre.lv1];
if (!lv1Text) {
continue;
}
const un2Text = GenreUN2Map[(genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2];
if (un2Text) {
const key = (genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2;
if (key < 0xE000 || key > 0xE020) {
continue;
}
labels.push(<span key={key} className="caution">{un2Text}</span>);
continue;
}
if (!lv1Set.has(genre.lv1)) {
lv1Set.add(genre.lv1);
labels.push(<span key={genre.lv1} className={`bg-genre-lv1-${genre.lv1}`}>{lv1Text}</span>);
}
const lv2Text = Genre2Map[(genre.lv1 * 0x10) + genre.lv2];
labels.push(<span key={`${genre.lv1}.${genre.lv2}`} className={`bg-genre-lv1-${genre.lv1}`}>{lv2Text}</span>);
}
return (
<div className="component-program-genres">
{labels}
</div>
);
};

View File

@@ -0,0 +1,4 @@
.component-program-popover
padding: 15px
min-width: 300px
max-width: 380px

View File

@@ -0,0 +1,70 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { Popover, PopoverTargetProps } from "@blueprintjs/core";
import { Program } from "../../../api.d";
import { ProgramCardBase } from "./ProgramCardBase";
import "./ProgramPopover.sass";
type ProgramPopoverProps<T = {}> = {
program: Program;
key?: string;
className?: string;
portalContainer?: HTMLElement;
defaultIsOpen?: boolean;
renderTarget: (props: PopoverTargetProps & T) => JSX.Element;
};
export const ProgramPopover: React.FC<ProgramPopoverProps> = ({ program, renderTarget, className = "", defaultIsOpen = false, ...props }) => {
// console.debug("components", "ProgramPopover");
const [active, setActive] = useState(defaultIsOpen);
const [content, setContent] = useState<React.JSX.Element>(null);
useEffect(() => {
if (!active) {
return;
}
setContent(<ProgramCardBase program={program} />);
return () => {
setContent(null);
};
}, [active]);
if (className) {
className += " ";
}
className += "bp5-dark";
return (
<Popover {...props}
className={className}
renderTarget={renderTarget}
defaultIsOpen={active}
onOpening={() => setActive(true)}
onClosed={() => setActive(false)}
content={
<div className="component-program-popover">
{content}
</div>
}
/>
);
};

View File

@@ -0,0 +1,16 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-related-links
display: flex
gap: 10px
> .bp5-section
min-width: 300px
max-width: 380px
background-color: colors.$light-gray4
.bp5-dark &
background-color: colors.$dark-gray4
.component-program-card-base
padding: 15px

View File

@@ -0,0 +1,104 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useMemo } from "react";
import { Section } from "@blueprintjs/core";
import { relatedItemTypeMap, relatedItemTypeIconMap } from "../modules/constants";
import { state } from "../modules/state";
import { Program } from "../../../api.d";
import { ProgramCardBase } from "./ProgramCardBase";
import "./ProgramRelatedLinks.sass";
type ProgramRelatedLinksProps = {
program: Program;
};
export const ProgramRelatedLinks: React.FC<ProgramRelatedLinksProps> = ({ program }) => {
console.debug("components", "ProgramRelatedLinks");
const [links, setLinks] = useState<React.JSX.Element[]>([]);
const relatedItems = useMemo(() => {
return program?.relatedItems?.filter(item => {
if (item.networkId) {
return item.eventId !== program.eventId || item.serviceId !== program.serviceId || item.networkId !== program.networkId;
}
return item.eventId !== program.eventId || item.serviceId !== program.serviceId;
});
}, [program]);
useEffect(() => {
if (!relatedItems || relatedItems.length === 0) {
setLinks([]);
return;
}
console.debug("ProgramRelatedLinks", "relatedItems", relatedItems);
let abort = false;
(async () => {
const _links: React.JSX.Element[] = [];
const programs = state.programs.length > 0 ? state.programs : await state.fetchPrograms();
if (abort) {
return;
}
for (const item of relatedItems) {
const p = programs.find(p => {
if (item.networkId) {
return p.eventId === item.eventId && p.serviceId === item.serviceId && p.networkId === item.networkId;
}
return p.eventId === item.eventId && p.serviceId === item.serviceId;
});
if (!p) {
continue;
}
const link = (
<Section
key={`${item.type}-${item.eventId}-${item.serviceId}`}
className={`related-item-type-${item.type}`}
icon={relatedItemTypeIconMap[item.type]}
title={relatedItemTypeMap[item.type]}
compact
>
<ProgramCardBase program={p} />
</Section>
);
_links.push(link);
}
setLinks(_links);
})();
return () => {
abort = true;
}
}, [relatedItems]);
if (links.length === 0) {
return <></>;
}
return (
<div className="component-program-related-links">
{links}
</div>
);
};

View File

@@ -0,0 +1,38 @@
@use "~@blueprintjs/colors/lib/scss/colors"
.component-program-title
font-size: inherit
line-height: inherit
.attribute,
.name,
.bp5-icon
margin-right: 2px
.bp5-alert-body &
margin-right: 2px
.name
font-weight: 600
font-feature-settings: "palt" 1
.attribute
border-radius: 1px
padding: 0 1px
font-size: 80%
font-weight: 500
vertical-align: 1px
background: colors.$gray2
color: #fff
.bp5-icon
vertical-align: -10%
&-tick
color: colors.$green4
&-flag
color: colors.$red4
&-record
color: colors.$vermilion4
&-small-cross
color: colors.$gray3

View File

@@ -0,0 +1,101 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useMemo } from "react";
import * as regexp from "../modules/regexp";
import { ProgramAttributeMap } from "../modules/constants";;
import { Program } from "../../../api.d";
import "./ProgramTitle.sass";
type ProgramTitleProps = {
program: Program;
};
export const ProgramTitle: React.FC<ProgramTitleProps> = ({ program }) => {
// console.debug("components", "ProgramTitle");
let name = program.name;
if (!name) {
if (program.relatedItems) {
const isShared = program.relatedItems.some(item => item.type === "shared");
if (isShared) {
name = "(イベント共有)";
} else {
name = "(不明)";
}
} else {
name = "(未定)";
}
}
name = name.replace(regexp.squaredUnicode, "").replace(regexp.legacyAttributeFormat, "");
const attributes = useMemo(() => {
const attrSet = new Set<keyof typeof ProgramAttributeMap>();
const attributeSource = (program.name || "") + (program.description || "");
if (attributeSource) {
const items = [
...attributeSource.match(regexp.enclosedAttributeUnicode) || [],
...attributeSource.match(regexp.legacyAttributeFormat) || [],
];
if (program.networkId >= 0x01 && program.networkId <= 0x0C && program.isFree) {
items.push("無");
}
for (const item of items) {
const attrKey = item.replace(/[\[\]()]/g, "").normalize("NFKC");
if (ProgramAttributeMap[attrKey]) {
attrSet.add(attrKey as any);
}
}
}
return [...attrSet];
}, [program.name, program.description]);
const labels = useMemo(() => {
const pre: JSX.Element[] = [];
const post: JSX.Element[] = [];
for (const attribute of attributes) {
/* if (attribute === "無") {
// 公共放送と無料放送の [無] は省略
continue;
} */
const label = (
<span key={`attribute-${attribute}`}
className={`attribute bg-attribute-${attribute}`}
title={ProgramAttributeMap[attribute]}>
{attribute}
</span>
);
if (["新", "再", "終", "生"].includes(attribute)) {
pre.push(label);
} else {
post.push(label);
}
}
return { pre, post };
}, [attributes]);
return (
<span className="component-program-title">
{labels.pre}
<span className="name" title={program.name}>{name}</span>
{labels.post}
</span>
);
};

View File

@@ -0,0 +1,50 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { Button, Dialog, DialogBody, DialogFooter } from "@blueprintjs/core";
export const Restart: React.FC<{
isOpen: boolean;
onClose: () => void;
}> = ({ isOpen, onClose }) => {
const handleRestart = async () => {
await fetch("/api/restart", { method: "PUT" });
onClose();
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title="Restart Mirakurun"
canEscapeKeyClose
>
<DialogBody>
<div>
Do you want to restart Mirakurun?
</div>
</DialogBody>
<DialogFooter
actions={
<>
<Button text="Cancel" onClick={onClose} />
<Button text="Restart" intent="danger" onClick={handleRestart} />
</>
}
/>
</Dialog>
);
};

View File

@@ -0,0 +1,8 @@
.component-service-link
display: flex
gap: 10px
align-items: center
> img
max-height: 18px
border-radius: 1px

View File

@@ -0,0 +1,73 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { channelTypeMap } from "../modules/constants";
import { Service } from "../../../api.d";
import { state } from "../modules/state";
import "./ServiceLink.sass";
type ServiceLinkProps = {
globalId: number;
date?: string;
time?: number;
} & React.HTMLAttributes<HTMLDivElement>;
export const ServiceLink: React.FC<ServiceLinkProps> = ({ globalId, date, time, ...props }) => {
console.debug("components", "ServiceLink");
const [service, setService] = useState<Service>(null);
useEffect(() => {
(async () => {
const _service = state.services.find(s => s.id === globalId);
setService(_service);
})();
return () => {
setService(null);
};
}, [globalId]);
let to = "#";
let className = "component-service-link";
if (props.className) {
className += ` ${props.className}`;
}
if (service) {
to = `/epg/services/${service.id}`;
if (date && time) {
to += `?date=${date}&time=${time}`
} else if (time) {
to += `?time=${time}`
} else if (date) {
to += `?date=${date}`
}
}
return (
<div className={className} {...props}>
{service && service.hasLogoData && <img src={`/api/services/${service.id}/logo`} />}
<Link className={service ? null : "bp5-skeleton"} title="EPG 番組表 (週間)" to={to}>
{service ? `${service.name.normalize("NFKC")} (${channelTypeMap[service.channel.type]})` : "サービス名..."}
</Link>
</div>
);
};

View File

@@ -0,0 +1,127 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useEffect, useState } from "react";
import { MenuItem } from "@blueprintjs/core";
import * as semver from "semver";
import { state } from "../modules/state";
interface VersionInfo {
current: string;
latest: string;
}
let cachedVersion: VersionInfo | null = null;
let isFetching = false;
const fetchListeners: Array<(version: VersionInfo | null) => void> = [];
const fetchVersion = async (): Promise<VersionInfo | null> => {
if (cachedVersion) {
return cachedVersion;
}
if (isFetching) {
return new Promise((resolve) => {
fetchListeners.push(resolve);
});
}
isFetching = true;
try {
const res = await fetch("/api/version");
if (res.ok) {
const data: VersionInfo = await res.json();
cachedVersion = data;
const listeners = [...fetchListeners];
fetchListeners.length = 0;
listeners.forEach((resolve) => resolve(data));
return data;
}
} catch (e) {
console.error("Failed to fetch version", e);
}
isFetching = false;
const listeners = [...fetchListeners];
fetchListeners.length = 0;
listeners.forEach((resolve) => resolve(null));
return null;
};
export const VersionStatus: React.FC<{
asMenuItem?: boolean;
}> = ({ asMenuItem = false }) => {
const [version, setVersion] = useState<VersionInfo | null>(cachedVersion);
const [loading, setLoading] = useState<boolean>(!cachedVersion);
useEffect(() => {
let isMounted = true;
if (!cachedVersion) {
fetchVersion().then((data) => {
if (isMounted) {
setVersion(data);
setLoading(false);
}
});
} else {
setLoading(false);
}
return () => {
isMounted = false;
};
}, []);
const hasUpdate = version
&& semver.valid(version.current)
&& semver.valid(version.latest)
&& semver.gt(version.latest, version.current);
if (asMenuItem) {
if (loading) {
return <MenuItem icon="updated" text="アップデートを確認中..." disabled />;
}
if (!version) {
return <MenuItem icon="updated" text="最新版を実行中です" disabled />;
}
if (hasUpdate) {
return (
<MenuItem
icon="updated"
intent="primary"
text={`最新版 (v${version.latest}) が利用可能です`}
onClick={() => {
state.navigate("/about");
}}
/>
);
}
return <MenuItem icon="updated" text="最新版を実行中です" disabled />;
}
if (loading) {
return <span>...</span>;
}
if (!version) {
return <span> ()</span>;
}
if (hasUpdate) {
return (
<span style={{ color: "#2d72d9", fontWeight: "bold" }}>
{version.latest} ()
</span>
);
}
return <span>{version.latest} ()</span>;
};

View File

@@ -0,0 +1,126 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Button, ButtonGroup, ButtonProps, Menu, MenuItem, Popover, PopoverTargetProps, Placement } from "@blueprintjs/core";
import { copyToClipboard } from "../modules/common";
import { state } from "../modules/state";
type WatchButtonProps = {
globalServiceId: number;
popoverPlacement?: Placement;
} & ButtonProps & React.HTMLAttributes<HTMLButtonElement>;
export const WatchButton: React.FC<WatchButtonProps> = ({ globalServiceId, popoverPlacement, ...props }) => {
console.debug("components", "WatchButton");
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
if (state.serverConfig) {
setLoading(false);
return;
}
await state.fetchServerConfig();
if (state.serverConfig) {
setLoading(false);
}
})();
}, []);
const tsplayDisabled = !state.serverConfig?.tsplayEndpoint || !state.serverConfig?.allowPNA;
const streamEndpoint = `${location.protocol}//${location.host}/api/services/${globalServiceId}/stream`;
return (<>
<ButtonGroup className={loading ? "bp5-skeleton" : ""}>
<Button {...props}
text="視聴テスト"
icon="play"
endIcon="lab-test"
disabled={tsplayDisabled}
onPointerUp={e => {
if (tsplayDisabled) {
return;
}
// e.preventDefault();
// e.stopPropagation();
// マウス中クリックか ctrl 押しながら左クリックか判定
const isMiddleButton = e.button === 1 || (e.button === 0 && e.ctrlKey);
let features = "noreferrer";
if (isMiddleButton) {
// features += "";
} else {
const width = 1280;
const height = 770;
// winPosX, winPosY は現在のブラウザウィンドウの画面上の真ん中に設定
const top = window.screenTop + (window.innerHeight / 2) - (height / 2);
const left = window.screenLeft + (window.innerWidth / 2) - (width / 2);
features += `,popup,width=${width},height=${height},top=${top},left=${left},resizable=yes`;
}
const openUrl = `${state.serverConfig.tsplayEndpoint}#${streamEndpoint}`;
window.open(openUrl, `_blank`, features);
}}
/>
<Popover
hasBackdrop={true}
onClose={e => {
e.preventDefault();
e.stopPropagation();
}}
captureDismiss={true}
placement={popoverPlacement}
positioningStrategy="absolute"
content={
<Menu>
<MenuItem icon="clipboard" text="URL をクリップボードにコピー" onClick={() => {
copyToClipboard(streamEndpoint);
}} />
<MenuItem icon="desktop" text="M3U プレイリスト..." onClick={() => {
const m3u8Content = (
`#EXTM3U\n` +
`#EXTINF:-1,\n` +
`${streamEndpoint}\n`
);
const blob = new Blob([m3u8Content], { type: "application/x-mpegURL" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Mirakurun_service_${globalServiceId}.m3u8`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}} />
</Menu>
}
renderTarget={({ isOpen, ref, ...targetProps }: PopoverTargetProps) => (
<Button {...{...props, text: ""}} {...targetProps} active={isOpen} ref={ref} icon="more" title="再生方法..." />
)}
/>
</ButtonGroup>
</>);
};

4
web/src/custom.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}

View File

@@ -0,0 +1,97 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useCallback } from "react";
import equal from "fast-deep-equal";
export function useLocalStorageState<T>(key: string, initState: T): [T, (newState: T) => void] {
key = "mirakurun:state:" + key;
const [stored, setStored] = useState<T>();
if (stored === undefined) {
const storedState = localStorage.getItem(key);
if (storedState !== null) {
initState = JSON.parse(storedState) as T;
}
setStored(initState);
// debug
console.debug("hooks", "useLocalStorageState()", "get", key, initState);
}
const [state, setState] = useState(initState);
const setStorageState = useCallback((newState: T) => {
if (equal(newState, state)) {
return;
}
if (newState === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(newState));
}
setState(newState);
setStored(newState);
// debug
console.debug("hooks", "useLocalStorageState()", "set", key, newState);
}, [state]);
return [state, setStorageState];
}
export function useSessionStorageState<T>(key: string, initState: T): [T, (newState: T) => void] {
key = "mirakurun:state:" + key;
const [stored, setStored] = useState<T>();
if (stored === undefined) {
const storedState = sessionStorage.getItem(key);
if (storedState !== null) {
initState = JSON.parse(storedState) as T;
}
setStored(initState);
// debug
console.debug("hooks", "useSessionStorageState()", "get", key, initState);
}
const [state, setState] = useState(initState);
const setStorageState = useCallback((newState: T) => {
if (equal(newState, state)) {
return;
}
if (newState === undefined) {
sessionStorage.removeItem(key);
} else {
sessionStorage.setItem(key, JSON.stringify(newState));
}
setState(newState);
setStored(newState);
// debug
console.debug("hooks", "useSessionStorageState()", "set", key, newState);
}, [state]);
return [state, setStorageState];
}

50
web/src/icon-active.svg Normal file
View File

@@ -0,0 +1,50 @@
<!--
Copyright 2020 kanreisa
CC BY-SA 4.0
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
https://creativecommons.org/licenses/by-sa/4.0/
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<defs>
<style>
.a {
fill: none;
}
.b {
fill: #ffe18a;
}
.c {
fill: #fff;
}
.d {
clip-path: url(#a);
}
.e {
fill: #ffd56c;
}
.f {
fill: #ce3851;
}
</style>
<clipPath id="a">
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
</clipPath>
</defs>
<g>
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
<g class="d">
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
</g>
</g>
<circle class="f" cx="13.87538" cy="2.54867" r="1.81129"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

45
web/src/icon-gray.svg Normal file
View File

@@ -0,0 +1,45 @@
<!--
Copyright 2020 kanreisa
CC BY-SA 4.0
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
https://creativecommons.org/licenses/by-sa/4.0/
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<defs>
<style>
.a {
fill: none;
}
.b {
fill: #e0e0e0;
}
.c {
fill: #fff;
}
.d {
clip-path: url(#a);
}
.e {
fill: #d6d6d6;
}
</style>
<clipPath id="a">
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
</clipPath>
</defs>
<g>
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
<g class="d">
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

45
web/src/icon.svg Normal file
View File

@@ -0,0 +1,45 @@
<!--
Copyright 2020 kanreisa
CC BY-SA 4.0
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
https://creativecommons.org/licenses/by-sa/4.0/
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<defs>
<style>
.a {
fill: none;
}
.b {
fill: #ffe18a;
}
.c {
fill: #fff;
}
.d {
clip-path: url(#a);
}
.e {
fill: #ffd56c;
}
</style>
<clipPath id="a">
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
</clipPath>
</defs>
<g>
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
<g class="d">
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

15
web/src/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<title>...</title>
<link id="icon" rel="icon" type="image/svg+xml" href="/icon.svg">
<script defer src="/vendors.bundle.js"></script>
<script defer src="/index.bundle.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

217
web/src/index.sass Normal file
View File

@@ -0,0 +1,217 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "./vars"
.bp5-spinner
animation: 0.2s ease 0.3s 1 normal forwards running fade-in
.bp5-navbar
display: flex
.bp5-navbar-group
&.bp5-align-left
flex: 1 1 0
&,
.bp5-navbar-heading
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.bp5-non-ideal-state
position: absolute
z-index: 2
top: 0
left: 0
right: 0
bottom: 0
width: auto
height: auto
overflow: auto
background: colors.$light-gray4
.bp5-dark &
background: colors.$dark-gray4
body
font-family: vars.$font-base
overflow: hidden
&.indiana-dragging
cursor: default
.bp5-button > .bp5-button-text,
.bp5-menu-item
font-family: vars.$font-ui
select,
button,
.bp5-tabs
user-select: none
button:not(.bp5-button)
border: none
background: inherit
&:not(:disabled)
cursor: pointer
a[target="_blank"]::after
font-family: "blueprint-icons-16"
content: ""
margin-left: 4px
font-size: 10px
.font-bolder
font-weight: bolder !important
.color-warning
color: colors.$orange3 !important
.color-danger
color: colors.$red3 !important
.color-dow-6
color: colors.$blue3 !important
.color-dow-7
color: colors.$red3 !important
.bg-genre-lv1-0
background: rgb(255,255,224) !important
.bg-genre-lv1-1
background: rgb(224,224,255) !important
.bg-genre-lv1-2
background: rgb(255,224,240) !important
.bg-genre-lv1-3
background: rgb(255,224,224) !important
.bg-genre-lv1-4
background: rgb(224,255,224) !important
.bg-genre-lv1-5
background: rgb(224,255,255) !important
.bg-genre-lv1-6
background: rgb(255,240,224) !important
.bg-genre-lv1-7
background: rgb(255,224,255) !important
.bg-genre-lv1-8
background: rgb(255,255,224) !important
.bg-genre-lv1-9
background: rgb(255,240,224) !important
.bg-genre-lv1-10
background: rgb(224,240,255) !important
.bg-genre-lv1-11
background: rgb(224,240,255) !important
.bg-genre-lv1-15
background: rgb(240,240,240) !important
.bg-attribute-新
background: colors.$forest3 !important
.bg-attribute-再
background: colors.$cerulean3 !important
.bg-attribute-終
background: colors.$vermilion3 !important
.bg-attribute-生
background: colors.$rose4 !important
.bg-attribute-多
background: colors.$rose3 !important
.bg-attribute-解
background: colors.$sepia4 !important
.bg-attribute-初
background: colors.$lime3 !important
.bg-attribute-手
background: colors.$sepia3 !important
.bg-attribute-字
background: colors.$gray3 !important
.bg-attribute-デ,
.bg-attribute-双
background: colors.$violet3 !important
.bg-attribute-二
background: colors.$gold3 !important
.bg-attribute-無
background: colors.$turquoise3 !important
#root
display: flex
flex-direction: column
width: 100vw
height: 100vh
overflow: hidden
#dev-header
position: absolute
top: 0
left: 5px
font-size: 10px
z-index: 9999
opacity: 0.5
#main
flex-direction: column
flex-grow: 1
// overflow-y: auto
position: relative
#page
position: absolute
top: 0
right: 0
bottom: 0
left: 0
background: colors.$light-gray4
.bp5-dark &
background: colors.$dark-gray4
> .route
position: absolute
top: 0
right: 0
bottom: 0
left: 0
display: flex
flex-direction: column
flex-grow: 1
> .toolbar.bp5-navbar
z-index: 9
background: none
box-shadow: none
.bp5-navbar-heading
font-weight: 400
font-size: 18px
font-feature-settings: "palt" 1
.component-program-title
.name
font-weight: inherit
.attribute
font-size: 12px
vertical-align: 14%
.bp5-breadcrumb
font-weight: inherit
font-size: inherit
> .bp5-navbar-group
&.bp5-align-right
column-gap: 10px
.bp5-tab-list
column-gap: 20px
> .bp5-tab
&[aria-selected="true"]
cursor: default
sup[class*="color-dow-"]
font-weight: 600
margin-left: 2px
> .content
flex-grow: 1
overflow: auto
position: relative
margin: 0
padding: 10px 25px
&.no-margin
padding: 0

101
web/src/index.tsx Normal file
View File

@@ -0,0 +1,101 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom";
import { FocusStyleManager } from "@blueprintjs/core";
FocusStyleManager.onlyShowFocusOnTabs();
import { DateTime, Settings as LuxonSettings } from "luxon";
LuxonSettings.defaultZone = "Asia/Tokyo";
LuxonSettings.defaultLocale = "ja";
import { state } from "./modules/state";
import * as ui from "./modules/ui";
import * as at from "./modules/at";
at.init(5000);
import { Nav } from "./components/Nav";
import { EPGView } from "./routes/EPGView";
import { ProgramView } from "./routes/ProgramView";
import { SearchView } from "./routes/SearchView";
import { JobsView } from "./routes/JobsView";
import { LogsView } from "./routes/LogsView";
import { ServerConfigView } from "./routes/ServerConfigView";
import { TunersConfigView } from "./routes/TunersConfigView";
import { ChannelsConfigView } from "./routes/ChannelsConfigView";
import { HomeView } from "./routes/HomeView";
import { AboutView } from "./routes/AboutView";
import "normalize.css";
import "@blueprintjs/core/lib/css/blueprint.css";
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
import "./index.sass";
const Index: React.FC = () => {
console.debug("Index");
const navigate = state.navigate = useNavigate();
const location = state.location = useLocation();
const searchParams = state.searchParams = new URLSearchParams(location.search);
const pathname = state.pathname = location.pathname;
const pathLv1 = pathname.split("/")[1];
useEffect(() => {
ui.blur();
}, [location.pathname]);
return <>
{state.isDev && /* 開発用 */ <>
<div id="dev-header">
[dev] {JSON.stringify({
pathname,
pathLv1,
searchParams: searchParams.toString()
})}
</div>
</>}
<Nav pathLv1={pathLv1} />
<div id="main">
<div id="page">
<Routes>
<Route path="/*" element={<div>not found</div>} />
<Route path="/" element={<HomeView />} />
<Route path="epg" element={<EPGView />} />
<Route path="epg/services/:globalServiceId" element={<EPGView />} />
<Route path="epg/programs/:programId" element={<ProgramView />} />
<Route path="epg/search" element={<SearchView />} />
<Route path="jobs" element={<JobsView />} />
<Route path="logs" element={<LogsView />} />
<Route path="config/server" element={<ServerConfigView />} />
<Route path="config/tuners" element={<TunersConfigView />} />
<Route path="config/channels" element={<ChannelsConfigView />} />
<Route path="about" element={<AboutView />} />
</Routes>
</div>
</div>
</>;
};
{
const basename = state.isDev ? "/dev/" : "";
const root = createRoot(document.getElementById("root"));
root.render(<BrowserRouter basename={basename}><Index /></BrowserRouter>);
}

63
web/src/modules/at.ts Normal file
View File

@@ -0,0 +1,63 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type ScheduleId = string;
export type ScheduleTask = () => void;
const map: Record<ScheduleId, [number, ScheduleTask]> = {};
let count = 0;
let intervalId: ReturnType<typeof setInterval>;
export function setSchedule(time: number, task: ScheduleTask): ScheduleId {
const id = (++count).toString(10);
map[id] = [time, task];
console.debug("at", "setSchedule()", id, map[id]);
return id;
}
export function clearSchedule(id: ScheduleId): void {
console.debug("at", "clearSchedule()", id, map[id]);
delete map[id];
}
export function init(interval = 1000) {
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(() => run(), interval);
}
export function deinit() {
if (intervalId) {
clearInterval(intervalId);
}
}
function run() {
const now = Date.now();
for (const id in map) {
const [time, task] = map[id];
if (time <= now) {
console.debug("at", "run()", id, map[id]);
delete map[id];
task();
}
}
}

119
web/src/modules/common.ts Normal file
View File

@@ -0,0 +1,119 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as regexp from "./regexp";
export async function sleep(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
export function inRange<T = number>(value: T, min: T, max: T): boolean {
return value >= min && value <= max;
}
export function getGlobalServiceId(networkId: number, serviceId: number): number {
return parseInt(networkId + (serviceId / 100000).toFixed(5).slice(2), 10);
}
export function getIdWithHex(id: number): string {
return `0x${id.toString(16).toUpperCase()} (${id})`;
}
export function textMatch(text: string, queryNormalized: string): boolean {
if (normalizeText(text).includes(queryNormalized)) {
return true;
}
return false;
}
export function normalizeText(text: string): string {
return katakanaToHiragana(squaredUnicodeToBrackets(text).normalize("NFKC")).toLowerCase();
}
export function squaredUnicodeToBrackets(text: string): string {
return text.replace(regexp.squaredUnicode, (s) => {
return `[${s.normalize("NFKC")}]`;
});
}
export function katakanaToHiragana(text: string): string {
return text.replace(regexp.katakana, (s) => {
const code = s.charCodeAt(0) - 0x60;
return String.fromCharCode(code);
});
}
export class LazyCaller<T extends Function> {
caller: T;
private _delayTimeout: NodeJS.Timeout;
private _activate: null | any[] = null; // args
private _running = false;
/**
* 遅延時間が経過するまでコールされなかった時に指定関数を実行
* @param msDelay 最低遅延時間
* @param msSleep 実行後待機時間
* @param fn 実行関数 (Promise の場合は重複を避けて遅延実行する)
*/
constructor(public msDelay: number, public msSleep: number, public fn: T) {
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const _lazy = this;
this.caller = function lazyCaller(this: never, ...args: any[]) {
if (_lazy._running) {
_lazy._activate = args || [];
return;
}
clearTimeout(_lazy._delayTimeout);
_lazy._delayTimeout = setTimeout(async () => {
_lazy._activate = null;
_lazy._running = true;
if (_lazy.fn) {
await Reflect.apply(_lazy.fn, this, args);
}
await sleep(_lazy.msSleep);
_lazy._running = false;
if (_lazy._activate && _lazy.caller) {
setTimeout(_lazy.caller.apply(this, _lazy._activate), 0);
}
}, _lazy.msDelay);
} as any as T;
}
destroy() {
clearTimeout(this._delayTimeout);
this._activate = null;
delete this.fn;
delete this.caller;
}
}
export function copyToClipboard(text: string) {
// secure context ではないため execCommand を使用する
const input = document.createElement("input");
input.setAttribute("readonly", "readonly");
input.setAttribute("value", text);
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}

View File

@@ -0,0 +1,292 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IconName } from "@blueprintjs/core";
export const channelTypeMap = {
GR: "地上",
BS: "BS",
CS: "CS",
SKY: "SKY",
};
export const relatedItemTypeMap = {
shared: "イベント共有",
relay: "イベントリレー",
movement: "イベント移動"
};
export const relatedItemTypeIconMap: Record<string, IconName> = {
shared: "duplicate",
relay: "one-to-one",
movement: "flow-linear"
};
export const langMap = {
jpn: "日本語",
eng: "英語",
deu: "ドイツ語",
fra: "フランス語",
ita: "イタリア語",
rus: "ロシア語",
zho: "中国語",
kor: "韓国語",
spa: "スペイン語",
etc: "その他",
};
export const audioModeMap = {
"00001": "モノラル",
"00010": "デュアルモノ",
"00011": "ステレオ",
"00100": "2/1モード",
"00101": "3/0モード",
"00110": "2/2モード",
"00111": "3/1モード",
"01000": "3/2モード",
"01001": "5.1ch",
"01010": "3/3.1モード",
"01011": "2/0/0-2/0/2-0.1モード",
"01100": "5/2.1モード",
"01101": "3/2/2.1モード",
"01110": "2/0/0-3/0/2-0.1モード",
"01111": "0/2/0-3/0/2-0.1モード",
"10000": "2/0/0-3/2/3-0.2モード",
"10001": "3/3/3-5/2/3-3/0/0.2モード",
};
export const ProgramAttributeMap = {
: "字幕放送", // 字幕放送
: "新番組", // 新番組
: "初回放送", // 初回放送
: "最終回", // 最終回
: "再放送", // 再放送
: "番組連動データ放送", // 番組連動データ放送
: "双方向放送", // 双方向放送
: "無料放送", // 無料放送
: "二ヶ国語放送", // 二ヶ国語放送
: "音声多重放送", // 音声多重放送
SS: "サラウンドステレオ", // サラウンドステレオ
: "生放送", // 生放送
: "前編", // 前編
: "後編", // 後編
: "音声解説", // 音声解説
PPV: "PPV", // PPV
: "手話通訳放送", // 手話通訳放送
};
export const Genre1Map = {
0x0: "ニュース/報道",
0x1: "スポーツ",
0x2: "情報/ワイドショー",
0x3: "ドラマ",
0x4: "音楽",
0x5: "バラエティ",
0x6: "映画",
0x7: "アニメ/特撮",
0x8: "ドキュメンタリー/教養",
0x9: "劇場/公演",
0xA: "趣味/教育",
0xB: "福祉",
0xC: "予備",
0xD: "予備",
0xE: "拡張",
0xF: "その他",
};
export const Genre2Map = {
0x00: "定時・総合",
0x01: "天気",
0x02: "特集・ドキュメント",
0x03: "政治・国会",
0x04: "経済・市況",
0x05: "海外・国際",
0x06: "解説",
0x07: "討論・会談",
0x08: "報道特番",
0x09: "ローカル・地域",
0x0A: "交通",
0x0F: "その他",
0x10: "スポーツニュース",
0x11: "野球",
0x12: "サッカー",
0x13: "ゴルフ",
0x14: "その他の球技",
0x15: "相撲・格闘技",
0x16: "オリンピック・国際大会",
0x17: "マラソン・陸上・水泳",
0x18: "モータースポーツ",
0x19: "マリン・ウィンタースポーツ",
0x1A: "競馬・公営競技",
0x1F: "その他",
0x20: "芸能・ワイドショー",
0x21: "ファッション",
0x22: "暮らし・住まい",
0x23: "健康・医療",
0x24: "ショッピング・通販",
0x25: "グルメ・料理",
0x26: "イベント",
0x27: "番組紹介・お知らせ",
0x2F: "その他",
0x30: "国内ドラマ",
0x31: "海外ドラマ",
0x32: "時代劇",
0x3F: "その他",
0x40: "国内ロック・ポップス",
0x41: "海外ロック・ポップス",
0x42: "クラシック・オペラ",
0x43: "ジャズ・フュージョン",
0x44: "歌謡曲・演歌",
0x45: "ライブ・コンサート",
0x46: "ランキング・リクエスト",
0x47: "カラオケ・のど自慢",
0x48: "民謡・邦楽",
0x49: "童謡・キッズ",
0x4A: "民族音楽・ワールドミュージック",
0x4F: "その他",
0x50: "クイズ",
0x51: "ゲーム",
0x52: "トークバラエティ",
0x53: "お笑い・コメディ",
0x54: "音楽バラエティ",
0x55: "旅バラエティ",
0x56: "料理バラエティ",
0x5F: "その他",
0x60: "洋画",
0x61: "邦画",
0x62: "アニメ",
0x6F: "その他",
0x70: "国内アニメ",
0x71: "海外アニメ",
0x72: "特撮",
0x7F: "その他",
0x80: "社会・時事",
0x81: "歴史・紀行",
0x82: "自然・動物・環境",
0x83: "宇宙・科学・医学",
0x84: "カルチャー・伝統文化",
0x85: "文学・文芸",
0x86: "スポーツ",
0x87: "ドキュメンタリー全般",
0x88: "インタビュー・討論",
0x8F: "その他",
0x90: "現代劇・新劇",
0x91: "ミュージカル",
0x92: "ダンス・バレエ",
0x93: "落語・演芸",
0x94: "歌舞伎・古典",
0x9F: "その他",
0xA0: "旅・釣り・アウトドア",
0xA1: "園芸・ペット・手芸",
0xA2: "音楽・美術・工芸",
0xA3: "囲碁・将棋",
0xA4: "麻雀・パチンコ",
0xA5: "車・オートバイ",
0xA6: "コンピュータ・TVゲーム",
0xA7: "会話・語学",
0xA8: "幼児・小学生",
0xA9: "中学生・高校生",
0xAA: "大学生・受験",
0xAB: "生涯教育・資格",
0xAC: "教育問題",
0xAF: "その他",
0xB0: "高齢者",
0xB1: "障害者",
0xB2: "社会福祉",
0xB3: "ボランティア",
0xB4: "手話",
0xB5: "文字(字幕)",
0xB6: "音声解説",
0xBF: "その他",
0xC0: "予備",
0xD0: "予備",
0xE0: "BS/地上デジタル放送用番組付属情報",
0xE1: "広帯域CSデジタル放送用拡張",
0xE2: "衛星デジタル音声放送用拡張",
0xE3: "サーバー型番組付属情報",
0xE4: "IP放送用番組付属情報",
0xF0: "その他",
0xFF: "その他",
};
export const GenreUN1Map = {
0xE10: "スポーツ",
0xE11: "洋画",
0xE12: "邦画",
};
export const GenreUN2Map = {
0xE000: "中止の可能性あり",
0xE001: "延長の可能性あり",
0xE002: "中断の可能性あり",
0xE003: "同一シリーズの別話数放送の可能性あり",
0xE004: "編成未定枠",
0xE005: "繰り上げの可能性あり",
0xE010: "中断ニュースあり",
0xE011: "当該イベントに関連する臨時サービスあり",
0xE020: "当該イベント中に3D映像あり",
0xE100: "テニス",
0xE101: "バスケットボール",
0xE102: "ラグビー",
0xE103: "アメリカンフットボール",
0xE104: "ボクシング",
0xE105: "プロレス",
0xE10F: "その他",
0xE110: "アクション",
0xE111: "SF/ファンタジー",
0xE112: "コメディー",
0xE113: "サスペンス/ミステリー",
0xE114: "恋愛/ロマンス",
0xE115: "ホラー/スリラー",
0xE116: "ウエスタン",
0xE117: "ドラマ/社会派ドラマ",
0xE118: "アニメーション",
0xE119: "ドキュメンタリー",
0xE11A: "アドベンチャー/冒険",
0xE11B: "ミュージカル/音楽映画",
0xE11C: "ホームドラマ",
0xE11F: "その他",
0xE120: "アクション",
0xE121: "SF/ファンタジー",
0xE122: "お笑い/コメディー",
0xE123: "サスペンス/ミステリー",
0xE124: "恋愛/ロマンス",
0xE125: "ホラー/スリラー",
0xE126: "青春/学園/アイドル",
0xE127: "任侠/時代劇",
0xE128: "アニメーション",
0xE129: "ドキュメンタリー",
0xE12A: "アドベンチャー/冒険",
0xE12B: "ミュージカル/音楽映画",
0xE12C: "ホームドラマ",
0xE12F: "その他",
};

35
web/src/modules/regexp.ts Normal file
View File

@@ -0,0 +1,35 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** カタカナ */
export const katakana = /[\u30A1-\u30F6]/ug;
/** Unicode 囲み文字 (四角形) */
export const squaredUnicode = /[\u{1F130}-\u{1F14E}\u{1F201}-\u{1F23B}]/ug;
/** Unicode 囲み文字 (属性用) */
export const enclosedAttributeUnicode = /[\u{1F14D}-\u{1F14E}\u{1F210}-\u{1F222}]/ug;
/** レガシー属性フォーマット */
export const legacyAttributeFormat = /(?:[\[][新生無][\]]|\([二字]\)|[\[【]無料[\]】])/g;
/** EPG オートリンク用 */
export const epgHTTPLinkFormat = /(?:(https?:\/\/[\x21-\x7e]+)|(?[\uFF01-\uFF5E]+)|(www\.[\x21-\x7e]+))/gi;
/** EPG オートリンク用 */
export const epgXLinkFormat = /(?:Twitter||X|)[\s\S]{0,14}([@][\da-z_]+)/gi
/** EPG オートリンク用 */
export const epgInstagramLinkFormat = /(?:Instagram||インスタグラム)[\s\S]{0,14}([@][\da-z_]+)/gi

427
web/src/modules/state.ts Normal file
View File

@@ -0,0 +1,427 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventEmitter } from "eventemitter3";
import { DateTime } from "luxon";
import { useNavigate, useLocation } from "react-router-dom";
import { Client as RPCClient } from "jsonrpc2-ws";
import { setSchedule } from "./at";
import * as ui from "./ui";
import { Event, Service, Program, TunerDevice, Status, JobItem, JobScheduleItem, ConfigServer } from "../../../api.d";
import { JoinParams, NotifyParams } from "../../types/rpc";
type StatusIconKey = "normal" | "offline" | "active";
type StateEventTypes = {
"todayTime": [number];
"statusName": [string];
"statusIconKey": [StatusIconKey];
"version": [string];
"status": [Status];
"services": [Service[]];
"tuners": [TunerDevice[]];
"jobs": [JobItem[]];
"jobSchedules": [JobScheduleItem[]];
"programs": [Program[]];
"logs": [string[], boolean];
};
import normalIcon from "../icon.svg";
import offlineIcon from "../icon-gray.svg";
import activeIcon from "../icon-active.svg";
const iconSrcMap = {
normal: normalIcon,
offline: offlineIcon,
active: activeIcon
};
const jobStatusOrderMap = {
queued: 0,
standby: 1,
running: 2,
finished: 3
};
class State extends EventEmitter<StateEventTypes> {
isDev: boolean = /^\/dev\/.*$/.test(location.pathname);
navigate?: ReturnType<typeof useNavigate>;
location?: ReturnType<typeof useLocation>;
pathname?: string;
searchParams?: URLSearchParams;
todayTime?: number;
version = "..";
statusName = "Loading";
statusIconKey: StatusIconKey = "offline";
status?: Status;
services: Service[] = [];
tuners: TunerDevice[] = [];
jobs: JobItem[] = [];
jobSchedules: JobScheduleItem[] = [];
programs: Program[] = [];
serverConfig?: ConfigServer;
private _rpc?: RPCClient;
constructor() {
super();
if (!this.isDev) {
const emptyFunction = function () {};
if (console?.debug) {
console.debug = emptyFunction;
}
if (console?.log) {
console.log = emptyFunction;
}
}
this._setTodayTime();
this._initRPC();
this.on("tuners", () => this._updateIdleStatus());
this.on("statusIconKey", key => ui.setFavicon(iconSrcMap[key]));
}
get statusIconSrc() {
return iconSrcMap[this.statusIconKey];
}
async fetchStatus(): Promise<Status> {
this.status = await this._rpc.call("getStatus");
this.emit("status", this.status);
// version
if (this.version !== ".." && this.version !== this.status.version) {
location.reload();
return;
}
this.version = this.status.version;
this.emit("version", this.version);
return this.status;
}
async fetchServices(): Promise<Service[]> {
this.services = await this._rpc.call("getServices");
if (this.services.length > 0) {
this.emit("services", this.services);
}
return this.services;
}
async fetchTuners(): Promise<TunerDevice[]> {
this.tuners.splice(0, this.tuners.length, ...await this._rpc.call("getTuners"));
if (this.tuners.length > 0) {
this.emit("tuners", this.tuners);
}
return this.tuners;
}
async fetchJobs(): Promise<JobItem[]> {
this.jobs.splice(0, this.jobs.length, ...await this._rpc.call("getJobs"));
if (this.jobs.length > 0) {
this._handleJobs();
this.emit("jobs", this.jobs);
}
return this.jobs;
}
async fetchJobSchedules(): Promise<JobScheduleItem[]> {
this.jobSchedules = await this._rpc.call("getJobSchedules");
if (this.jobSchedules.length > 0) {
this.emit("jobSchedules", this.jobSchedules);
}
return this.jobSchedules;
}
async fetchPrograms(): Promise<Program[]> {
this.programs = await (await fetch("/api/programs")).json();
if (this.programs.length > 0) {
this.emit("programs", this.programs);
}
return this.programs;
}
private _joinProgramEvents: () => void;
async subscribePrograms(forceEmit = false): Promise<void> {
if (this._joinProgramEvents) {
if (forceEmit) {
this.emit("programs", this.programs);
}
return;
}
this._joinProgramEvents = () => {
this.fetchPrograms();
this._rpc.call("join", {
rooms: ["events:program"],
} as JoinParams);
};
this._rpc.on("connected", this._joinProgramEvents);
if (this._rpc.isConnected()) {
this._joinProgramEvents();
}
}
async unsubscribePrograms(): Promise<void> {
if (this._rpc.isConnected()) {
this._rpc.call("leave", {
rooms: ["events:program"],
} as JoinParams);
}
if (this._joinProgramEvents) {
this._rpc.off("connected", this._joinProgramEvents);
this._joinProgramEvents = undefined;
}
}
async fetchServerConfig(): Promise<ConfigServer> {
this.serverConfig = await (await fetch("/api/config/server")).json();
return this.serverConfig;
}
private _setTodayTime() {
console.debug("state", "setTodayTime()");
const init = !this.todayTime;
this.todayTime = DateTime.now().startOf("day").toMillis();
if (!init) {
this.emit("todayTime", this.todayTime);
}
// update daily
setSchedule(this.todayTime + 86400000, () => this._setTodayTime());
}
private _initRPC() {
const rpc = this._rpc = new RPCClient(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/rpc`, {
protocols: null,
bufferSendingMessages: false
});
rpc.on("connecting", () => {
console.debug("rpc:connecting");
this.statusName = "Connecting";
this.statusIconKey = "offline";
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
});
let _statusRefreshInterval: NodeJS.Timeout | undefined;
let _servicesRefreshInterval: NodeJS.Timeout | undefined;
rpc.on("connected", async () => {
console.debug("rpc:connected");
this.programs = [];
this.statusName = "Connected";
this.statusIconKey = "normal";
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
await rpc.call("join", {
rooms: [
"events:service",
"events:tuner",
"events:job",
"events:job_schedule"
],
} as JoinParams);
await this.fetchStatus();
await this.fetchServices();
await this.fetchTuners();
await this.fetchJobs();
await this.fetchJobSchedules();
// periodic refresh (every 5s)
_statusRefreshInterval = setInterval(async () => {
if (document.hidden) { return; }
await this.fetchStatus();
}, 1000 * 5);
// periodic refresh (every 60s, for data not covered by push events like logo)
_servicesRefreshInterval = setInterval(async () => {
if (document.hidden) { return; }
await this.fetchServices();
}, 1000 * 60);
});
rpc.on("disconnect", () => {
console.debug("rpc:disconnected");
if (_statusRefreshInterval) {
clearInterval(_statusRefreshInterval);
_statusRefreshInterval = undefined;
}
if (_servicesRefreshInterval) {
clearInterval(_servicesRefreshInterval);
_servicesRefreshInterval = undefined;
}
this.statusName = "Disconnected";
this.statusIconKey = "offline";
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
});
rpc.methods.set("events", async (socket, { array }: NotifyParams<Event>) => {
let programsUpdated = false;
let servicesUpdated = false;
let tunersUpdated = false;
let jobsUpdated = false;
let jobSchedulesUpdated = false;
for (const event of array) {
switch (event.resource) {
case "program": {
const program = event.data as Program;
const index = this.programs.findIndex(p => p.id === program.id);
if (event.type === "remove") {
if (index !== -1) {
this.programs.splice(index, 1);
}
} else {
if (index === -1) {
this.programs.push(program);
} else {
this.programs.splice(index, 1, program);
}
}
programsUpdated = true;
break;
}
case "service": {
const service = event.data as Service;
const index = this.services.findIndex(s => s.id === service.id);
if (index === -1) {
this.services.push(service);
} else {
this.services[index] = {
...this.services[index],
...service
};
}
servicesUpdated = true;
break;
}
case "tuner": {
const tuner = event.data as TunerDevice;
this.tuners[this.tuners.findIndex(value => value.index === tuner.index)] = tuner;
tunersUpdated = true;
break;
}
case "job": {
const job = event.data as JobItem;
const index = this.jobs.findIndex(j => j.id === job.id);
if (index === -1) {
this.jobs.unshift(job);
} else {
this.jobs.splice(index, 1, job);
}
jobsUpdated = true;
break;
}
case "job_schedule": {
const jobSchedule = event.data as JobScheduleItem;
const index = this.jobSchedules.findIndex(j => j.key === jobSchedule.key);
if (index === -1) {
this.jobSchedules.push(jobSchedule);
} else {
this.jobSchedules.splice(index, 1, jobSchedule);
}
jobSchedulesUpdated = true;
break;
}
}
}
if (programsUpdated) {
this.emit("programs", this.programs);
}
if (servicesUpdated) {
this.emit("services", this.services);
}
if (tunersUpdated) {
this.emit("tuners", this.tuners);
}
if (jobsUpdated) {
this._handleJobs();
this.emit("jobs", this.jobs);
}
if (jobSchedulesUpdated) {
this.emit("jobSchedules", this.jobSchedules);
}
});
// ログイベントの処理
rpc.methods.set("logs", async (socket, { array }: NotifyParams<string>) => {
// 配列から文字列を抽出し、unshift=false末尾に追加でイベントを発行
this.emit("logs", array, false);
});
}
private _handleJobs() {
this.jobs.sort((a, b) => {
if (a.status === b.status) {
if (a.finishedAt && b.finishedAt) {
return b.finishedAt - a.finishedAt;
}
if (a.startedAt && b.startedAt) {
return b.startedAt - a.startedAt;
}
if (a.createdAt && b.createdAt) {
return b.createdAt - a.createdAt;
}
return b.id.localeCompare(a.id);
}
return jobStatusOrderMap[a.status] - jobStatusOrderMap[b.status];
});
// drop old jobs
if (this.jobs.length > 200) {
this.jobs.splice(200, this.jobs.length - 200);
}
}
private _updateIdleStatus() {
let statusName = "Standby";
let statusIconKey: StatusIconKey = "normal";
const isActive = this.tuners.some(tuner => tuner.isUsing === true && tuner.users.some(user => user.priority !== -1));
if (isActive) {
statusName = "Active";
statusIconKey = "active";
}
if (this.statusName !== statusName) {
this.statusName = statusName;
this.statusIconKey = statusIconKey;
this.emit("statusName", this.statusName);
this.emit("statusIconKey", this.statusIconKey);
}
}
}
export const state = new State();

80
web/src/modules/ui.ts Normal file
View File

@@ -0,0 +1,80 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { LazyCaller } from "./common";
import * as regexp from "./regexp";
export function blur(): void {
(document.activeElement as HTMLElement)?.blur();
}
export const setTitle = (() => {
const lazy = new LazyCaller(100, 0, _setTitle);
return lazy.caller.bind(this) as typeof _setTitle;
})();
export function _setTitle(title: string, loading?: boolean): void {
if (!title) {
return;
}
title = title.replace(regexp.squaredUnicode, "").replace(regexp.legacyAttributeFormat, "");
if (!title) {
return;
}
if (!loading) {
const elements = document.querySelectorAll(".heading-title");
elements.forEach(element => {
element.classList.remove("bp5-skeleton");
element.textContent = title;
});
}
document.title = `${title.normalize("NFKC")} | Mirakurun`;
}
let _faviconElement: HTMLLinkElement;
export function setFavicon(src: string) {
if (!_faviconElement) {
_faviconElement = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
}
if (_faviconElement) {
_faviconElement.href = src;
}
}
export function autoLink(text: string): string {
return text
.replace(regexp.epgHTTPLinkFormat, text => {
let url = text.normalize("NFKC");
if (!/^http/.test(url)) {
url = `https://${url}`;
}
return `<a referrerpolicy="no-referrer" target="_blank" href="${url}" title="外部サイト">${text}</a>`;
})
.replace(regexp.epgXLinkFormat, (text, username) => {
return text.replace(username,
`<a referrerpolicy="no-referrer" target="_blank" href="https://x.com/${username.slice(1)}" title="X">${username}</a>`
);
})
.replace(regexp.epgInstagramLinkFormat, (text, username) => {
return text.replace(username,
`<a referrerpolicy="no-referrer" target="_blank" href="https://instagram.com/${username.slice(1)}" title="Instagram">${username}</a>`
);
});
}

37
web/src/redoc-ui.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<title>Mirakurun API Documentation</title>
<link id="icon" rel="icon" type="image/svg+xml" href="/icon.svg">
<script src="/redoc/redoc.standalone.js#/redoc@999.9.9+dummy-for-redoc-try/"></script>
<script src="/redoc-try/try.js"></script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="redoc-container"></div>
<script>
initTry({
openApi: "/api/docs",
redocOptions: {
scrollYOffset: 0,
hideDownloadButton: true,
theme: {
colors: {
primary: {
main: "#1976d2"
}
}
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-about-view
.about-container
max-width: 922px // opencollective img width is 890 + card padding
margin: 0 auto
display: flex
flex-direction: column
gap: 16px
padding-bottom: 24px
.about-card, .heart-card
padding: 24px
.about-header
display: flex
align-items: center
gap: 16px
margin-bottom: 16px
.product-icon
width: 48px
height: 48px
h3
margin: 0
.about-info
margin-top: 16px
margin-bottom: 16px
.info-table
width: 100%
td:first-child
width: 150px
font-weight: bold
.warranty-warning
background: colors.$light-gray5
border-left: 4px solid colors.$red3
padding: 12px 16px
margin-bottom: 16px
border-radius: 0 4px 4px 0
.warranty-text
font-weight: bold
color: colors.$red2
.links
display: flex
gap: 8px
.consent
margin-top: 16px
padding: 16px
background: colors.$light-gray5
border-radius: 4px
p
margin-bottom: 12px
.contributors-list
margin-top: 16px
display: flex
flex-direction: column
gap: 24px
.section
h5
margin-bottom: 8px
display: flex
align-items: center
gap: 8px
.text-small
font-size: 0.85em
.image-container
margin-top: 12px
overflow-x: auto
.opencollective-img
max-width: 100%
height: auto
display: block
.sponsors-avatars
margin-top: 12px
display: flex
flex-wrap: wrap
gap: 8px
.sponsor-avatar
padding: 4px
transition: transform 0.2s, box-shadow 0.2s
&:hover
transform: scale(1.05)
body.bp5-dark
#route-about-view
.warranty-warning
background: colors.$dark-gray4
.warranty-text
color: colors.$red4
.consent
background: colors.$dark-gray4

View File

@@ -0,0 +1,188 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Alignment, Breadcrumbs, Button, Card, Divider, Elevation, H3, H5, Navbar, Text } from "@blueprintjs/core";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { VersionStatus } from "../components/VersionStatus";
import "./AboutView.sass";
export const AboutView: React.FC = () => {
console.debug("routes", "AboutView");
ui.setTitle("Mirakurun について");
const [version, setVersion] = useState<string>(state.version);
useEffect(() => {
const onVersion = () => {
setVersion(state.version);
};
state.on("version", onVersion);
return () => {
state.off("version", onVersion);
};
}, []);
const [consented, setConsented] = useState<boolean>(false);
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "Mirakurun について"
}
]} />
</Navbar.Heading>
</Navbar.Group>
</Navbar>
);
return (
<div className="route" id="route-about-view">
{toolbar}
<div className="content">
<div className="about-container">
<Card elevation={Elevation.ONE} className="about-card">
<div className="about-header">
<img className="product-icon" src={state.statusIconSrc} alt={state.statusName} />
<H3>Mirakurun</H3>
</div>
<Divider />
<div className="about-info">
<table className="bp5-html-table bp5-html-table-striped bp5-html-table-condensed info-table">
<tbody>
<tr>
<td>Current</td>
<td>{version}</td>
</tr>
<tr>
<td>Latest</td>
<td><VersionStatus /></td>
</tr>
<tr>
<td>License</td>
<td>Apache License 2.0</td>
</tr>
<tr>
<td>Copyright</td>
<td>Copyright &copy; 2016-2026 <a href="https://github.com/kanreisa" target="_blank" rel="noreferrer">kanreisa</a></td>
</tr>
</tbody>
</table>
</div>
<div className="warranty-warning">
<Text className="warranty-text">
Mirakurun comes with ABSOLUTELY NO WARRANTY. USE AT YOUR OWN RISK.
</Text>
</div>
<div className="links">
<Button
icon="git-branch"
text="GitHub Repository"
onClick={() => window.open("https://github.com/Chinachu/Mirakurun", "_blank")}
variant="minimal"
/>
<Button
icon="globe"
text="Chinachu Project"
onClick={() => window.open("https://chinachu.moe/", "_blank")}
variant="minimal"
/>
</div>
</Card>
<Card elevation={Elevation.ONE} className="heart-card">
<H5>Special Thanks</H5>
{consented === false ? (
<div className="consent">
<p>We sincerely thank you for your continued support.</p>
<p>
This page is attempting to retrieve images from your browser by going directly to{" "}
<a href="https://opencollective.com/" target="_blank" rel="noreferrer">
opencollective.com
</a>{" "}
in order to display a list of contributors.
</p>
<Button
intent="primary"
text="Continue"
onClick={() => setConsented(true)}
/>
</div>
) : (
<div className="contributors-list">
<div className="section">
<H5>Contributors</H5>
<p>This project exists thanks to all the people who contribute.</p>
<div className="image-container">
<a href="https://github.com/Chinachu/Mirakurun/graphs/contributors" target="_blank" rel="noreferrer">
<img src="https://opencollective.com/Mirakurun/contributors.svg?width=890&button=false" alt="Contributors" className="opencollective-img" />
</a>
</div>
</div>
<Divider />
<div className="section">
<H5>
Backers{" "}
<span className="bp5-text-muted text-small">
[<a href="https://opencollective.com/Mirakurun#backer" target="_blank" rel="noreferrer">Become a backer</a>]
</span>
</H5>
<p>Thank you to all our backers! 🙏</p>
<div className="image-container">
<a href="https://opencollective.com/Mirakurun#backers" target="_blank" rel="noreferrer">
<img src="https://opencollective.com/Mirakurun/backers.svg?width=890" alt="Backers" className="opencollective-img" />
</a>
</div>
</div>
<Divider />
<div className="section">
<H5>
Sponsors{" "}
<span className="bp5-text-muted text-small">
[<a href="https://opencollective.com/Mirakurun#sponsor" target="_blank" rel="noreferrer">Become a sponsor</a>]
</span>
</H5>
<p>Support this project by becoming a sponsor. Your logo will show up here with a link to your website.</p>
<div className="sponsors-avatars">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => (
<a key={i} href={`https://opencollective.com/Mirakurun/sponsor/${i}/website`} target="_blank" rel="noreferrer">
<img src={`https://opencollective.com/Mirakurun/sponsor/${i}/avatar.svg`} alt={`Sponsor ${i}`} className="sponsor-avatar" />
</a>
))}
</div>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,78 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-channels-config-view
.content
display: flex
flex-direction: column
gap: 16px
padding: 20px
overflow-y: auto
.channels-table
width: 100%
border-collapse: collapse
th, td
vertical-align: top !important
padding: 12px 8px !important
td
.bp5-form-group
margin-bottom: 8px
&:last-child
margin-bottom: 0
.bp5-label
margin-bottom: 3px
font-weight: 600
font-size: 11px
color: colors.$gray1
.bp5-dark &
color: colors.$gray4
.channel-options-grid
display: flex
gap: 12px
align-items: flex-start
flex-wrap: wrap
.cmd-vars-container
display: flex
flex-direction: column
gap: 4px
min-width: 200px
flex: 1
.cmd-vars-title
font-weight: 600
font-size: 11px
margin-bottom: 4px
color: colors.$gray1
.bp5-dark &
color: colors.$gray4
.cmd-vars-list
display: flex
flex-wrap: wrap
gap: 6px
align-items: center
.cmd-var-pair
display: flex
align-items: center
gap: 4px
background-color: rgba(colors.$light-gray1, 0.4)
padding: 2px 6px
border-radius: 4px
.bp5-dark &
background-color: rgba(colors.$dark-gray5, 0.4)
.cmd-var-key, .cmd-var-value
width: 75px
.controls-cell
display: flex
gap: 4px
justify-content: flex-end
align-items: center

View File

@@ -0,0 +1,915 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import {
Alignment,
Breadcrumbs,
Button,
Callout,
Dialog,
DialogBody,
DialogFooter,
FormGroup,
HTMLSelect,
HTMLTable,
InputGroup,
Navbar,
NonIdealState,
ProgressBar,
Spinner,
Switch
} from "@blueprintjs/core";
import equal from "fast-deep-equal";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { ConfigChannels, ConfigChannelsItem, ChannelType, ChannelScanStatus } from "../../../api.d";
import "./ChannelsConfigView.sass";
const configAPI = "/api/config/channels";
const typesIndex = ["GR", "BS", "CS", "SKY"];
function sortTypes(types: ChannelType[]): ChannelType[] {
return types.sort((a, b) => typesIndex.indexOf(a) - typesIndex.indexOf(b));
}
// チャンネル範囲を展開する関数(例: "14-16,18" → "14,15,16,18"
function expandChannelRanges(input: string): string {
if (!input) return "";
const parts = input.split(",");
const result: number[] = [];
for (const part of parts) {
if (part.includes("-")) {
const [start, end] = part.split("-").map(n => parseInt(n.trim(), 10));
if (!isNaN(start) && !isNaN(end)) {
for (let i = start; i <= end; i++) {
result.push(i);
}
}
} else {
const num = parseInt(part.trim(), 10);
if (!isNaN(num)) {
result.push(num);
}
}
}
return [...new Set(result)].sort((a, b) => a - b).join(",");
}
const migrateChannels = (channels: ConfigChannels): ConfigChannels => {
return channels.map(ch => {
if ((ch.satellite || ch.space !== undefined || ch.freq !== undefined || ch.polarity) && (!ch.commandVars || Object.keys(ch.commandVars).length === 0)) {
const commandVars: Record<string, string | number> = {};
if (ch.satellite) {
commandVars["satellite"] = ch.satellite;
}
if (ch.space !== undefined) {
commandVars["space"] = ch.space;
}
if (ch.freq !== undefined) {
commandVars["freq"] = ch.freq;
}
if (ch.polarity) {
commandVars["polarity"] = ch.polarity;
}
return {
...ch,
commandVars
};
}
return ch;
});
};
export const ChannelsConfigView: React.FC = () => {
console.debug("routes", "ChannelsConfigView");
const [current, setCurrent] = useState<ConfigChannels | null>(null);
const [editing, setEditing] = useState<ConfigChannels | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saved, setSaved] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// チャンネルスキャンのためのステート
const [showScanDialog, setShowScanDialog] = useState(false);
const [scanType, setScanType] = useState<ChannelType>("GR");
const [scanMinCh, setScanMinCh] = useState("13");
const [scanMaxCh, setScanMaxCh] = useState("62");
const [scanSkipCh, setScanSkipCh] = useState("");
const [scanMinSubCh, setScanMinSubCh] = useState("0");
const [scanMaxSubCh, setScanMaxSubCh] = useState("3");
const [scanUseSubCh, setScanUseSubCh] = useState(true);
const [scanChannelNameFormatEnabled, setScanChannelNameFormatEnabled] = useState(false);
const [scanChannelNameFormat, setScanChannelNameFormat] = useState("");
const [scanSetDisabledOnAdd, setScanSetDisabledOnAdd] = useState(false);
const [scanAutoApply, setScanAutoApply] = useState(false);
const [scanRefresh, setScanRefresh] = useState(false);
const [scanStatus, setScanStatus] = useState<ChannelScanStatus | null>(null);
const [scanInProgress, setScanInProgress] = useState(false);
const [showScanResultDialog, setShowScanResultDialog] = useState(false);
ui.setTitle("チャンネル設定", isLoading);
// スキャンステータスを取得する
const fetchScanStatus = async () => {
try {
const res: ChannelScanStatus = await (await fetch("/api/config/channels/scan")).json();
console.log("ChannelsConfigView", "GET", "/api/config/channels/scan", "->", res);
setScanStatus(res);
setScanInProgress(prev => {
if (res.status === "completed" && prev && !res.isScanning) {
setShowScanResultDialog(true);
}
return res.isScanning;
});
} catch (e) {
console.error("Failed to fetch scan status:", e);
}
};
// スキャンを開始する
const startScan = async () => {
try {
const params = new URLSearchParams();
params.append("type", scanType);
params.append("minCh", scanMinCh);
params.append("maxCh", scanMaxCh);
if (scanSkipCh.trim()) {
const expandedSkipCh = expandChannelRanges(scanSkipCh.trim());
params.append("skipCh", expandedSkipCh);
}
if (scanType === "BS" && scanUseSubCh) {
params.append("minSubCh", scanMinSubCh);
params.append("maxSubCh", scanMaxSubCh);
params.append("useSubCh", "true");
}
if (!scanAutoApply) {
params.append("dryRun", "true");
}
if (scanChannelNameFormatEnabled && scanChannelNameFormat.trim()) {
params.append("channelNameFormat", scanChannelNameFormat.trim());
}
params.append("setDisabledOnAdd", scanSetDisabledOnAdd ? "true" : "false");
if (scanRefresh) {
params.append("refresh", "true");
}
params.append("async", "true");
const url = `/api/config/channels/scan?${params.toString()}`;
console.log("ChannelsConfigView", "PUT", url);
const response = await fetch(url, { method: "PUT" });
const result = await response.json();
if (response.status === 202) {
console.log("Scan started:", result);
setScanInProgress(true);
setShowScanDialog(false);
await fetchScanStatus();
} else {
console.error("Failed to start scan:", result);
}
} catch (e) {
console.error("Error starting scan:", e);
}
};
// スキャンを停止する
const stopScan = async () => {
try {
const response = await fetch("/api/config/channels/scan", { method: "DELETE" });
console.log("ChannelsConfigView", "DELETE", "/api/config/channels/scan", "->", await response.json());
setScanInProgress(false);
} catch (e) {
console.error("Error stopping scan:", e);
}
};
// スキャン結果を適用する
const applyScanResult = () => {
if (scanStatus && scanStatus.result) {
setEditing(JSON.parse(JSON.stringify(scanStatus.result)));
setShowScanResultDialog(false);
}
};
// 初期データ読み込み
useEffect(() => {
if (saved === true) {
setTimeout(() => {
// Restart notification will be emitted in production when requested
}, 500);
setSaved(false);
return;
}
(async () => {
try {
const res = await (await fetch(configAPI)).json();
console.log("ChannelsConfigView", "GET", configAPI, "->", res);
const migrated = migrateChannels(res);
setEditing(JSON.parse(JSON.stringify(migrated)));
setCurrent(JSON.parse(JSON.stringify(migrated)));
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
})();
}, [saved]);
// スキャン状態の定期チェック
useEffect(() => {
fetchScanStatus();
}, [current]);
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (scanInProgress) {
intervalId = setInterval(fetchScanStatus, 5000);
} else {
intervalId = setInterval(fetchScanStatus, 30000);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [scanInProgress]);
const hasChanges = editing !== null && current !== null && !equal(editing, current);
const handleCancel = () => {
if (current) {
setEditing(JSON.parse(JSON.stringify(current)));
}
};
const handleSave = async () => {
if (!editing) return;
setShowSaveDialog(false);
try {
console.log("ChannelsConfigView", "PUT", configAPI, "<-", editing);
await fetch(configAPI, {
method: "PUT",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(editing)
});
setSaved(true);
} catch (err) {
console.error(err);
}
};
const handleAddChannel = () => {
if (!editing) return;
const i = editing.length;
const newChannel: ConfigChannelsItem = {
name: `ch${i}`,
type: "GR",
channel: "0",
isDisabled: true
};
setEditing([...editing, newChannel]);
};
const updateChannel = (index: number, updated: Partial<ConfigChannelsItem>) => {
if (!editing) return;
const newEditing = [...editing];
newEditing[index] = { ...newEditing[index], ...updated };
setEditing(newEditing);
};
const deleteChannelProperty = (index: number, key: keyof ConfigChannelsItem) => {
if (!editing) return;
const newEditing = [...editing];
const updated = { ...newEditing[index] };
delete updated[key];
newEditing[index] = updated;
setEditing(newEditing);
};
const handleUp = (i: number) => {
if (!editing || i === 0) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i - 1];
newEditing[i - 1] = temp;
setEditing(newEditing);
};
const handleDown = (i: number) => {
if (!editing || i === editing.length - 1) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i + 1];
newEditing[i + 1] = temp;
setEditing(newEditing);
};
const handleRemove = (i: number) => {
if (!editing) return;
const newEditing = [...editing];
newEditing.splice(i, 1);
setEditing(newEditing);
};
// Command Var Helpers
const updateCommandVarKey = (chIndex: number, oldKey: string, newKey: string) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
const updatedVars: Record<string, string | number> = {};
Object.entries(commandVars).forEach(([k, v]) => {
if (k === oldKey) {
updatedVars[newKey] = v;
} else {
updatedVars[k] = v;
}
});
ch.commandVars = updatedVars;
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const updateCommandVarValue = (chIndex: number, key: string, newValue: string) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
if (newValue === "") {
commandVars[key] = "";
} else if (newValue === "0") {
commandVars[key] = 0;
} else if (/^[0-9]+(\.[0-9]+)?$/.test(newValue)) {
commandVars[key] = parseFloat(newValue);
} else {
commandVars[key] = newValue;
}
ch.commandVars = commandVars;
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const removeCommandVar = (chIndex: number, key: string) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
delete commandVars[key];
if (Object.keys(commandVars).length === 0) {
delete ch.commandVars;
} else {
ch.commandVars = commandVars;
}
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const addCommandVar = (chIndex: number) => {
if (!editing) return;
const newEditing = [...editing];
const ch = { ...newEditing[chIndex] };
const commandVars = { ...(ch.commandVars || {}) };
let newKey = "arg";
let counter = 1;
while (commandVars[newKey] !== undefined) {
newKey = `arg${counter}`;
counter++;
}
commandVars[newKey] = "";
ch.commandVars = commandVars;
newEditing[chIndex] = ch;
setEditing(newEditing);
};
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "チャンネル設定"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
minimal
intent="success"
icon="add"
text="Add Channel"
onClick={handleAddChannel}
/>
<Button
minimal
intent="warning"
icon="search"
text="Channel Scan"
onClick={() => setShowScanDialog(true)}
disabled={scanInProgress}
/>
<Navbar.Divider />
<Button
minimal
intent="danger"
icon="undo"
text="Cancel"
disabled={!hasChanges}
onClick={handleCancel}
/>
<Button
intent="primary"
icon="saved"
text="Save"
disabled={!hasChanges}
onClick={() => setShowSaveDialog(true)}
/>
</Navbar.Group>
</Navbar>
);
if (isLoading || !editing) {
return (
<div className="route" id="route-channels-config-view">
{toolbar}
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="設定を読み込んでいます..."
/>
</div>
);
}
return (
<div className="route" id="route-channels-config-view">
{toolbar}
<div className="content">
{/* スキャン進行中/完了時のステータス表示 */}
{scanInProgress && scanStatus && (
<Callout intent="primary" title={`チャンネルスキャン中 (${scanStatus.type})`}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "8px" }}>
<ProgressBar value={(scanStatus.progress || 0) / 100} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
: <strong>{scanStatus.currentChannel || "初期化中..."}</strong> (: {scanStatus.progress || 0}%)
<span style={{ marginLeft: "16px" }}>: {scanStatus.newCount || 0} / : {scanStatus.takeoverCount || 0}</span>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<Button small icon="refresh" onClick={fetchScanStatus}></Button>
<Button small intent="danger" icon="stop" onClick={stopScan}></Button>
</div>
</div>
</div>
</Callout>
)}
{!scanInProgress && scanStatus && (scanStatus.status === "completed" || (scanStatus.scanLog && scanStatus.scanLog.length > 0)) && (
<Callout
intent={scanStatus.status === "completed" ? "success" : "warning"}
title={`前回のスキャン結果 (${scanStatus.type})`}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "8px" }}>
<div>
: <strong>{scanStatus.status}</strong>
<span style={{ marginLeft: "16px" }}>: {scanStatus.newCount || 0} / : {scanStatus.takeoverCount || 0}</span>
</div>
<div style={{ display: "flex", gap: "8px" }}>
{scanStatus.status === "completed" && scanStatus.result && (
<Button small intent="success" icon="tick" onClick={applyScanResult}></Button>
)}
<Button small icon="document" onClick={() => setShowScanResultDialog(true)}></Button>
</div>
</div>
</Callout>
)}
<HTMLTable className="channels-table" striped interactive>
<thead>
<tr>
<th style={{ width: "80px" }}>Enable</th>
<th style={{ width: "160px" }}>Name</th>
<th style={{ width: "100px" }}>Type</th>
<th style={{ width: "120px" }}>Channel</th>
<th>Options</th>
<th style={{ width: "140px", textAlign: "right" }}></th>
</tr>
</thead>
<tbody>
{editing.map((ch, i) => (
<tr key={i}>
<td>
<Switch
checked={!ch.isDisabled}
onChange={(e) => {
updateChannel(i, { isDisabled: !e.currentTarget.checked });
}}
/>
</td>
<td>
<InputGroup
value={ch.name || ""}
onChange={(e) => {
updateChannel(i, { name: e.target.value });
}}
onBlur={() => {
if (ch.name === "") {
updateChannel(i, { name: `ch${i}` });
}
}}
/>
</td>
<td>
<HTMLSelect
value={ch.type}
onChange={(e) => {
updateChannel(i, { type: e.target.value as ChannelType });
}}
options={[
{ value: "GR", label: "GR" },
{ value: "BS", label: "BS" },
{ value: "CS", label: "CS" },
{ value: "SKY", label: "SKY" }
]}
/>
</td>
<td>
<InputGroup
value={ch.channel || ""}
onChange={(e) => {
updateChannel(i, { channel: e.target.value });
}}
onBlur={() => {
if (ch.channel === "") {
updateChannel(i, { channel: "0" });
}
}}
/>
</td>
<td>
<div className="channel-options-grid">
<FormGroup label="Service ID" style={{ width: "90px", marginBottom: 0 }}>
<InputGroup
placeholder="SID"
value={`${ch.serviceId || ""}`}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteChannelProperty(i, "serviceId");
} else if (/^[0-9]+$/.test(val)) {
const sid = parseInt(val, 10);
if (sid <= 65535 && sid > 0) {
updateChannel(i, { serviceId: sid });
}
}
}}
/>
</FormGroup>
<FormGroup label="TsmfRelTs" style={{ width: "90px", marginBottom: 0 }}>
<InputGroup
placeholder="TsmfRelTs"
value={`${ch.tsmfRelTs || ""}`}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteChannelProperty(i, "tsmfRelTs");
} else if (/^[0-9]+$/.test(val)) {
const tsmfRelTs = parseInt(val, 10);
updateChannel(i, { tsmfRelTs });
}
}}
/>
</FormGroup>
<div className="cmd-vars-container">
<div className="cmd-vars-title">Command Vars</div>
<div className="cmd-vars-list">
{ch.commandVars && Object.entries(ch.commandVars).map(([key, value]) => (
<div key={key} className="cmd-var-pair">
<InputGroup
small
className="cmd-var-key"
value={key}
onChange={(e) => updateCommandVarKey(i, key, e.target.value)}
/>
<span className="cmd-var-separator">:</span>
<InputGroup
small
className="cmd-var-value"
value={`${value}`}
onChange={(e) => updateCommandVarValue(i, key, e.target.value)}
/>
<Button
small
minimal
intent="danger"
icon="cross"
onClick={() => removeCommandVar(i, key)}
/>
</div>
))}
<Button
small
minimal
intent="primary"
icon="plus"
text="Add Var"
onClick={() => addCommandVar(i)}
/>
</div>
</div>
</div>
</td>
<td>
<div className="controls-cell">
<Button
disabled={i === 0}
icon="chevron-up"
onClick={() => handleUp(i)}
minimal
/>
<Button
disabled={i === editing.length - 1}
icon="chevron-down"
onClick={() => handleDown(i)}
minimal
/>
<Button
icon="trash"
intent="danger"
onClick={() => handleRemove(i)}
minimal
/>
</div>
</td>
</tr>
))}
</tbody>
</HTMLTable>
</div>
{/* 保存確認ダイアログ */}
<Dialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
title="Save"
>
<DialogBody>
<p></p>
<p className="bp5-text-muted"></p>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowSaveDialog(false)}></Button>
<Button
intent="primary"
disabled={!hasChanges}
onClick={handleSave}
>
</Button>
</>
}
/>
</Dialog>
{/* スキャン設定ダイアログ */}
<Dialog
isOpen={showScanDialog}
onClose={() => setShowScanDialog(false)}
title="Channel Scan"
style={{ width: "450px" }}
>
<DialogBody>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<FormGroup label="Channel Type">
<HTMLSelect
value={scanType}
onChange={(e) => {
const newType = e.target.value as ChannelType;
setScanType(newType);
switch (newType) {
case "GR":
setScanMinCh("13");
setScanMaxCh("62");
break;
case "BS":
setScanMinCh("1");
setScanMaxCh("23");
break;
case "CS":
setScanMinCh("2");
setScanMaxCh("24");
break;
}
}}
options={[
{ value: "GR", label: "GR" },
{ value: "BS", label: "BS" },
{ value: "CS", label: "CS" }
]}
/>
</FormGroup>
<div style={{ display: "flex", gap: "16px" }}>
<FormGroup label="Min Channel" style={{ flex: 1 }}>
<InputGroup
value={scanMinCh}
onChange={(e) => setScanMinCh(e.target.value)}
/>
</FormGroup>
<FormGroup label="Max Channel" style={{ flex: 1 }}>
<InputGroup
value={scanMaxCh}
onChange={(e) => setScanMaxCh(e.target.value)}
/>
</FormGroup>
</div>
<FormGroup
label="Skip Channels (comma separated integers)"
helperText="Enter channel numbers to skip. Range notation (e.g. 14-16) is supported."
>
<InputGroup
placeholder="Example: 13,14-16,18"
value={scanSkipCh}
onChange={(e) => {
const val = e.target.value;
if (val === "" || /^[0-9,\-]+$/.test(val)) {
setScanSkipCh(val);
}
}}
/>
</FormGroup>
{scanType === "BS" && (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<Switch
label="Use Subchannel Style (BS01_0)"
checked={scanUseSubCh}
onChange={(e) => setScanUseSubCh(e.currentTarget.checked)}
/>
{scanUseSubCh && (
<div style={{ display: "flex", gap: "16px" }}>
<FormGroup label="Min Subchannel" style={{ flex: 1 }}>
<InputGroup
value={scanMinSubCh}
onChange={(e) => setScanMinSubCh(e.target.value)}
/>
</FormGroup>
<FormGroup label="Max Subchannel" style={{ flex: 1 }}>
<InputGroup
value={scanMaxSubCh}
onChange={(e) => setScanMaxSubCh(e.target.value)}
/>
</FormGroup>
</div>
)}
</div>
)}
<Switch
label="Use Channel Name Format"
checked={scanChannelNameFormatEnabled}
onChange={(e) => setScanChannelNameFormatEnabled(e.currentTarget.checked)}
/>
{scanChannelNameFormatEnabled && (
<FormGroup
label="Channel Name Format"
helperText="Format to use for channel names. Supports placeholders like {ch}, {ch00}, {subch}."
>
<InputGroup
placeholder="Example: {ch}, BS{ch00}_{subch}"
value={scanChannelNameFormat}
onChange={(e) => setScanChannelNameFormat(e.target.value)}
/>
</FormGroup>
)}
<Switch
label="Auto Apply Results (Restart required)"
checked={scanAutoApply}
onChange={(e) => setScanAutoApply(e.currentTarget.checked)}
/>
<Switch
label="Set Disabled on Add"
checked={scanSetDisabledOnAdd}
onChange={(e) => setScanSetDisabledOnAdd(e.currentTarget.checked)}
/>
<Switch
label="Refresh (Update existing channels)"
checked={scanRefresh}
onChange={(e) => setScanRefresh(e.currentTarget.checked)}
/>
</div>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowScanDialog(false)}>Cancel</Button>
<Button intent="primary" onClick={startScan}>Start Scan</Button>
</>
}
/>
</Dialog>
{/* スキャン結果/ログダイアログ */}
<Dialog
isOpen={showScanResultDialog}
onClose={() => setShowScanResultDialog(false)}
title="Scan Results"
style={{ width: "600px" }}
>
<DialogBody>
{scanStatus && (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{scanStatus.status === "completed" && (
<Callout intent="success" title="スキャン完了">
<div>: {scanStatus.newCount} | : {scanStatus.takeoverCount}</div>
</Callout>
)}
<div style={{
maxHeight: "300px",
overflowY: "auto",
border: "1px solid rgba(0,0,0,0.1)",
padding: "8px",
fontFamily: "monospace",
fontSize: "12px",
whiteSpace: "pre-wrap",
backgroundColor: "rgba(0,0,0,0.02)"
}}>
{scanStatus.scanLog && scanStatus.scanLog.length > 0 ? (
scanStatus.scanLog.join("\n")
) : (
<div></div>
)}
</div>
{scanStatus.status === "completed" && scanStatus.result && (
<Callout intent="primary">
</Callout>
)}
</div>
)}
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowScanResultDialog(false)}></Button>
<Button
intent="primary"
onClick={applyScanResult}
disabled={scanStatus?.status !== "completed" || !scanStatus?.result}
>
</Button>
</>
}
/>
</Dialog>
</div>
);
};

198
web/src/routes/EPGView.tsx Normal file
View File

@@ -0,0 +1,198 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Alignment, Button, Navbar, Tabs, Tab, HTMLSelect, Breadcrumbs } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { useLocalStorageState } from "../hooks/useWebStorageState";
import { ChannelType } from "../../../api";
import { WatchButton } from "../components/WatchButton";
import { EPGTable } from "../components/EPGTable";
export const EPGView: React.FC = () => {
console.debug("routes", "EPG");
const params = useParams();
const { navigate, searchParams } = state;
const [channelType, setChannelType] = useLocalStorageState<ChannelType>("EPG.channelType", "GR");
const [programId, setProgramId] = useState<number>(null);
const [time, setTime] = useState<number>(null);
const globalServiceId = parseInt(params.globalServiceId, 10) || null;
const programIdQuery = searchParams.get("programId");
const typeQuery = searchParams.get("type");
const dateQuery = searchParams.get("date");
const timeQuery = searchParams.get("time");
const now = DateTime.now();
const isoDate = /^\d{4}-\d{2}-\d{2}$/.test(dateQuery) ? dateQuery : now.toISODate();
const startDate = now.startOf("day");
const endDate = startDate.plus({ days: 7 });
let date = DateTime.fromISO(isoDate);
if (globalServiceId) {
date = date.set({ day: now.day });
}
if (typeQuery) {
if (typeQuery === "ALL") {
if (channelType !== null) {
setChannelType(null);
return;
}
} else if (typeQuery !== channelType) {
setChannelType(typeQuery as ChannelType);
return;
}
} else if (!globalServiceId && !programIdQuery && !timeQuery) {
let to = `?type=${channelType || "ALL"}&date=${isoDate}`;
setTimeout(() => navigate(to, { replace: true }), 0);
return;
}
let hasRemovedTempParams = false;
if (programIdQuery) {
setProgramId(parseInt(programIdQuery, 10));
searchParams.delete("programId");
hasRemovedTempParams = true;
}
if (timeQuery) {
setTime(parseInt(timeQuery, 10));
searchParams.delete("time");
hasRemovedTempParams = true;
}
if (hasRemovedTempParams) {
setTimeout(() => navigate(`?${searchParams.toString()}`, { replace: true }), 0);
return;
}
if (!globalServiceId) {
ui.setTitle("EPG");
}
const toolbarTabs: JSX.Element[] = [];
if (date >= startDate && date <= endDate && !globalServiceId) {
for (let i = 0; i <= 7; i++) {
const cur = startDate.plus({ days: i });
const id = `epg-toolbar-tabs-item-${cur.toISODate()}`;
const d = i === 0 ? cur.toFormat("M/d") : cur.toFormat("d");
const c = cur.toFormat("ccc");
toolbarTabs.push(<Tab key={id} id={id} title={<>{d}<sup className={`color-dow-${cur.weekday}`}>{c}</sup></>} />);
}
} else {
const id = `epg-toolbar-tabs-item-${date.toISODate()}`;
const title = date.toFormat("yyyy/MM/dd(ccc)");
toolbarTabs.push(<Tab key={id} id={id} title={title} />);
}
const showTodayButton = (!globalServiceId && toolbarTabs.length === 1) || (globalServiceId && date.toMillis() !== startDate.toMillis());
return (
<div className="route" id="route-epg">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
{globalServiceId
? <Breadcrumbs items={[
{ text: "EPG 番組表", onClick: () => {
let to = `/epg?type=${channelType || ""}`;
if (isoDate) {
to += `&date=${isoDate}`;
}
if (time) {
to += `&time=${time}`;
}
navigate(to)
} },
{ text: "週間" },
{ text: "放送サービス...", className: "heading-title bp5-skeleton" }
]} />
: "EPG 番組表"
}
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
{globalServiceId && (
<>
<WatchButton variant="outlined" popoverPlacement="bottom-start" globalServiceId={globalServiceId} />
</>
)}
{!globalServiceId && (
<>
{showTodayButton && (
<Button variant="minimal" icon="reset" text="今日" onClick={() => {
navigate("?", { replace: true });
}} />
)}
<Tabs id="epg-toolbar-tabs"
selectedTabId={`epg-toolbar-tabs-item-${isoDate}`}
onChange={(tabId: string) => {
const to = tabId.replace(/^epg-toolbar-tabs-item-/, "");
if (isoDate !== to) {
navigate(`?date=${to}&type=${channelType || "ALL"}`);
}
}}
>
{toolbarTabs}
</Tabs>
<Navbar.Divider />
<HTMLSelect
className="bp5-outlined"
options={[
{ value: "ALL", label: "全波" },
{ value: "GR", label: "地上" },
{ value: "BS" },
{ value: "CS" },
{ value: "SKY" },
]}
value={channelType || ""}
onChange={event => {
ui.blur();
const type = event.currentTarget.value;
let to = `/epg?type=${type}`;
if (isoDate) {
to += `&date=${isoDate}`;
}
navigate(to);
}}
/>
</>
)}
</Navbar.Group>
</Navbar>
<div className="content no-margin">
{globalServiceId
? <EPGTable date={date} defaultTime={time} globalServiceId={globalServiceId} />
: <EPGTable date={date} defaultTime={time} defaultProgramId={programId} channelType={channelType} />
}
</div>
</div>
);
};

View File

@@ -0,0 +1,238 @@
@use "~@blueprintjs/colors/lib/scss/colors"
@use "../vars"
#route-home-view
.home-container
display: flex
flex-direction: column
gap: 20px
.home-section
flex-shrink: 0
background: rgba(colors.$light-gray5, 0.2)
&:last-child
margin-bottom: 20px
.bp5-dark &
background: rgba(colors.$black, 0.2)
.home-section-content
padding: 15px
.bp5-section-header
min-height: 40px
// --- Status Section ---
.status-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))
gap: 10px
.status-item
display: flex
flex-direction: column
gap: 5px
padding: 5px 10px
border-radius: 4px
.status-label
font-size: 12px
color: colors.$gray3
font-weight: 500
.bp5-dark &
color: colors.$gray5
.status-value
font-size: 14px
font-family: vars.$font-ui
word-break: break-all
// --- Services Section ---
.service-filters
display: flex
gap: 10px
margin-bottom: 10px
.service-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))
gap: 5px
.service-item
display: flex
align-items: center
gap: 5px
padding: 5px 10px
border-radius: 4px
transition: background 0.15s
&:hover
background: colors.$light-gray3
.bp5-dark &
background: colors.$dark-gray2
.bp5-icon
vertical-align: baseline
.service-item-main
display: flex
align-items: center
gap: 5px
flex: 1
min-width: 0
color: inherit
text-decoration: none
cursor: pointer
.service-logo
width: 32px
height: auto
border-radius: 1px
flex-shrink: 0
.service-name
flex: 1
min-width: 0
font-size: 13px
font-weight: 500
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.service-epg-status
flex-shrink: 0
.service-play
flex-shrink: 0
padding: 4px
border-radius: 4px
cursor: pointer
transition: background 0.15s
&:hover
background: colors.$light-gray3
.bp5-dark &
background: colors.$dark-gray2
.service-tooltip
display: flex
flex-direction: column
gap: 5px
font-size: 12px
font-family: vars.$font-ui
line-height: 1.5
.bp5-dark &
color: colors.$gray1
// --- Tuners Section ---
.tuner-tree
.bp5-tree-node
.bp5-tree-node-content
height: 35px
gap: 10px
.bp5-tree-node-content:hover
background: none
.bp5-tree-node-label
display: flex
align-items: center
gap: 10px
.tuner-label
font-size: 13px
font-weight: 600
white-space: nowrap
.tuner-device-info
display: flex
align-items: center
gap: 10px
font-size: 12px
font-family: vars.$font-ui
color: colors.$gray1
white-space: nowrap
.bp5-button
margin-left: 4px
padding: 0 4px
min-width: unset
min-height: unset
.bp5-dark &
color: colors.$gray2
.tuner-user-info
display: flex
align-items: center
gap: 16px
font-size: 12px
font-family: vars.$font-ui
white-space: nowrap
.tuner-user-info-item
display: inline-flex
align-items: center
gap: 4px
.stream-info-link
color: colors.$blue3
text-decoration: none
cursor: pointer
&:hover
text-decoration: underline
// --- EPG Ready Color ---
.color-epg-ready
color: colors.$green3 !important
// Stream Info Dialog (rendered via Portal outside #route-home-view)
.stream-info-table
width: 100%
table-layout: fixed
font-size: 12px
border-collapse: collapse
th, td
padding: 4px 12px
th:nth-child(1), td:nth-child(1)
text-align: left
th:nth-child(2), td:nth-child(2),
th:nth-child(3), td:nth-child(3)
text-align: right
font-variant-numeric: tabular-nums
td.color-danger
color: colors.$red3
// Dark mode adjustments
body.bp5-dark
#route-home-view
.service-item
&:hover
background: colors.$dark-gray2
.service-tooltip
color: colors.$gray1
.tuner-device-info
color: colors.$gray2
.color-epg-ready
color: colors.$green4 !important
.stream-info-link
color: colors.$blue4
.stream-info-table td.color-danger
color: colors.$red4

583
web/src/routes/HomeView.tsx Normal file
View File

@@ -0,0 +1,583 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import {
Alignment,
Breadcrumbs,
Button,
Checkbox,
Dialog,
DialogBody,
DialogFooter,
Icon,
Navbar,
NonIdealState,
Section,
Spinner,
Tooltip,
Tree,
TreeNodeInfo
} from "@blueprintjs/core";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { Service, Status, StreamInfo, TunerDevice } from "../../../api.d";
import "./HomeView.sass";
const summarizeStreamInfo = (streamInfo: StreamInfo): string => {
if (!streamInfo) {
return "-";
}
let packets = 0;
let drops = 0;
for (const pid in streamInfo) {
packets += streamInfo[pid].packet;
drops += streamInfo[pid].drop;
}
return `Dropped Pkts: ${drops} / ${packets}`;
};
const isEmptyStreamInfo = (streamInfo: StreamInfo): boolean => {
if (!streamInfo) {
return true;
}
return Object.keys(streamInfo).length === 0;
};
// --- Status Section ---
const StatusSection: React.FC<{ status: Status }> = ({ status }) => {
if (!status) {
return <Spinner size={20} />;
}
const dockerStat = status.process?.env?.DOCKER === "YES" ? " 🐋" : "";
const items: { label: string; text: string }[] = [
{ label: "Platform", text: `${status.process?.platform} (${status.process?.arch})${dockerStat}` },
{ label: "Rust Version", text: status.process?.versions?.rust },
{ label: "Memory (RSS)", text: `${Math.round(status.process?.memoryUsage?.rss / 1024 / 1024)} MB` },
{ label: "EPG Gathering Network IDs", text: status.epg.gatheringNetworks.map(id => `0x${id.toString(16).toUpperCase()}`).join(", ") || "-" },
{ label: "EPG Stored Events", text: `${status.epg.storedEvents} Events` },
{ label: "TunerDevice Streams", text: `${status.streamCount.tunerDevice}` },
{ label: "TSFilter Streams", text: `${status.streamCount.tsFilter}` },
{ label: "Decoder Streams", text: `${status.streamCount.decoder}` },
{ label: "RPC Connections", text: `${status.rpcCount}` }
];
return (
<div className="status-grid">
{items.map((item, i) => (
<div key={i} className="status-item">
<span className="status-label">{item.label}</span>
<span className="status-value">{item.text}</span>
</div>
))}
</div>
);
};
// --- Services Section ---
const ServicesSection: React.FC<{
status: Status;
services: Service[];
allowPNA: boolean;
tsplayEndpoint: string;
}> = ({ status, services, allowPNA, tsplayEndpoint }) => {
const [showDTV, setShowDTV] = useState<boolean>(true);
const [showData, setShowData] = useState<boolean>(false);
const [showOthers, setShowOthers] = useState<boolean>(false);
const filteredServices = services.filter(service => {
if (service.type === 0x01 || service.type === 0xAD) {
return showDTV;
} else if (service.type === 0xC0) {
return showData;
}
return showOthers;
});
return (
<>
<div className="service-filters">
<Checkbox
label="DTV"
checked={showDTV}
onChange={() => setShowDTV(!showDTV)}
inline
/>
<Checkbox
label="Data"
checked={showData}
onChange={() => setShowData(!showData)}
inline
/>
<Checkbox
label="Others"
checked={showOthers}
onChange={() => setShowOthers(!showOthers)}
inline
/>
</div>
<div className="service-grid">
{filteredServices.map((service) => (
<Tooltip
key={service.id}
content={
<div className="service-tooltip">
<div>#{service.id}</div>
<div>SID: 0x{service.serviceId.toString(16).toUpperCase()} ({service.serviceId})</div>
<div>NID: 0x{service.networkId.toString(16).toUpperCase()} ({service.networkId})</div>
<div>Type: 0x{service.type.toString(16).toUpperCase()} ({service.type})</div>
<div>Channel: {service.channel?.type} / {service.channel?.channel}</div>
</div>
}
placement="bottom"
hoverOpenDelay={300}
>
<div className="service-item">
<Link className="service-item-main" to={`/epg/services/${service.id}`}>
{service.hasLogoData && (
<img
className="service-logo"
src={`/api/services/${service.id}/logo`}
alt=""
/>
)}
<span className="service-name">{service.name}</span>
<span className="service-epg-status">
{
status?.epg.gatheringNetworks.includes(service.networkId) && <Icon icon="refresh" className="color-warning" size={12} /> ||
service.epgReady && <Icon icon="tick" className="color-epg-ready" size={12} /> ||
<Icon icon="time" className="bp5-text-muted" size={12} />
}
</span>
</Link>
{service.type === 0x01 && allowPNA && tsplayEndpoint && (
<span
className="service-play"
onClick={(e) => {
e.stopPropagation();
window.open(
`${tsplayEndpoint}#${location.protocol}//${location.host}/api/services/${service.id}/stream?decode=1`,
"_blank",
"popup"
);
}}
title="TSPlay (Experimental)"
>
<Icon icon="play" intent="primary" size={12} />
</span>
)}
</div>
</Tooltip>
))}
</div>
</>
);
};
// --- Stream Info Table (for dialog) ---
const StreamInfoTable: React.FC<{
userId: string;
tuners: TunerDevice[];
initialInfo: StreamInfo;
}> = ({ userId, tuners, initialInfo }) => {
let currentInfo = initialInfo;
for (const tuner of tuners) {
const user = tuner.users.find(u => u.id === userId);
if (user?.streamInfo) {
currentInfo = user.streamInfo;
break;
}
}
const entries = Object.entries(currentInfo || {});
if (entries.length === 0) {
return <NonIdealState icon="info-sign" description="No stream info available." />;
}
return (
<table className="bp5-html-table bp5-html-table-striped bp5-html-table-condensed stream-info-table">
<thead>
<tr>
<th>PID</th>
<th className="numeric">Packets</th>
<th className="numeric">Drops</th>
</tr>
</thead>
<tbody>
{entries.map(([pid, data]) => (
<tr key={pid}>
<td>{pid}</td>
<td className="numeric">{data.packet.toLocaleString()}</td>
<td className={`numeric${data.drop > 0 ? " color-danger" : ""}`}>{data.drop.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
};
// --- Tuners Section ---
const TunersSection: React.FC<{
tuners: TunerDevice[];
}> = ({ tuners }) => {
const [killTarget, setKillTarget] = useState<number>(null);
const [tunersEx, setTunersEx] = useState<TunerDevice[]>([]);
const [streamDetail, setStreamDetail] = useState<{ userId: string; info: StreamInfo }>(null);
// get streamInfo periodically
useEffect(() => {
const interval = setInterval(async () => {
if (document.hidden) {
return;
}
try {
const result = await (await fetch("/api/tuners")).json();
setTunersEx(result);
} catch (e) {
console.warn(e);
}
}, 1000 * 5);
return () => clearInterval(interval);
}, []);
// merge streamInfo from tunersEx into tuners
const mergedTuners = tuners.map(tuner => {
const tunerEx = tunersEx.find(t => t.index === tuner.index);
if (tunerEx) {
return {
...tuner,
users: tuner.users.map(user => {
const userEx = tunerEx.users.find(u => u.id === user.id);
if (userEx?.streamInfo) {
return { ...user, streamInfo: userEx.streamInfo };
}
return user;
})
};
}
return tuner;
});
const treeNodes: TreeNodeInfo[] = mergedTuners.map((tuner) => {
const tunerLabel = `#${tuner.index}: ${tuner.name} (${tuner.types.join(", ")})`;
const hasUsers = tuner.users.length > 0;
let tunerIcon: TreeNodeInfo["icon"];
if (tuner.isFault) {
tunerIcon = <Icon icon="error" intent="danger" />;
} else if (!tuner.isAvailable) {
tunerIcon = <Icon icon="disable" className="bp5-text-muted" />;
} else if (tuner.isUsing) {
tunerIcon = <Icon icon="dot" className="color-epg-ready" />;
} else {
tunerIcon = <Icon icon="dot" className="bp5-text-muted" />;
}
const childNodes: TreeNodeInfo[] = [];
// device info node
if (tuner.command || tuner.pid) {
childNodes.push({
id: `tuner-${tuner.index}-device`,
icon: <Icon icon="console" className="bp5-text-muted" />,
label: (
<span className="tuner-device-info">
<span>{tuner.command || "-"}</span>
{tuner.pid ? <span className="bp5-text-muted"> (pid={tuner.pid})</span> : null}
{tuner.command && (
<Button
variant="minimal"
icon="cross"
intent="danger"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setKillTarget(tuner.index);
}}
title="Kill Tuner Process..."
/>
)}
</span>
),
hasCaret: false
});
}
// user nodes
for (let i = 0; i < tuner.users.length; i++) {
const user = tuner.users[i];
const isMirakurun = /Mirakurun/.test(user.id);
const userInfoItems: JSX.Element[] = [
<span key="priority" className="tuner-user-info-item">
<Icon icon="sort" className="bp5-text-muted" size={12} />
<span>{user.priority}</span>
</span>,
<span key="user" className="tuner-user-info-item">
<Icon icon={isMirakurun ? "cog" : "person"} className="bp5-text-muted" size={12} />
<span>{user.id}</span>
</span>,
<span key="ch" className="tuner-user-info-item">
<Icon icon="mobile-video" className="bp5-text-muted" size={12} />
<span>{user.streamSetting?.channel?.type} / {user.streamSetting?.channel?.channel}</span>
</span>,
<span key="sid" className="tuner-user-info-item">
<Icon icon="filter" className="bp5-text-muted" size={12} />
<span>{user.streamSetting?.serviceId ? `0x${user.streamSetting.serviceId.toString(16).toUpperCase()} (${user.streamSetting.serviceId})` : "-"}</span>
</span>
];
// stream info
if (!isEmptyStreamInfo(user.streamInfo)) {
userInfoItems.push(
<span key="stream" className="tuner-user-info-item">
<Icon icon="cube" className="bp5-text-muted" size={12} />
<a
className="stream-info-link"
onClick={(e) => {
e.stopPropagation();
setStreamDetail({ userId: user.id, info: user.streamInfo });
}}
>
{summarizeStreamInfo(user.streamInfo)}
</a>
</span>
);
}
childNodes.push({
id: `tuner-${tuner.index}-user-${i}`,
label: <span className="tuner-user-info">{userInfoItems}</span>,
hasCaret: false
});
}
return {
id: `tuner-${tuner.index}`,
icon: tunerIcon,
label: <span className="tuner-label">{tunerLabel}</span>,
isExpanded: hasUsers || !!tuner.command,
childNodes: childNodes.length > 0 ? childNodes : undefined,
hasCaret: childNodes.length > 0
};
});
const handleNodeCollapse = useCallback((_node: TreeNodeInfo) => {
// Tree is stateless; for now we allow expand/collapse via Tree's own behavior
}, []);
const handleNodeExpand = useCallback((_node: TreeNodeInfo) => {
// Tree is stateless
}, []);
return (
<>
{mergedTuners.length === 0 ? (
<Spinner size={20} />
) : (
<Tree
contents={treeNodes}
onNodeCollapse={handleNodeCollapse}
onNodeExpand={handleNodeExpand}
className="tuner-tree"
/>
)}
{/* Kill Tuner Process Dialog */}
<Dialog
isOpen={killTarget !== null}
onClose={() => setKillTarget(null)}
title="Kill Tuner Process"
icon="warning-sign"
>
<DialogBody>
<p>Do you want to kill this running tuner process?</p>
</DialogBody>
<DialogFooter
actions={
<>
<Button
text="Cancel"
onClick={() => setKillTarget(null)}
/>
<Button
intent="danger"
text="Kill"
onClick={() => {
(async () => {
await fetch(`/api/tuners/${killTarget}/process`, { method: "DELETE" });
})();
setKillTarget(null);
}}
/>
</>
}
/>
</Dialog>
{/* Stream Info Detail Dialog */}
<Dialog
isOpen={!!streamDetail}
onClose={() => setStreamDetail(null)}
title="Stream Info"
icon="cube"
style={{ width: 500 }}
>
<DialogBody>
{streamDetail && (
<>
<p className="bp5-text-muted">{streamDetail.userId}</p>
<StreamInfoTable
userId={streamDetail.userId}
tuners={tunersEx}
initialInfo={streamDetail.info}
/>
</>
)}
</DialogBody>
<DialogFooter
actions={
<Button
text="Close"
onClick={() => setStreamDetail(null)}
/>
}
/>
</Dialog>
</>
);
};
// --- HomeView ---
export const HomeView: React.FC = () => {
console.debug("routes", "HomeView");
ui.setTitle("Home");
const [status, setStatus] = useState<Status>(state.status);
const [services, setServices] = useState<Service[]>(state.services);
const [tuners, setTuners] = useState<TunerDevice[]>(state.tuners);
const [allowPNA, setAllowPNA] = useState<boolean>(false);
const [tsplayEndpoint, setTsplayEndpoint] = useState<string>("");
useEffect(() => {
// fetch server config
(async () => {
try {
if (!state.serverConfig) {
await state.fetchServerConfig();
}
if (state.serverConfig) {
setAllowPNA(state.serverConfig.allowPNA);
setTsplayEndpoint(state.serverConfig.tsplayEndpoint);
}
} catch (e) {
console.error(e);
}
})();
}, []);
useEffect(() => {
const onStatus = () => {
setStatus({ ...state.status });
};
state.on("status", onStatus);
const onServices = () => {
setServices([...state.services]);
};
state.on("services", onServices);
const onTuners = () => {
setTuners([...state.tuners]);
};
state.on("tuners", onTuners);
return () => {
state.off("status", onStatus);
state.off("services", onServices);
state.off("tuners", onTuners);
};
}, []);
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "Home"
}
]} />
</Navbar.Heading>
</Navbar.Group>
</Navbar>
);
return (
<div className="route" id="route-home-view">
{toolbar}
<div className="content">
<div className="home-container">
<Section className="home-section" title="Status" icon="dashboard" compact>
<div className="home-section-content">
<StatusSection status={status} />
</div>
</Section>
<Section
className="home-section"
title={`Services${services.length > 0 ? ` (${services.length})` : ""}`}
icon="globe-network"
compact
>
<div className="home-section-content">
<ServicesSection
status={status}
services={services}
allowPNA={allowPNA}
tsplayEndpoint={tsplayEndpoint}
/>
</div>
</Section>
<Section
className="home-section"
title={`Tuners${tuners.length > 0 ? ` (${tuners.length})` : ""}`}
icon="antenna"
compact
>
<div className="home-section-content">
<TunersSection tuners={tuners} />
</div>
</Section>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-jobs-view
.content
display: flex
flex-direction: column
gap: 20px
> *
flex-shrink: 0
.bp5-section
background: rgba(colors.$light-gray5, 0.2)
.bp5-dark &
background: rgba(colors.$black, 0.2)
.bp5-section-header
.bp5-dark &
&:hover,
&:active
background: rgba(colors.$black, 0.1)
.bp5-collapse-body
display: flex
flex-direction: column
gap: 10px
padding: 10px
.bp5-navbar,
.bp5-navbar-group
height: 30px
.bp5-navbar
box-shadow: none
background: none
padding: 0 5px
.bp5-navbar-group
gap: 10px
> span
opacity: 0.75
&:hover .bp5-navbar-group > span
opacity: 1

510
web/src/routes/JobsView.tsx Normal file
View File

@@ -0,0 +1,510 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Alignment, Spinner, Breadcrumbs, Navbar, NonIdealState, NonIdealStateProps, Section, Button, Dialog, DialogBody, DialogFooter, Tooltip, Icon } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { useLocalStorageState } from "../hooks/useWebStorageState";
import { LazyCaller } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { JobScheduleItem, JobItem, Error as ApiError } from "../../../api.d";
import "./JobsView.sass";
export const JobsView: React.FC = () => {
console.debug("routes", "JobsView");
const [nonIdealState, setNonIdealState] = useState<NonIdealStateProps | null>(null);
const [reload, setReload] = useState<number>(0);
const [jobScheduleIsOpen, setJobScheduleIsOpen] = useLocalStorageState<boolean>("JobsView.jobScheduleIsOpen", true);
const [queuedIsOpen, setQueuedIsOpen] = useLocalStorageState<boolean>("JobsView.queuedIsOpen", true);
const [standbyIsOpen, setStandbyIsOpen] = useLocalStorageState<boolean>("JobsView.standbyIsOpen", true);
const [runningIsOpen, setRunningIsOpen] = useLocalStorageState<boolean>("JobsView.runningIsOpen", true);
const [finishedIsOpen, setFinishedIsOpen] = useLocalStorageState<boolean>("JobsView.finishedIsOpen", true);
const [jobScheduleItems, setJobScheduleItems] = useState<JSX.Element[]>([]);
const [queuedJobItems, setQueuedJobItems] = useState<JSX.Element[]>([]);
const [standbyJobItems, setStandbyJobItems] = useState<JSX.Element[]>([]);
const [runningJobItems, setRunningJobItems] = useState<JSX.Element[]>([]);
const [finishedJobItems, setFinishedJobItems] = useState<JSX.Element[]>([]);
const [title, setTitle] = useState<string>("ジョブ");
// const isLoading = !programs && !error;
// Action dialog state
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [dialogType, setDialogType] = useState<"run_schedule" | "abort_job" | "rerun_job" | null>(null);
const [selectedScheduleKey, setSelectedScheduleKey] = useState<string | null>(null);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [isActionLoading, setIsActionLoading] = useState<boolean>(false);
const [actionError, setActionError] = useState<string | null>(null);
const isLoading = !state.jobs && !state.jobSchedules;
ui.setTitle(title, isLoading);
// API handlers for job operations
const runJobSchedule = async (key: string): Promise<boolean> => {
try {
const res = await fetch(`/api/job-schedules/${encodeURIComponent(key)}/run`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errorData = await res.json() as ApiError;
setActionError(errorData.reason || `Error: ${res.status}`);
return false;
}
// Re-fetch jobs and job schedules
await state.fetchJobs();
await state.fetchJobSchedules();
setActionError(null);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`リクエスト失敗: ${message}`);
return false;
}
};
const abortJob = async (jobId: string): Promise<boolean> => {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/abort`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errorData = await res.json() as ApiError;
setActionError(errorData.reason || `Error: ${res.status}`);
return false;
}
// Re-fetch jobs
await state.fetchJobs();
setActionError(null);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`リクエスト失敗: ${message}`);
return false;
}
};
const rerunJob = async (jobId: string): Promise<boolean> => {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/rerun`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errorData = await res.json() as ApiError;
setActionError(errorData.reason || `Error: ${res.status}`);
return false;
}
// Re-fetch jobs
await state.fetchJobs();
setActionError(null);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`リクエスト失敗: ${message}`);
return false;
}
};
// Dialog action handler
const handleConfirm = async () => {
setIsActionLoading(true);
let success = false;
try {
if (dialogType === "run_schedule" && selectedScheduleKey) {
success = await runJobSchedule(selectedScheduleKey);
} else if (dialogType === "abort_job" && selectedJobId) {
success = await abortJob(selectedJobId);
} else if (dialogType === "rerun_job" && selectedJobId) {
success = await rerunJob(selectedJobId);
}
if (success) {
setIsDialogOpen(false);
setDialogType(null);
setSelectedScheduleKey(null);
setSelectedJobId(null);
}
} finally {
setIsActionLoading(false);
}
};
const openDialog = (type: "run_schedule" | "abort_job" | "rerun_job", key?: string) => {
setActionError(null);
setDialogType(type);
if (type === "run_schedule" && key) {
setSelectedScheduleKey(key);
} else if (type !== "run_schedule" && key) {
setSelectedJobId(key);
}
setIsDialogOpen(true);
};
useEffect(() => {
const onUpdated = () => {
setReload(Date.now());
};
const onUpdatedLazy = new LazyCaller(0, 500, onUpdated);
state.on("jobs", onUpdatedLazy.caller);
state.on("jobSchedules", onUpdatedLazy.caller);
return () => {
state.off("jobs", onUpdatedLazy.caller);
state.off("jobSchedules", onUpdatedLazy.caller);
onUpdatedLazy.destroy();
}
}, []);
useEffect(() => {
if (isLoading) {
setTitle("ジョブ...");
setNonIdealState({
icon: <Spinner />,
title: "ロード中",
description: "ジョブを読み込んでいます..."
});
return;
}
setJobScheduleItems(state.jobSchedules.map(jobSchedule => createJobScheduleItemElement(jobSchedule)));
setQueuedJobItems(state.jobs.filter(job => job.status === "queued").map(job => createJobItemElement(job)));
setStandbyJobItems(state.jobs.filter(job => job.status === "standby").map(job => createJobItemElement(job)));
setRunningJobItems(state.jobs.filter(job => job.status === "running").map(job => createJobItemElement(job)));
setFinishedJobItems(state.jobs.filter(job => job.status === "finished").map(job => createJobItemElement(job)));
setNonIdealState(null);
return () => {
setNonIdealState(null);
};
}, [reload]);
// Helper functions to create job/schedule items with closures over openDialog
const createJobScheduleItemElement = (jobSchedule: JobScheduleItem) => {
return (
<Navbar key={jobSchedule.key}>
<Navbar.Group align={Alignment.START}>
<code className="bp5-code">
{jobSchedule.schedule}
</code>
<span>
{jobSchedule.job.name}
</span>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
variant="minimal"
intent="warning"
icon="play"
text="実行..."
onClick={() => openDialog("run_schedule", jobSchedule.key)}
/>
</Navbar.Group>
</Navbar>
);
};
const createJobItemElement = (job: JobItem) => {
const statusLabel = getJobStatusLabel(job);
const statusIcon = getJobStatusIcon(job);
const statusIntent = getJobStatusIntent(job);
// Build detail tooltip content
const detailLines: string[] = [
`ID: ${job.id}`,
`Key: ${job.key}`
];
if (job.retryMax) {
detailLines.push(`リトライ: ${job.retryCount}/${job.retryMax}`);
}
if (job.startedAt) {
detailLines.push(`開始: ${DateTime.fromMillis(job.startedAt).toFormat("yyyy/MM/dd HH:mm:ss")}`);
}
if (job.finishedAt) {
detailLines.push(`終了: ${DateTime.fromMillis(job.finishedAt).toFormat("yyyy/MM/dd HH:mm:ss")}`);
}
if (job.duration) {
const durationSec = Math.round(job.duration / 1000);
detailLines.push(`実行時間: ${durationSec}`);
}
if (job.hasFailed && job.error) {
detailLines.push(`エラー: ${job.error}`);
}
if (job.hasAborted) {
detailLines.push("状態: 中止済み");
}
if (job.hasSkipped) {
detailLines.push("状態: スキップ");
}
const detailTooltip = detailLines.join("\n");
return (
<Navbar key={job.id}>
<Navbar.Group align={Alignment.START}>
<Tooltip content={detailTooltip} position="right">
<span className="bp5-text-muted" style={{ cursor: "help" }} title="詳細">
{job.id.split(".").slice(-1)[0]}
</span>
</Tooltip>
<span title={job.key} style={{ marginLeft: "0.5rem" }}>
{job.name}
</span>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<span className="bp5-text-muted" style={{ marginLeft: "0.5rem" }}>
<Icon icon={statusIcon} intent={statusIntent} />
<span style={{ marginLeft: "0.35rem" }}>{statusLabel}</span>
</span>
<Tooltip content={DateTime.fromMillis(job.updatedAt).toFormat("yyyy/MM/dd HH:mm:ss")}>
<span className="bp5-text-muted">
{DateTime.fromMillis(job.updatedAt).toRelative()}
</span>
</Tooltip>
{job.status !== "finished" && (
<Button
minimal
small
icon="stop"
intent="danger"
onClick={() => openDialog("abort_job", job.id)}
title="ジョブを中止リクエスト"
disabled={job.isAborting}
/>
)}
{job.status === "finished" && (
<Button
minimal
small
icon="refresh"
intent="primary"
onClick={() => openDialog("rerun_job", job.id)}
title="ジョブを再実行"
style={{ visibility: job.isRerunnable ? undefined : "hidden" }}
/>
)}
</Navbar.Group>
</Navbar>
);
};
return (
<div className="route" id="route-jobs-view">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "ジョブ"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
</Navbar.Group>
</Navbar>
<div className="content">
{!nonIdealState && <>
<Section
title="スケジュール"
icon="time"
collapsible
collapseProps={{
isOpen: jobScheduleIsOpen,
onToggle: () => setJobScheduleIsOpen(!jobScheduleIsOpen),
}}
compact
>
{jobScheduleItems.map((item) => item)}
</Section>
<Section
title="ジョブ"
icon="ninja"
collapsible
compact
>
{queuedJobItems.length > 0 && (
<Section
title={`queued (${queuedJobItems.length})`}
icon="time"
collapsible
collapseProps={{
isOpen: queuedIsOpen,
onToggle: () => setQueuedIsOpen(!queuedIsOpen),
}}
compact
>
{queuedJobItems.map((item) => item)}
</Section>
)}
{standbyJobItems.length > 0 && (
<Section
title={`standby (${standbyJobItems.length})`}
icon="stopwatch"
collapsible
collapseProps={{
isOpen: standbyIsOpen,
onToggle: () => setStandbyIsOpen(!standbyIsOpen),
}}
compact
>
{standbyJobItems.map((item) => item)}
</Section>
)}
{runningJobItems.length > 0 && (
<Section
title={`running (${runningJobItems.length})`}
icon="play"
collapsible
collapseProps={{
isOpen: runningIsOpen,
onToggle: () => setRunningIsOpen(!runningIsOpen),
}}
compact
>
{runningJobItems.map((item) => item)}
</Section>
)}
{finishedJobItems.length > 0 && (
<Section
title={`finished (${finishedJobItems.length})`}
icon="tick"
collapsible
collapseProps={{
isOpen: finishedIsOpen,
onToggle: () => setFinishedIsOpen(!finishedIsOpen),
}}
compact
>
{finishedJobItems.map((item) => item)}
</Section>
)}
</Section>
</>}
{nonIdealState && <>
<NonIdealState {...nonIdealState} />
</>}
</div>
{/* Confirmation Dialog */}
<Dialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
title={
dialogType === "run_schedule" ? "スケジュール実行" :
dialogType === "abort_job" ? "ジョブ中止" :
dialogType === "rerun_job" ? "ジョブ再実行" :
"確認"
}
canEscapeKeyClose={!isActionLoading}
>
<DialogBody>
{actionError && (
<div className="bp5-text-intent-danger" style={{ marginBottom: "16px" }}>
{actionError}
</div>
)}
<div>
{dialogType === "run_schedule" && "このスケジュールのジョブを実行してもよろしいですか?"}
{dialogType === "abort_job" && "このジョブの中止をリクエストしてもよろしいですか?"}
{dialogType === "rerun_job" && "このジョブを再実行してもよろしいですか?"}
</div>
</DialogBody>
<DialogFooter
actions={
<>
<Button text="キャンセル" onClick={() => setIsDialogOpen(false)} disabled={isActionLoading} />
<Button text="実行" intent="primary" onClick={handleConfirm} loading={isActionLoading} />
</>
}
/>
</Dialog>
</div>
);
};
function getJobStatusLabel(job: JobItem): string {
if (job.status === "queued") {
return "Queued...";
}
if (job.status === "standby") {
return "Standby...";
}
if (job.status === "running") {
return "Running...";
}
if (job.hasFailed) {
return `Failed${job.duration ? ` (${Math.round(job.duration / 1000)}s)` : ""}`;
}
if (job.hasAborted) {
return "Aborted";
}
if (job.hasSkipped) {
return "Skipped";
}
return `Finished${job.duration ? ` (${Math.round(job.duration / 1000)}s)` : ""}`;
}
function getJobStatusIcon(job: JobItem): any {
if (job.status === "queued") return "time";
if (job.status === "standby") return "stopwatch";
if (job.status === "running") return "play";
if (job.hasFailed) return "error";
if (job.hasAborted) return "cross";
if (job.hasSkipped) return "disable";
return "tick";
}
function getJobStatusIntent(job: JobItem): "none" | "primary" | "success" | "warning" | "danger" {
if (job.status === "running") return "primary";
if (job.status === "standby") return "warning";
if (job.hasFailed) return "danger";
if (job.hasAborted) return "warning";
return "success";
}

View File

@@ -0,0 +1,44 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-logs-view
position: absolute
top: 0
right: 0
bottom: 0
left: 0
overflow: scroll
overflow-x: hidden
background: colors.$dark-gray2
.logs
padding: 8px 0
font-family: 'Courier New', Courier, monospace
font-size: 12px
color: colors.$gray3
> div
padding: 2px 16px
word-break: break-all
white-space: break-spaces
&:hover
background: colors.$dark-gray1
> div.latest
padding: 0
> div.level-debug
color: colors.$indigo5
> div.level-info
color: colors.$light-gray1
> div.level-warn
color: colors.$orange3
> div.level-error
color: colors.$red4
> div.level-fatal
color: colors.$white
background: colors.$red3

104
web/src/routes/LogsView.tsx Normal file
View File

@@ -0,0 +1,104 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect, useRef } from "react";
import { Client as RPCClient } from "jsonrpc2-ws";
import { JoinParams } from "../../types/rpc";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import "./LogsView.sass";
let _itemId = 0;
let logListCache: JSX.Element[] = [];
export const LogsView: React.FC = () => {
console.debug("routes", "LogsView");
ui.setTitle("ログ");
const [logList, setLogList] = useState<JSX.Element[]>([]);
const latestRef = useRef<HTMLDivElement>(null);
const onLogs = (lines: string[], unshift: boolean) => {
const newList: JSX.Element[] = [];
for (const line of lines) {
const parsed = line.match(/^[0-9.T:+-]+ ([a-z]+): /);
const level = parsed ? parsed[1] : "other";
newList.push(
<div key={`logs-list-item${_itemId}`} className={`level-${level}`}>
{line}
</div>
);
++_itemId;
}
if (unshift === true) {
logListCache = [...newList, ...logListCache].slice(-500);
} else {
logListCache = [...logListCache, ...newList].slice(-500);
}
setLogList(logListCache);
};
useEffect(() => {
const rpc = (state as any)._rpc as RPCClient;
const join = () => {
rpc.call("join", { rooms: ["logs"] } as JoinParams);
};
rpc.on("connected", join);
// 既に接続済みなら即座に join、そうでなければ connected イベントを待つ
if (rpc.isConnected()) {
join();
}
// 初期ログを取得
(async () => {
const lines: string = await (await fetch("/api/log")).text();
onLogs(lines.trim().split("\n"), true);
})();
// state の logs イベントをサブスクライブ
const onLogsEvent = (lines: string[], unshift: boolean) => {
onLogs(lines, unshift);
};
state.on("logs", onLogsEvent);
return () => {
rpc.off("connected", join);
if (rpc.isConnected()) {
rpc.call("leave", { rooms: ["logs"] } as JoinParams);
}
state.off("logs", onLogsEvent);
logListCache = [];
};
}, []);
useEffect(() => {
latestRef.current?.scrollIntoView();
});
return (
<div id="route-logs-view">
<div className="logs">
{logList}
<div className="latest" ref={latestRef}></div>
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-program-view
.content
> *
margin: 20px 0
&:first-child
margin-top: 0
> .flex
display: flex
gap: 15px
align-items: center
> .component-date-time-range
color: colors.$gray2
.bp5-dark &
color: colors.$gray4
> p,
> .extended > p
max-width: 650px
white-space: pre-wrap
font-feature-settings: "palt" 1
> .extended
h4
font-size: 13px
font-weight: 600
margin: 15px 0 10px
p
margin-left: 15px
h4 + p
margin-top: 10px
> p.meta
font-size: 11px
opacity: 0.5

View File

@@ -0,0 +1,209 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { Alignment, Button, Breadcrumbs, Navbar, NonIdealState } from "@blueprintjs/core";
import { DateTime } from "luxon";
import { getGlobalServiceId, getIdWithHex } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import * as regexp from "../modules/regexp";
import { clearSchedule, setSchedule } from "../modules/at";
import { Error, Program } from "../../../api.d";
import { ProgramTitle } from "../components/ProgramTitle";
import { WatchButton } from "../components/WatchButton";
import { DateTimeRange } from "../components/DateTimeRange";
import { ServiceLink } from "../components/ServiceLink";
import { ProgramGenres } from "../components/ProgramGenres";
import { ProgramAVInfo } from "../components/ProgramAVInfo";
import { ProgramRelatedLinks } from "../components/ProgramRelatedLinks";
import "./ProgramView.sass";
export const ProgramView: React.FC = () => {
console.debug("routes", "ProgramView");
const [reload, setReload] = useState(Date.now()); // リロード用
const [error, setError] = useState<Error>(null);
const [program, setProgram] = useState<Program>(null);
const { navigate } = state;
const params = useParams();
const programId = parseInt(params.programId, 10);
const now = Date.now();
const startTime = program?.startAt;
const endTime = program ? program.startAt + program.duration : null;
const isLoading = program === null && error === null;
const isOnAir = program && startTime <= now && endTime >= now;
const date = program && DateTime.fromMillis(program.startAt).set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
const time = program && DateTime.fromMillis(program.startAt).diff(date).toMillis();
const timeForServiceLink = (time > (1000 * 60 * 60 * 24 - 1000 * 60 * 5)) ? 1 : time;
useEffect(() => {
const onPrograms = (programs: Program[]) => {
const _program = programs.find(p => p.id === programId);
if (!_program) {
setError({
code: 404,
reason: "番組が見つかりません",
});
setProgram(null);
return;
}
setError(null);
setProgram(_program);
};
state.on("programs", onPrograms);
state.subscribePrograms(true);
return () => {
// // unsubscribe せずに差分更新を継続する
// state.unsubscribePrograms();
state.off("programs", onPrograms);
}
}, [programId]);
useEffect(() => {
if (!program) {
ui.setTitle("番組詳細...", true);
return;
}
ui.setTitle(program.name);
const schedules: ReturnType<typeof setSchedule>[] = [];
if (now <= startTime) {
schedules.push(setSchedule(startTime, () => setReload(Date.now())));
}
if (now <= endTime) {
schedules.push(setSchedule(endTime, () => setReload(Date.now())));
}
console.debug("ProgramView", program);
return () => {
for (const id of schedules) {
clearSchedule(id);
}
};
}, [program]);
return (
<div className="route" id="route-program-view">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "EPG",
onClick: () => {
navigate(`/epg?date=${date.toISODate()}&time=${time}`)
}
},
{
className: isLoading ? "bp5-skeleton" : "",
text: isLoading ? "Loading................................." : (error ? "エラー" : (
program ? <ProgramTitle program={program} /> : <></>
))
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
{isLoading && <>
<Button className="bp5-skeleton" text="Loading............................" />
</>}
{program && <>
{isOnAir && <WatchButton variant="outlined" popoverPlacement="bottom-end" globalServiceId={getGlobalServiceId(program.networkId, program.serviceId)} />}
</>}
<Button
variant="outlined"
intent="primary"
icon="timeline-events"
text="番組表で表示"
onClick={() => {
let to = "/epg";
if (program) {
to += `?date=${date.toISODate()}`;
if (time) {
to += `&time=${time}`;
}
to += `&programId=${program.id}`;
}
navigate(to);
}}
/>
</Navbar.Group>
</Navbar>
<div className="content">
{program && <>
<div className="flex">
<ServiceLink
globalId={getGlobalServiceId(program.networkId, program.serviceId)}
date={date.toISODate()}
time={timeForServiceLink}
/>
<DateTimeRange start={program.startAt} end={program.startAt + program.duration} />
</div>
{program.description && (
<p className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</p>
)}
{program.extended && Object.entries(program.extended).map(([head, body]) => {
return <div className="extended" key={head}>
<h4>{head}</h4>
<p dangerouslySetInnerHTML={{ __html: ui.autoLink(body.trim()) }} />
</div>;
})}
{program.genres?.length > 0 && (
<ProgramGenres genres={program.genres} />
)}
{(program.video || program.audios) && (
<ProgramAVInfo video={program.video} audios={program.audios} />
)}
<p className="meta">
Program ID: {program.id}<br />
event_id: {getIdWithHex(program.eventId)}<br />
SID: {getIdWithHex(program.serviceId)}<br />
NID: {getIdWithHex(program.networkId)}
</p>
<ProgramRelatedLinks program={program} />
</>}
{error && <>
<NonIdealState
icon="warning-sign"
title={`${error.code} Error`}
description={error.reason || "エラーが発生しました"}
/>
</>}
</div>
</div>
);
};

View File

@@ -0,0 +1,21 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-search-view
.content
display: flex
flex-direction: column
gap: 15px
> .bp5-card
max-width: 680px
background-color: colors.$light-gray4
.bp5-dark &
background-color: colors.$dark-gray4
.component-program-title
font-size: 18px
line-height: 1.3em
.name
font-weight: 400

View File

@@ -0,0 +1,160 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import { Alignment, Spinner, Breadcrumbs, Navbar, NonIdealState, NonIdealStateProps, Card } from "@blueprintjs/core";
import { LazyCaller, textMatch, normalizeText } from "../modules/common";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { Program } from "../../../api.d";
import { ProgramCardBase } from "../components/ProgramCardBase";
import "./SearchView.sass";
export const SearchView: React.FC = () => {
console.debug("routes", "SearchView");
const [nonIdealState, setNonIdealState] = useState<NonIdealStateProps>(null);
const [programs, setPrograms] = useState<Program[]>(null);
const [result, setResult] = useState<JSX.Element[]>([]);
const [title, setTitle] = useState<string>("検索");
// const isLoading = !programs && !error;
const { navigate, searchParams } = state;
const query = searchParams.get("q") || null;
const isLoading = query && !programs;
ui.setTitle(title, isLoading);
useEffect(() => {
const onPrograms = () => {
const _programs = state.programs.filter(program => {
// 共有イベントを除外
if (program.relatedItems?.filter(item => item.type === "shared").length === 1) {
return false;
}
return true;
});
setPrograms(_programs);
};
const onProgramsLazy = new LazyCaller(0, 1000, onPrograms);
state.on("programs", onProgramsLazy.caller);
state.subscribePrograms(true);
return () => {
// // unsubscribe せずに差分更新を継続する
// state.unsubscribePrograms();
state.off("programs", onProgramsLazy.caller);
onProgramsLazy.destroy();
}
}, []);
useEffect(() => {
if (!query) {
setTitle("検索");
setResult([]);
setNonIdealState({
icon: "search",
title: "検索キーワードを入力してください",
description: "番組名、番組説明、サービス名、ジャンルなどで検索できます"
});
return;
}
if (!programs) {
setTitle("検索...");
setNonIdealState({
icon: <Spinner />,
title: "ロード中",
description: "番組一覧を読み込んでいます..."
});
return;
}
const q = normalizeText(query.trim()).toLowerCase();
const filteredPrograms: Program[] = [];
for (const p of programs) {
if (
(p.name && textMatch(p.name, q)) ||
(p.description && textMatch(p.description, q)) ||
(p.extended && textMatch(Object.entries(p.extended).flat().join(" "), q))
) {
filteredPrograms.push(p);
continue;
}
}
filteredPrograms.sort((a, b) => {
return a.startAt - b.startAt;
});
const _result: JSX.Element[] = filteredPrograms.map(createResultItem);
setTitle(`検索 "${query}" (${_result.length}件)`);
setResult(_result);
setNonIdealState(null);
return () => {
setNonIdealState(null);
};
}, [programs, query]);
return (
<div className="route" id="route-search-view">
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "EPG",
onClick: () => navigate("/epg")
},
{
text: "検索",
className: `heading-title ${isLoading ? "bp5-skeleton" : ""}`.trim(),
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
</Navbar.Group>
</Navbar>
<div className="content">
{!nonIdealState && result}
{nonIdealState && <>
<NonIdealState {...nonIdealState} />
</>}
</div>
</div>
);
};
function createResultItem(program: Program) {
return (
<Card key={program.id} elevation={0}>
<ProgramCardBase
program={program}
noAVInfo={true}
noActions={true}
/>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-server-config-view
.content
display: flex
flex-direction: column
gap: 16px
padding-top: 20px
.config-section
flex-shrink: 0
background: rgba(colors.$light-gray5, 0.2)
&:last-child
margin-bottom: 20px
.bp5-dark &
background: rgba(colors.$black, 0.2)
.bp5-collapse-body
padding: 0
> .bp5-section-card
padding: 0
.bp5-section-header
min-height: 44px
.bp5-dark &
&:hover,
&:active
background: rgba(colors.$black, 0.1)
.config-form-grid
display: flex
flex-direction: column
.bp5-form-group
padding: 14px 20px 16px
margin: 0
border-top: 1px solid rgba(colors.$light-gray1, 0.65)
.bp5-dark &
border-top-color: rgba(colors.$dark-gray5, 0.7)
.bp5-label
margin-bottom: 5px
font-weight: 600
.bp5-html-select,
.bp5-input-group
width: min(100%, 200px)
.bp5-numeric-input
width: min(100%, 100px)
textarea.bp5-input
width: min(100%, 400px)
min-height: 88px
resize: vertical
.bp5-html-select select,
.bp5-input,
.bp5-numeric-input .bp5-input-group
width: 100%
.bp5-numeric-input
.bp5-button-group
flex-shrink: 0
.bp5-control
display: flex
align-items: center
min-height: 30px
margin-bottom: 0
.bp5-form-helper-text
max-width: 680px
margin-top: 6px
line-height: 1.45
.config-switch-group
.bp5-control
width: fit-content

View File

@@ -0,0 +1,584 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import {
Alignment,
Breadcrumbs,
Button,
Dialog,
DialogBody,
DialogFooter,
FormGroup,
HTMLSelect,
InputGroup,
Intent,
Navbar,
NonIdealState,
NumericInput,
Section,
Spinner,
Switch,
TextArea
} from "@blueprintjs/core";
import equal from "fast-deep-equal";
import { Validator as IPValidator } from "ip-num/Validator";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { ConfigServer, LogLevel } from "../../../api.d";
import "./ServerConfigView.sass";
const configAPI = "/api/config/server";
const multilineConfigValue = (values?: string[] | null) => (values ?? []).join("\n");
const parseMultilineConfigValue = (value: string): string[] | null => {
const trimmedValue = value.trim();
if (trimmedValue === "") {
return null;
}
return trimmedValue.split("\n").map(line => line.trim());
};
export const ServerConfigView: React.FC = () => {
console.debug("routes", "ServerConfigView");
const [current, setCurrent] = useState<ConfigServer | null>(null);
const [editing, setEditing] = useState<ConfigServer | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saved, setSaved] = useState(false);
const [isLoading, setIsLoading] = useState(true);
ui.setTitle("サーバー設定", isLoading);
const [allowIPv4CidrRangesText, setAllowIPv4CidrRangesText] = useState("");
const [allowIPv6CidrRangesText, setAllowIPv6CidrRangesText] = useState("");
const [allowOriginsText, setAllowOriginsText] = useState("");
const syncMultilineConfigValues = (config: ConfigServer) => {
setAllowIPv4CidrRangesText(multilineConfigValue(config.allowIPv4CidrRanges));
setAllowIPv6CidrRangesText(multilineConfigValue(config.allowIPv6CidrRanges));
setAllowOriginsText(multilineConfigValue(config.allowOrigins));
};
useEffect(() => {
if (saved === true) {
setTimeout(() => {
// location.reload();
}, 500);
setSaved(false);
return;
}
(async () => {
try {
const res = await (await fetch(configAPI)).json();
console.log("ServerConfigView", "GET", configAPI, "->", res);
setEditing({ ...res });
setCurrent({ ...res });
syncMultilineConfigValues(res);
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
})();
}, [saved]);
const docker = (state as any).status?.process?.env?.DOCKER === "YES";
const ipv6Ready = docker === false || (state as any).status?.process?.env?.DOCKER_NETWORK === "host";
let invalid = false;
let invalidEpgGatheringJobSchedule = false;
let invalidAllowIPv4CidrRanges = false;
let invalidAllowIPv6CidrRanges = false;
if (editing) {
if (editing.epgGatheringJobSchedule) {
if (!isValidCronExpression(editing.epgGatheringJobSchedule)) {
invalid = true;
invalidEpgGatheringJobSchedule = true;
}
}
if (editing.allowIPv4CidrRanges) {
for (const range of editing.allowIPv4CidrRanges) {
const [valid] = IPValidator.isValidIPv4CidrRange(range);
if (!valid) {
invalid = true;
invalidAllowIPv4CidrRanges = true;
break;
}
}
}
if (!invalid && editing.allowIPv6CidrRanges) {
for (const range of editing.allowIPv6CidrRanges) {
const [valid] = IPValidator.isValidIPv6CidrRange(range);
if (!valid) {
invalid = true;
invalidAllowIPv6CidrRanges = true;
break;
}
}
}
}
const hasChanges = editing !== null && current !== null && !equal(editing, current);
const handleCancel = () => {
if (current) {
setEditing({ ...current });
syncMultilineConfigValues(current);
}
};
const handleSave = async () => {
if (!editing) {
return;
}
setShowSaveDialog(false);
try {
const payload: { [key: string]: any } = { ...editing };
for (const key of Object.keys(payload)) {
if (payload[key] === null) {
delete payload[key];
}
}
console.log("ServerConfigView", "PUT", configAPI, "<-", payload);
await fetch(configAPI, {
method: "PUT",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(payload)
});
setSaved(true);
} catch (err) {
console.error(err);
}
};
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "サーバー設定"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
minimal
intent="danger"
icon="undo"
text="Cancel"
disabled={!hasChanges}
onClick={handleCancel}
/>
<Button
intent="primary"
icon="saved"
text="Save"
disabled={!hasChanges || invalid}
onClick={() => setShowSaveDialog(true)}
/>
</Navbar.Group>
</Navbar>
);
if (isLoading || !editing) {
return (
<div className="route" id="route-server-config-view">
{toolbar}
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="設定を読み込んでいます..."
/>
</div>
);
}
return (
<div className="route" id="route-server-config-view">
{toolbar}
<div className="content">
<Section
className="config-section"
title="Basic Config"
icon="settings"
compact
>
<div className="config-form-grid">
<FormGroup
label="Log Level"
labelFor="log-level"
helperText="ログ出力設定。通常運用では WARN を推奨します。問題が発生した時に変更し、ログを確認してください。"
>
<HTMLSelect
id="log-level"
value={editing.logLevel ?? 2}
onChange={(e) => {
setEditing({ ...editing, logLevel: parseInt(e.target.value, 10) as LogLevel });
}}
>
<option value="-1">FATAL (-1)</option>
<option value="0">ERROR (0)</option>
<option value="1">WARN (1)</option>
<option value="2">INFO (2)</option>
<option value="3">DEBUG (3)</option>
</HTMLSelect>
</FormGroup>
<FormGroup
label="Hostname"
labelFor="hostname"
helperText="Web UI にアクセスするためのホスト名を設定してください。任意のホスト名・ドメイン上のページからのアクセスを禁止しています。 (DNS Rebinding / CSRF 攻撃対策)"
>
<InputGroup
id="hostname"
value={editing.hostname ?? ""}
onChange={(e) => {
setEditing({ ...editing, hostname: e.target.value });
}}
/>
</FormGroup>
{ipv6Ready && (
<FormGroup
className="config-switch-group"
labelFor="disable-ipv6"
helperText="IPv6 の無効化 (よく分からない場合は ON)"
>
<Switch
id="disable-ipv6"
checked={editing.disableIPv6 ?? false}
label="Disable IPv6"
onChange={(e) => {
setEditing({ ...editing, disableIPv6: e.currentTarget.checked });
}}
/>
</FormGroup>
)}
</div>
</Section>
<Section
className="config-section"
title="Advanced Config"
icon="wrench"
compact
>
<div className="config-form-grid">
<FormGroup
label="Job Max Running"
labelFor="job-max-running"
helperText="同時実行できる最大ジョブ数"
>
<NumericInput
id="job-max-running"
value={editing.jobMaxRunning ?? ""}
placeholder="100"
min={1}
max={100}
onValueChange={(value, _) => {
if (value === null) {
delete editing.jobMaxRunning;
} else {
editing.jobMaxRunning = value;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
label="Job Max Standby"
labelFor="job-max-standby"
helperText="同時実行できる最大ジョブ準備数"
>
<NumericInput
id="job-max-standby"
value={editing.jobMaxStandby ?? ""}
placeholder="100"
min={1}
max={100}
onValueChange={(value, _) => {
if (value === null) {
delete editing.jobMaxStandby;
} else {
editing.jobMaxStandby = value;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
label="EPG Gathering Job Schedule (Cron)"
labelFor="epg-gathering-schedule"
helperText={invalidEpgGatheringJobSchedule ? "Cron expression is invalid." : "EPG 収集スケジュール (cron 風形式)"}
intent={invalidEpgGatheringJobSchedule ? Intent.DANGER : Intent.NONE}
>
<InputGroup
id="epg-gathering-schedule"
value={editing.epgGatheringJobSchedule ?? ""}
placeholder="20,50 * * * *"
onChange={(e) => {
editing.epgGatheringJobSchedule = e.target.value;
setEditing({ ...editing });
}}
intent={invalidEpgGatheringJobSchedule ? Intent.DANGER : Intent.NONE}
/>
</FormGroup>
<FormGroup
label="Max Buffer Bytes Before Ready (MB)"
labelFor="max-buffer-bytes"
helperText="番組イベント検出前の最大バッファサイズ (バイト) ※番組開始の頭が欠ける場合は増やす"
>
<NumericInput
id="max-buffer-bytes"
value={editing.maxBufferBytesBeforeReady ? Math.round(editing.maxBufferBytesBeforeReady / 1024 / 1024) : ""}
placeholder="8"
min={1}
max={64}
onValueChange={(value, _) => {
if (value === null) {
delete editing.maxBufferBytesBeforeReady;
} else {
editing.maxBufferBytesBeforeReady = value * 1024 * 1024;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
label="Event End Timeout (sec)"
labelFor="event-end-timeout"
helperText="番組イベント終了タイムアウト (ミリ秒) ※番組終了が誤判定される場合は長くする"
>
<NumericInput
id="event-end-timeout"
value={editing.eventEndTimeout ?? ""}
placeholder="1000"
min={1}
max={10000}
onValueChange={(value, _) => {
if (value === null) {
delete editing.eventEndTimeout;
} else {
editing.eventEndTimeout = value;
}
setEditing({ ...editing });
}}
/>
</FormGroup>
<FormGroup
className="config-switch-group"
labelFor="disable-eit-parsing"
helperText="EIT 解析の無効化 (EPG 関連機能が無効になります)"
intent={editing.disableEITParsing ? Intent.WARNING : Intent.NONE}
>
<Switch
id="disable-eit-parsing"
checked={editing.disableEITParsing ?? false}
label="Disable EIT Parsing ⚠️"
onChange={(e) => {
setEditing({ ...editing, disableEITParsing: e.currentTarget.checked ? true : undefined });
}}
/>
</FormGroup>
</div>
</Section>
<Section
className="config-section"
title="Network Config"
icon="globe-network"
compact
>
<div className="config-form-grid">
<FormGroup
className="config-form-wide"
label="Allow IPv4 CIDR Ranges ⚠️"
labelFor="allow-ipv4-cidrs"
helperText={invalidAllowIPv4CidrRanges ? "IPv4 CIDR range is invalid." : "アクセスを許可する IPv4 CIDR 範囲を1行に1つずつ指定 ⚠️ 最大限の注意が必要な設定です (グローバル IPv4 アドレスを指定しないでください)"}
intent={invalidAllowIPv4CidrRanges ? Intent.DANGER : Intent.NONE}
>
<TextArea
id="allow-ipv4-cidrs"
value={allowIPv4CidrRangesText}
onChange={(e) => {
const newValue = e.target.value;
setAllowIPv4CidrRangesText(newValue);
setEditing({ ...editing, allowIPv4CidrRanges: parseMultilineConfigValue(newValue) });
}}
rows={3}
intent={invalidAllowIPv4CidrRanges ? Intent.DANGER : Intent.NONE}
/>
</FormGroup>
<FormGroup
className="config-form-wide"
label="Allow IPv6 CIDR Ranges ⚠️"
labelFor="allow-ipv6-cidrs"
helperText={invalidAllowIPv6CidrRanges ? "IPv6 CIDR range is invalid." : "アクセスを許可する IPv6 CIDR 範囲を1行に1つずつ指定 ⚠️ 最大限の注意が必要な設定です (グローバル IPv6 アドレスを指定しないでください)"}
intent={invalidAllowIPv6CidrRanges ? Intent.DANGER : Intent.NONE}
>
<TextArea
id="allow-ipv6-cidrs"
value={allowIPv6CidrRangesText}
onChange={(e) => {
const newValue = e.target.value;
setAllowIPv6CidrRangesText(newValue);
setEditing({ ...editing, allowIPv6CidrRanges: parseMultilineConfigValue(newValue) });
}}
rows={3}
intent={invalidAllowIPv6CidrRanges ? Intent.DANGER : Intent.NONE}
/>
</FormGroup>
<FormGroup
className="config-form-wide"
label="Allow Origins ⚠️🧪"
labelFor="allow-origins"
helperText="アクセスを許可する Origin を1行に1つずつ指定"
>
<TextArea
id="allow-origins"
value={allowOriginsText}
onChange={(e) => {
const newValue = e.target.value;
setAllowOriginsText(newValue);
setEditing({ ...editing, allowOrigins: parseMultilineConfigValue(newValue) });
}}
rows={3}
/>
</FormGroup>
<FormGroup
className="config-switch-group"
labelFor="allow-pna"
helperText="Private Network Access / Local Network Access を許可 (ブラウザで保護されたコンテキストからのアクセスを認可できるようになります)"
>
<Switch
id="allow-pna"
checked={editing.allowPNA ?? true}
label="Allow PNA/LNA 🧪"
onChange={(e) => {
setEditing({ ...editing, allowPNA: e.currentTarget.checked });
}}
/>
</FormGroup>
</div>
</Section>
<Section
className="config-section"
title="Other Config"
icon="more"
compact
>
<div className="config-form-grid">
<FormGroup
label="TSPlay Endpoint 🧪"
labelFor="tsplay-endpoint"
helperText="TSPlay で使用するエンドポイント URL (保護されたコンテキスト)"
>
<InputGroup
id="tsplay-endpoint"
value={editing.tsplayEndpoint ?? ""}
onChange={(e) => {
const newValue = e.target.value.trim();
if (newValue === "") {
setEditing({ ...editing, tsplayEndpoint: null });
} else {
setEditing({ ...editing, tsplayEndpoint: newValue });
}
}}
/>
</FormGroup>
</div>
</Section>
</div>
{/* Save Confirmation Dialog */}
<Dialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
title="Save"
>
<DialogBody>
<p></p>
<p className="bp5-text-muted"></p>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowSaveDialog(false)}></Button>
<Button
intent="primary"
disabled={!hasChanges || invalid}
onClick={handleSave}
>
</Button>
</>
}
/>
</Dialog>
</div>
);
};
// (仮) src/Mirakurun/Job.ts にある関数と同じ
function isValidCronExpression(cronExpression: string): boolean {
const cronParts = cronExpression.split(" ");
if (cronParts.length !== 5) {
return false;
}
try {
// 各部分のパターンを定義
const patterns = [
/^(\*|([0-9]|[1-5][0-9])((-[0-9]|[1-5][0-9]))?)(\/([1-9]|[1-5][0-9]))?$/, // 分 (0-59)
/^(\*|([0-9]|1[0-9]|2[0-3])((-[0-9]|1[0-9]|2[0-3]))?)(\/([1-9]|1[0-9]|2[0-3]))?$/, // 時 (0-23)
/^(\*|([1-9]|[12][0-9]|3[01])((-[1-9]|[12][0-9]|3[01]))?)(\/([1-9]|[12][0-9]|3[01]))?$/, // 日 (1-31)
/^(\*|([1-9]|1[0-2])((-[1-9]|1[0-2]))?)(\/([1-9]|1[0-2]))?$/, // 月 (1-12)
/^(\*|([0-6])((-[0-6]))?)(\/([1-6]))?$/ // 曜日 (0-6)
];
// 各部分を検証
for (let i = 0; i < 5; i++) {
// カンマで区切られた値をすべて検証
const parts = cronParts[i].split(",");
for (const part of parts) {
if (part === "" || !patterns[i].test(part)) {
return false;
}
}
}
return true;
} catch (err) {
return false;
}
}

View File

@@ -0,0 +1,61 @@
@use "~@blueprintjs/colors/lib/scss/colors"
#route-tuners-config-view
.content
display: flex
flex-direction: column
gap: 16px
padding: 20px
overflow-y: auto
.tuner-table
width: 100%
border-collapse: collapse
th, td
vertical-align: top !important
padding: 12px 8px !important
td
.bp5-form-group
margin-bottom: 8px
&:last-child
margin-bottom: 0
.bp5-label
margin-bottom: 3px
font-weight: 600
font-size: 11px
color: colors.$gray1
.bp5-dark &
color: colors.$gray4
.types-checkboxes
display: flex
flex-direction: column
gap: 4px
margin-top: 6px
.bp5-control
margin-bottom: 0
.remote-mirakurun-group
display: flex
gap: 8px
align-items: flex-end
margin-bottom: 8px
.bp5-form-group
margin-bottom: 0 !important
.tuner-options-grid
display: flex
flex-direction: column
gap: 8px
.controls-cell
display: flex
gap: 4px
justify-content: flex-end
align-items: center

View File

@@ -0,0 +1,431 @@
/*
Copyright 2026 kanreisa
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { useState, useEffect } from "react";
import {
Alignment,
Breadcrumbs,
Button,
Checkbox,
Dialog,
DialogBody,
DialogFooter,
FormGroup,
InputGroup,
Navbar,
NonIdealState,
Spinner,
Switch,
HTMLTable
} from "@blueprintjs/core";
import equal from "fast-deep-equal";
import { state } from "../modules/state";
import * as ui from "../modules/ui";
import { ConfigTuners, ConfigTunersItem, ChannelType } from "../../../api.d";
import "./TunersConfigView.sass";
const configAPI = "/api/config/tuners";
const typesIndex = ["GR", "BS", "CS", "SKY"];
function sortTypes(types: ChannelType[]): ChannelType[] {
return types.sort((a, b) => typesIndex.indexOf(a) - typesIndex.indexOf(b));
}
export const TunersConfigView: React.FC = () => {
console.debug("routes", "TunersConfigView");
const [current, setCurrent] = useState<ConfigTuners | null>(null);
const [editing, setEditing] = useState<ConfigTuners | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saved, setSaved] = useState(false);
const [isLoading, setIsLoading] = useState(true);
ui.setTitle("チューナー設定", isLoading);
useEffect(() => {
if (saved === true) {
setTimeout(() => {
// Restart notification will be emitted in production when requested
}, 500);
setSaved(false);
return;
}
(async () => {
try {
const res = await (await fetch(configAPI)).json();
console.log("TunersConfigView", "GET", configAPI, "->", res);
setEditing(JSON.parse(JSON.stringify(res)));
setCurrent(JSON.parse(JSON.stringify(res)));
setIsLoading(false);
} catch (e) {
console.error(e);
setIsLoading(false);
}
})();
}, [saved]);
const hasChanges = editing !== null && current !== null && !equal(editing, current);
const handleCancel = () => {
if (current) {
setEditing(JSON.parse(JSON.stringify(current)));
}
};
const handleSave = async () => {
if (!editing) {
return;
}
setShowSaveDialog(false);
try {
console.log("TunersConfigView", "PUT", configAPI, "<-", editing);
await fetch(configAPI, {
method: "PUT",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(editing)
});
setSaved(true);
} catch (err) {
console.error(err);
}
};
const handleAddTuner = () => {
if (!editing) return;
const i = editing.length;
const newTuner: ConfigTunersItem = {
name: `adapter${i}`,
types: [],
command: `dvbv5-zap -a ${i} -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbs.conf -r -P <channel>`,
dvbDevicePath: `/dev/dvb/adapter${i}/dvr0`,
decoder: "arib-b25-stream-test",
isDisabled: true
};
setEditing([...editing, newTuner]);
};
const updateTuner = (index: number, updated: Partial<ConfigTunersItem>) => {
if (!editing) return;
const newEditing = [...editing];
newEditing[index] = { ...newEditing[index], ...updated };
setEditing(newEditing);
};
const deleteTunerProperty = (index: number, key: keyof ConfigTunersItem) => {
if (!editing) return;
const newEditing = [...editing];
const updated = { ...newEditing[index] };
delete updated[key];
newEditing[index] = updated;
setEditing(newEditing);
};
const handleUp = (i: number) => {
if (!editing || i === 0) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i - 1];
newEditing[i - 1] = temp;
setEditing(newEditing);
};
const handleDown = (i: number) => {
if (!editing || i === editing.length - 1) return;
const newEditing = [...editing];
const temp = newEditing[i];
newEditing[i] = newEditing[i + 1];
newEditing[i + 1] = temp;
setEditing(newEditing);
};
const handleRemove = (i: number) => {
if (!editing) return;
const newEditing = [...editing];
newEditing.splice(i, 1);
setEditing(newEditing);
};
const toolbar = (
<Navbar className="toolbar">
<Navbar.Group align={Alignment.START}>
<Navbar.Heading>
<Breadcrumbs items={[
{
text: "チューナー設定"
}
]} />
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.END}>
<Button
minimal
intent="success"
icon="add"
text="Add Tuner"
onClick={handleAddTuner}
/>
<Navbar.Divider />
<Button
minimal
intent="danger"
icon="undo"
text="Cancel"
disabled={!hasChanges}
onClick={handleCancel}
/>
<Button
intent="primary"
icon="saved"
text="Save"
disabled={!hasChanges}
onClick={() => setShowSaveDialog(true)}
/>
</Navbar.Group>
</Navbar>
);
if (isLoading || !editing) {
return (
<div className="route" id="route-tuners-config-view">
{toolbar}
<NonIdealState
icon={<Spinner />}
title="ロード中"
description="設定を読み込んでいます..."
/>
</div>
);
}
return (
<div className="route" id="route-tuners-config-view">
{toolbar}
<div className="content">
<HTMLTable className="tuner-table" striped interactive>
<thead>
<tr>
<th style={{ width: "80px" }}>Enable</th>
<th style={{ width: "180px" }}>Name</th>
<th style={{ width: "120px" }}>Types</th>
<th>Options</th>
<th style={{ width: "140px", textAlign: "right" }}></th>
</tr>
</thead>
<tbody>
{editing.map((tuner, i) => (
<tr key={i}>
<td>
<Switch
checked={!tuner.isDisabled}
onChange={(e) => {
updateTuner(i, { isDisabled: !e.currentTarget.checked });
}}
/>
</td>
<td>
<InputGroup
value={tuner.name || ""}
onChange={(e) => {
updateTuner(i, { name: e.target.value });
}}
/>
</td>
<td>
<div className="types-checkboxes">
{(["GR", "BS", "CS", "SKY"] as ChannelType[]).map((type) => {
const checked = tuner.types?.includes(type) ?? false;
return (
<Checkbox
key={type}
label={type}
checked={checked}
inline
onChange={(e) => {
let newTypes = [...(tuner.types || [])];
if (e.currentTarget.checked) {
newTypes.push(type);
newTypes = sortTypes(newTypes);
} else {
newTypes = newTypes.filter(t => t !== type);
}
updateTuner(i, { types: newTypes });
}}
/>
);
})}
</div>
</td>
<td>
<div className="tuner-options-grid">
{!tuner.remoteMirakurunHost && (
<>
<FormGroup label="Command">
<InputGroup
value={tuner.command || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "command");
} else {
updateTuner(i, { command: val });
}
}}
/>
</FormGroup>
<FormGroup label="DVB Device Path">
<InputGroup
value={tuner.dvbDevicePath || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "dvbDevicePath");
} else {
updateTuner(i, { dvbDevicePath: val });
}
}}
/>
</FormGroup>
</>
)}
{!tuner.command && (
<>
<div className="remote-mirakurun-group">
<FormGroup label="Remote Mirakurun Host" style={{ flex: 1 }}>
<InputGroup
value={tuner.remoteMirakurunHost || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "remoteMirakurunHost");
} else if (/^[0-9a-z\.]+$/.test(val)) {
updateTuner(i, { remoteMirakurunHost: val });
}
}}
/>
</FormGroup>
<FormGroup label="Port" style={{ width: "90px" }}>
<InputGroup
placeholder="40772"
value={`${tuner.remoteMirakurunPort || ""}`}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "remoteMirakurunPort");
} else if (/^[0-9]+$/.test(val)) {
const port = parseInt(val, 10);
if (port <= 65535 && port > 0) {
updateTuner(i, { remoteMirakurunPort: port });
}
}
}}
/>
</FormGroup>
</div>
<div style={{ marginBottom: "8px" }}>
<Checkbox
label="Decode (Remote Mirakurun Decoder)"
checked={tuner.remoteMirakurunDecoder || false}
onChange={(e) => {
if (e.currentTarget.checked) {
updateTuner(i, { remoteMirakurunDecoder: true });
} else {
deleteTunerProperty(i, "remoteMirakurunDecoder");
}
}}
/>
</div>
</>
)}
{(!tuner.remoteMirakurunHost || !tuner.remoteMirakurunDecoder) && (
<FormGroup label="Decoder">
<InputGroup
value={tuner.decoder || ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
deleteTunerProperty(i, "decoder");
} else {
updateTuner(i, { decoder: val });
}
}}
/>
</FormGroup>
)}
</div>
</td>
<td>
<div className="controls-cell">
<Button
disabled={i === 0}
icon="chevron-up"
onClick={() => handleUp(i)}
minimal
/>
<Button
disabled={i === editing.length - 1}
icon="chevron-down"
onClick={() => handleDown(i)}
minimal
/>
<Button
icon="trash"
intent="danger"
onClick={() => handleRemove(i)}
minimal
/>
</div>
</td>
</tr>
))}
</tbody>
</HTMLTable>
</div>
{/* Save Confirmation Dialog */}
<Dialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
title="Save"
>
<DialogBody>
<p></p>
<p className="bp5-text-muted"></p>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={() => setShowSaveDialog(false)}></Button>
<Button
intent="primary"
disabled={!hasChanges}
onClick={handleSave}
>
</Button>
</>
}
/>
</Dialog>
</div>
);
};

29
web/src/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"alwaysStrict": true,
"target": "es2022",
"lib": [
"es2022",
"dom",
"dom.iterable"
],
"module": "esnext",
"moduleResolution": "bundler",
"removeComments": true,
"sourceMap": true,
"incremental": true,
"esModuleInterop": false,
"jsx": "react",
"allowSyntheticDefaultImports": true,
"outDir": "../dist"
},
"include": [
"./**/*.ts",
"./**/*.tsx",
"./custom.d.ts"
],
"exclude": [
"node_modules"
]
}

19
web/src/vars.sass Normal file
View File

@@ -0,0 +1,19 @@
@keyframes fade-out
from
opacity: 1
to
opacity: 0
@keyframes fade-in
from
opacity: 0
to
opacity: 1
$invert-filter: brightness(52%) invert(100%) hue-rotate(180deg) saturate(300%) contrast(150%)
$font-base: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", "Yu Gothic", sans-serif
$font-ui: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", "Yu Gothic UI", sans-serif
$theme-dark-primary: #ffd56c
$theme-light-primary: #ffc126

7
web/types/rpc.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export interface JoinParams {
rooms: string[];
}
export interface NotifyParams<T> {
array: T[];
}

74
web/webpack.config.js Normal file
View File

@@ -0,0 +1,74 @@
const path = require("path");
const webpack = require("webpack");
const CopyPlugin = require("copy-webpack-plugin");
module.exports = {
mode: process.env.NODE_ENV === "development" ? "development" : "production",
devtool: process.env.NODE_ENV === "development" ? "source-map" : false,
entry: {
index: path.resolve(__dirname, "src/index.tsx")
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].bundle.js",
clean: true
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [{
loader: "ts-loader",
options: {
configFile: path.resolve(__dirname, "src/tsconfig.json")
}
}]
},
{
test: /\.s?[ac]ss$/,
use: ["style-loader", "css-loader", "sass-loader"]
},
{
test: /\.(png|woff|woff2|eot|ttf)$/,
type: "asset/resource",
generator: {
filename: "assets/[hash][ext]"
}
},
{
test: /\.svg$/,
type: "asset/inline"
}
]
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
},
plugins: [
new CopyPlugin({
patterns: [{
from: "**/*.{html,svg}",
to: "[path][name][ext]",
context: path.resolve(__dirname, "src")
}]
}),
new webpack.ProvidePlugin({
Buffer: ["buffer", "Buffer"]
}),
new webpack.ProvidePlugin({
process: "process/browser.js"
})
],
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
test: /node_modules/,
name: "vendors",
chunks: "all",
enforce: true
}
}
}
}
};