Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions src/plugins/nicojk/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,15 @@ function scopeLiveComment(
};
}

function compareComments(left: NiconicoComment, right: NiconicoComment) {
return (
left.vpos - right.vpos ||
left.date - right.date ||
left.date_usec - right.date_usec ||
left.id - right.id
);
}

function mergeComments(
existing: NiconicoComment[],
incoming: NiconicoComment[],
Expand All @@ -549,13 +558,38 @@ function mergeComments(
return existing;
}

const merged = [...existing, ...incoming].sort(
(a, b) =>
a.vpos - b.vpos ||
a.date - b.date ||
a.date_usec - b.date_usec ||
a.id - b.id,
);
// Live comments normally arrive one at a time. Keep the existing sorted
// array and insert into it instead of sorting all 1000 retained comments
// for every incoming message.
if (incoming.length === 1) {
const comment = incoming[0];
if (!comment || existing.some((item) => item.id === comment.id)) {
return existing;
}

let insertAt = existing.length;
const lastComment = existing.at(-1);
if (lastComment && compareComments(lastComment, comment) > 0) {
let low = 0;
let high = existing.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
const middleComment = existing[middle];
if (middleComment && compareComments(middleComment, comment) <= 0) {
low = middle + 1;
} else {
high = middle;
}
}
insertAt = low;
}

const merged = existing.slice();
merged.splice(insertAt, 0, comment);
return typeof maxCount === "number" ? merged.slice(-maxCount) : merged;
}

const merged = [...existing, ...incoming].sort(compareComments);
const deduped: NiconicoComment[] = [];
const seen = new Set<number>();
for (const comment of merged) {
Expand Down Expand Up @@ -1231,6 +1265,19 @@ export default function App() {
setInterruptedSources(data?.interruptedSources || []);
};

const pendingLiveSyncPlayerIds = new Set<string>();
let liveSyncTimer: number | null = null;
const scheduleLiveTargetSync = (playerID: string) => {
pendingLiveSyncPlayerIds.add(playerID);
if (liveSyncTimer !== null) return;
liveSyncTimer = window.setTimeout(() => {
liveSyncTimer = null;
const playerIDs = [...pendingLiveSyncPlayerIds];
pendingLiveSyncPlayerIds.clear();
for (const id of playerIDs) syncTargetState(id);
}, 50);
};

const startRecordedCommentsFetch = (
playerID: string,
priorityTime?: number,
Expand Down Expand Up @@ -1925,7 +1972,7 @@ export default function App() {
MAX_LIVE_COMMENTS,
);
if (targetPlayableRef.current?.playerID === pid) {
syncTargetState(pid);
scheduleLiveTargetSync(pid);
}
}
});
Expand Down Expand Up @@ -2129,6 +2176,11 @@ export default function App() {
handleLiveCommentSourceChange,
);
clearInterval(interval);
if (liveSyncTimer !== null) {
window.clearTimeout(liveSyncTimer);
liveSyncTimer = null;
}
pendingLiveSyncPlayerIds.clear();
startRecordedCommentsFetchRef.current = null;
pendingRecordedReloadRef.current.clear();
for (const client of clientsRef.current.values()) client.disconnect();
Expand Down
36 changes: 35 additions & 1 deletion src/plugins/nicojk/components/OverlayPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const RENDER_SEGMENT_SIZE = 3600;
const RENDER_SEGMENT_OVERLAP = 60;
// App側のライブコメント上限に余裕を加えた件数でrendererを再構築し、内部保持量を固定する
const MAX_LIVE_RENDERER_COMMENTS = 1200;
const DEFAULT_LIVE_COMMENT_DURATION_VPOS = 300;
const MAX_LIVE_COMMENT_DURATION_VPOS = 12_000;
// 横書きコメントは vpos の前後にも描画・衝突判定のために保持される。
const LIVE_COMMENT_DRAW_LEAD_VPOS = 125;

function getSegmentComments(
comments: NiconicoComment[],
Expand All @@ -55,6 +59,33 @@ function getSegmentComments(
);
}

function getLiveCommentDurationVpos(comment: NiconicoComment) {
let durationVpos = DEFAULT_LIVE_COMMENT_DURATION_VPOS;
for (const mail of comment.mail || []) {
const match = /^[@@]([0-9.]+)$/.exec(mail);
if (!match) continue;
const duration = Math.floor(Number(match[1]) * 100);
durationVpos =
Number.isFinite(duration) && duration > 0
? Math.min(duration, MAX_LIVE_COMMENT_DURATION_VPOS)
: DEFAULT_LIVE_COMMENT_DURATION_VPOS;
}
return durationVpos;
}

export function getLiveRendererSeedComments(
comments: NiconicoComment[],
nowVpos: number,
) {
return comments.filter(
(comment) =>
comment.vpos +
getLiveCommentDurationVpos(comment) +
LIVE_COMMENT_DRAW_LEAD_VPOS >=
nowVpos,
);
}

function isCommentVisibleForSource(
comment: NiconicoComment,
jkContext: NicoJKContext | null,
Expand Down Expand Up @@ -389,7 +420,10 @@ export default function OverlayPage({
: [];
const liveComments = isLive
? toFormattedComments(
segmentComments,
getLiveRendererSeedComments(
segmentComments,
Math.floor(Date.now() / 10),
),
visibleSourceKeys,
jkContext,
currentSettings,
Expand Down
106 changes: 52 additions & 54 deletions src/plugins/nicojk/components/PanelPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1458,63 +1458,21 @@ export default function PanelPage({
)}
{source.chapterCorrection && (
<div
className={
className={`mt-0.5 flex min-w-0 items-center justify-between gap-3 ${
source.chapterCorrection.enabled
? "text-emerald-300"
: "text-gray-500"
}
}`}
>
チャプター補正{" "}
{formatCorrectionSeconds(
source.chapterCorrection.offsetSeconds,
)}
{source.chapterCorrection.enabled
? " 適用中"
: " 無効"}
</div>
)}
{isInterrupted && interruptedChunks.length > 0 && (
<div className="mt-1.5 flex items-center gap-1">
{interruptedChunks.map((chunk) => {
const chunkLabel = `${formatTimeRange(chunk.startAt, chunk.endAt)}`;
return (
<span
key={chunk.startAt}
className={`inline-block h-2 w-2 rounded-full ${
chunk.fetched
? "bg-amber-400"
: "bg-gray-600"
}`}
title={
chunk.fetched
? `取得済: ${chunkLabel}`
: `未取得: ${chunkLabel}`
}
/>
);
})}
<span className="ml-1 text-[9px] text-amber-300/70">
{interruptedInfo?.fetchedChunkCount || 0}/
{interruptedInfo?.totalChunkCount || 0}
<span className="min-w-0 wrap-break-word">
チャプター補正{" "}
{formatCorrectionSeconds(
source.chapterCorrection.offsetSeconds,
)}
{source.chapterCorrection.enabled
? " 適用中"
: " 無効"}
</span>
</div>
)}
</div>
{(isInterrupted || source.chapterCorrection) && (
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
{isInterrupted && (
<button
type="button"
onClick={() => onResumeSource(source.key)}
className="flex items-center gap-1 rounded border border-amber-500/40 bg-amber-600/80 px-2 py-1 text-[10px] text-white transition-colors hover:bg-amber-500"
title={`${source.channelName} の取得を再開`}
aria-label={`${source.channelName} の取得を再開`}
>
<RotateCw size={12} />
全件取得
</button>
)}
{source.chapterCorrection && (
<button
type="button"
onClick={() =>
Expand All @@ -1523,7 +1481,7 @@ export default function PanelPage({
!source.chapterCorrection?.enabled,
)
}
className={`flex h-7 w-7 items-center justify-center rounded border transition-colors ${
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded border transition-colors ${
source.chapterCorrection.enabled
? "border-emerald-500/40 bg-emerald-600 text-white hover:bg-emerald-500"
: "border-gray-600 bg-gray-700 text-gray-300 hover:bg-gray-600"
Expand All @@ -1545,7 +1503,47 @@ export default function PanelPage({
<Check size={14} />
)}
</button>
)}
</div>
)}
{isInterrupted && interruptedChunks.length > 0 && (
<div className="mt-1.5 flex items-center gap-1">
{interruptedChunks.map((chunk) => {
const chunkLabel = `${formatTimeRange(chunk.startAt, chunk.endAt)}`;
return (
<span
key={chunk.startAt}
className={`inline-block h-2 w-2 rounded-full ${
chunk.fetched
? "bg-amber-400"
: "bg-gray-600"
}`}
title={
chunk.fetched
? `取得済: ${chunkLabel}`
: `未取得: ${chunkLabel}`
}
/>
);
})}
<span className="ml-1 text-[9px] text-amber-300/70">
{interruptedInfo?.fetchedChunkCount || 0}/
{interruptedInfo?.totalChunkCount || 0}
</span>
</div>
)}
</div>
{isInterrupted && (
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={() => onResumeSource(source.key)}
className="flex items-center gap-1 rounded border border-amber-500/40 bg-amber-600/80 px-2 py-1 text-[10px] text-white transition-colors hover:bg-amber-500"
title={`${source.channelName} の取得を再開`}
aria-label={`${source.channelName} の取得を再開`}
>
<RotateCw size={12} />
全件取得
</button>
</div>
)}
<div
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/nicojk/ng-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const LIVE_COMMENT_SOURCE_VALUES = ["niconico", "nx-jikkyo"] as const;
export type LiveCommentSource = (typeof LIVE_COMMENT_SOURCE_VALUES)[number];

export function normalizeLiveCommentSource(value: unknown): LiveCommentSource {
return value === "nx-jikkyo" ? "nx-jikkyo" : "niconico";
return value === "niconico" ? "niconico" : "nx-jikkyo";
}

export interface NicoJKSettings {
Expand Down Expand Up @@ -37,7 +37,7 @@ const DEFAULT_SETTINGS: NicoJKSettings = {
ngWords: [],
ngIds: [],
ngCommands: [],
liveCommentSource: "niconico",
liveCommentSource: "nx-jikkyo",
showComments: true,
opacity: 0.8,
secondarySourceOpacity: 1,
Expand Down
Loading