Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e651345
Prototype of latency reducer, only for Kick
Kaedriz Nov 19, 2025
a512a70
Prototype for Twitch
Kaedriz Nov 19, 2025
0ba1875
chore: lint
jvxz Nov 21, 2025
a85f3fc
feat(twitch): add mockup latency tab for latency-related options
jvxz Nov 21, 2025
1439ef5
refactor(twitch): move stream latency display toggle to dedicated tab
jvxz Nov 21, 2025
6f3944d
feat(twitch): add kick-identical mockup latency reducer options to la…
jvxz Nov 21, 2025
b28a867
feat(twitch): add latency reducer constants
jvxz Nov 21, 2025
ec9ce05
feat(twitch): extend twitch media player instance types to include ac…
jvxz Nov 21, 2025
0a1394a
feat(twitch): add stream latency reducer module, add core functionali…
jvxz Nov 21, 2025
9b85b87
fix(twitch): fix weird commit error
jvxz Nov 21, 2025
d71fc81
refactor(twitch): scrap first prototype for stream latency reduction …
jvxz Nov 21, 2025
8cc0a2b
refactor(twitch): opt for a single playback rate value instead of min…
jvxz Nov 21, 2025
dd75926
feat(twitch): extend latency component to include playback rate
jvxz Nov 22, 2025
67cdf96
feat(twitch): add getMediaPlayerPlaybackRate twitch util
jvxz Nov 22, 2025
e6b3e77
refactor(twitch): refactor stream latency reducer module to use playb…
jvxz Nov 22, 2025
934d1df
chore: cleanup
jvxz Nov 22, 2025
ffcddfb
feat: add playbackRate signal to stream latency module with video rat…
jvxz Nov 22, 2025
660a16c
refactor(twitch): organize latency settings declarations
jvxz Nov 22, 2025
9435081
feat(twitch): re-add min/max playback rate settings, add min/max thre…
jvxz Nov 22, 2025
8310d6e
feat(twitch): add latency offset for rate updates to prevent rapid up…
jvxz Nov 22, 2025
82d84de
fix(twitch): fix twitch default settings
jvxz Nov 22, 2025
ecc72c7
chore: remove accidentally-commited vscode settings.json
jvxz Nov 22, 2025
1ede871
Fixes playback not limited to 2 digits
Kaedriz Nov 25, 2025
58891d2
WIP fix for twitch latency reducer
Kaedriz Nov 25, 2025
d4112f3
feat(twitch): enhance fix for latency reducer
jvxz Nov 27, 2025
f54ab42
Some changes to make it working again
Kaedriz Dec 9, 2025
4cc0f81
Formatting
Kaedriz Dec 9, 2025
eb7222a
Fix compatibility with FFZ
Kaedriz Dec 9, 2025
12d2b34
Final touches to twitch version
Kaedriz Dec 18, 2025
9173f95
style: change ordering to be more logical
Kaedriz Jan 7, 2026
c437b68
refactor: Separate Reducer logic to separate module
Kaedriz Jan 7, 2026
2f34c2b
Revert "feat(twitch): enhance fix for latency reducer"
Kaedriz Jan 7, 2026
8e9e260
Merge remote-tracking branch 'origin/latency-reducer-2' into latency-…
Kaedriz Jan 7, 2026
30c5aba
Merge branch 'enhancer-app:master' into latency-reducer
Kaedriz Jan 7, 2026
aa0fac6
Apply suggestion from @gemini-code-assist[bot]
Kaedriz Jan 7, 2026
326cba2
Apply suggestion from @gemini-code-assist[bot]
Kaedriz Jan 7, 2026
dfc0081
Apply suggestion from @Copilot
Kaedriz Jan 7, 2026
623429e
Apply suggestion from @Copilot
Kaedriz Jan 7, 2026
360b3fc
Apply suggestion from @Copilot
Kaedriz Jan 7, 2026
3c3f62c
Apply suggestion from @Copilot
Kaedriz Jan 7, 2026
8d68151
Apply suggestion from @Copilot
Kaedriz Jan 7, 2026
7e4bbd2
style: Fix incomplete comment
Kaedriz Jan 7, 2026
446d483
refactor: Apply suggestion from Gemini
Kaedriz Jan 7, 2026
033faff
style: formatting fixes
Kaedriz Jan 7, 2026
0fbbd8d
Apply Gemini suggestion
Kaedriz Jan 7, 2026
4baf6c1
refactor: remove obsolete check
Kaedriz Jan 7, 2026
583ebe2
refactor: optimize settings calls for reducer
Kaedriz Jan 7, 2026
235fad8
fix: add error logging
Kaedriz Jan 7, 2026
b239fb4
refactor: remove redundant code
Kaedriz Jan 7, 2026
cc038f3
fix: Prevent possible bug
Kaedriz Jan 7, 2026
c5fc137
style: fix typo
Kaedriz Jan 7, 2026
e836df7
refactor: remove redundant code
Kaedriz Jan 7, 2026
a332b1a
style: formatting
Kaedriz Jan 7, 2026
1661c5d
refactor: extract part of latency getter to utils
Kaedriz Jan 7, 2026
56fc659
refactor: temporary move these new feature to experimental to test th…
Kaedriz Jan 7, 2026
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
1 change: 1 addition & 0 deletions public/assets/settings/latency.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/platforms/kick/kick.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const KICK_DEFAULT_SETTINGS: KickSettings = {
chatMessageMenuEnabled: true,
quickAccessLinks: [{ title: "Streams Charts", url: "https://streamscharts.com/channels/%username%?platform=kick" }],
streamLatencyEnabled: true,
streamLatencyReducerEnabled: false,
streamLatencyReducerMinRate: 1.05,
streamLatencyReducerMaxRate: 1.1,
streamLatencyReducerMinThreshold: 3,
streamLatencyReducerMaxThreshold: 6,
realVideoTimeEnabled: true,
realVideoTimeFormat12h: false,
channelSection: true,
Expand Down
2 changes: 2 additions & 0 deletions src/platforms/kick/kick.platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import LocalWatchtimeCounterModule from "$kick/modules/local-watchtime-counter/l
import RealVideoTimeModule from "$kick/modules/real-video-time/real-video-time.module.tsx";
import SettingsButtonModule from "$kick/modules/settings-button/settings-button.module.tsx";
import SettingsModule from "$kick/modules/settings/settings.module.tsx";
import StreamLatencyReducerModule from "$kick/modules/stream-latency-reducer/stream-latency-reducer.module.tsx";
import StreamLatencyModule from "$kick/modules/stream-latency/stream-latency.module.tsx";
import Platform from "$shared/platform/platform.ts";
import type { KickEvents } from "$types/platforms/kick/kick.events.types.ts";
Expand Down Expand Up @@ -49,6 +50,7 @@ export default class KickPlatform extends Platform<KickModule, KickEvents, KickS
new SettingsModule(...dependencies),
new ChatNicknameCustomizationModule(...dependencies),
new StreamLatencyModule(...dependencies),
new StreamLatencyReducerModule(...dependencies),
new RealVideoTimeModule(...dependencies),
new ChannelSectionModule(...dependencies),
new LocalWatchtimeCounterModule(...dependencies),
Expand Down
8 changes: 8 additions & 0 deletions src/platforms/kick/kick.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ export default class KickUtils {
return video.duration === Number.POSITIVE_INFINITY || video.duration > KickUtils.FIREFOX_LIVE_VIDEO_THRESHOLD;
}

getLatency(video: HTMLVideoElement): number {
const { currentTime, buffered } = video;
if (buffered.length === 0) return -1;
const bufferEnd = buffered.end(buffered.length - 1);

return bufferEnd - currentTime;
}

async scrollToBottomOnChat() {
const chatRoom = this.getChannelChatRoom();
if (!chatRoom) return;
Expand Down
62 changes: 54 additions & 8 deletions src/platforms/kick/modules/settings/settings.module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export default class SettingsModule extends KickModule {
title: "Channel",
iconUrl: await this.commonUtils().getAssetFile(this.workerService(), "settings/channel.svg"),
},
{
title: "Latency",
iconUrl: await this.commonUtils().getAssetFile(this.workerService(), "settings/latency.svg"),
},
{
title: "Experimental",
iconUrl: await this.commonUtils().getAssetFile(this.workerService(), "settings/experimental.svg"),
Expand All @@ -74,14 +78,6 @@ export default class SettingsModule extends KickModule {
discord: await this.commonUtils().getAssetFile(this.workerService(), "brands/discord.svg"),
} as const;
this.SETTING_DEFINITIONS = [
{
id: "streamLatencyEnabled",
title: "Enable Stream Latency",
description: "Shows the current stream delay on top of the chat.",
type: "toggle",
tabIndex: tabIndexes.General,
requiresRefreshToDisable: true,
},
{
id: "realVideoTimeEnabled",
title: "Enable Real Video Time",
Expand Down Expand Up @@ -187,6 +183,56 @@ export default class SettingsModule extends KickModule {
},
hideInfo: true,
},
{
id: "streamLatencyEnabled",
title: "Enable Stream Latency",
description: "Shows the current stream delay on top of the chat.",
type: "toggle",
tabIndex: tabIndexes.Latency,
requiresRefreshToDisable: true,
},
{
id: "streamLatencyReducerEnabled",
title: "Enable Stream Latency Reducer",
description: "Reduces stream latency by adjusting playback rate.",
type: "toggle",
tabIndex: tabIndexes.Experimental,
requiresRefreshToDisable: true,
},
{
id: "streamLatencyReducerMinRate",
title: "Minimum Playback Rate",
description: "The minimum playback rate the stream will be speeded up to.",
type: "number",
tabIndex: tabIndexes.Experimental,
requiresRefreshToDisable: true,
},
{
id: "streamLatencyReducerMaxRate",
title: "Maximum Playback Rate",
description: "The maximum playback rate the stream will be speeded up to.",
type: "number",
tabIndex: tabIndexes.Experimental,
requiresRefreshToDisable: true,
},
{
id: "streamLatencyReducerMinThreshold",
title: "Minimum Latency Threshold",
description:
"The latency threshold (in seconds) at which the playback rate will be speeded up to the minimum rate.",
type: "number",
tabIndex: tabIndexes.Experimental,
requiresRefreshToDisable: true,
},
{
id: "streamLatencyReducerMaxThreshold",
title: "Maximum Latency Threshold",
description:
"The latency threshold (in seconds) at which the playback rate will be speeded up to the maximum rate.",
type: "number",
tabIndex: tabIndexes.Experimental,
requiresRefreshToDisable: true,
},
{
id: "about",
title: "About This Extension",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import KickModule from "$kick/kick.module.ts";
import type { KickModuleConfig } from "$types/shared/module/module.types.ts";
import { signal } from "@preact/signals";

export default class StreamLatencyReducerModule extends KickModule {
private updateInterval: NodeJS.Timeout | undefined;
private latencyTimings = signal<number[]>([]);

readonly config: KickModuleConfig = {
name: "stream-latency-reducer",
appliers: [
{
type: "selector",
key: "stream-latency-reducer",
selectors: ["#channel-chatroom"],
callback: this.run.bind(this),
validateUrl: (url) => {
return !url.includes("/videos/") && !url.includes("/clips/");
},
once: true,
},
],
isModuleEnabledCallback: async () => await this.settingsService().getSettingsKey("streamLatencyReducerEnabled"),
};

private run(): void {
if (this.updateInterval) clearInterval(this.updateInterval);
this.updateInterval = setInterval(async () => {
const status = await this.getPlaybackRateStatus();

if (status === "catchingUpMax") {
this.setPlaybackRateMode("catchUpMax");
} else if (status === "catchingUpMin") {
this.setPlaybackRateMode("catchUpMin");
} else {
this.setPlaybackRateMode("reset");
}
}, 1000);
}

private changePlaybackSpeed(video: HTMLVideoElement, rate: number) {
video.playbackRate = rate;
}

private async setPlaybackRateMode(mode: "catchUpMin" | "catchUpMax" | "reset") {
const videoPlayer = this.getPlayer();
if (!videoPlayer) return;

let targetRate = 1;
const latency = this.computeLatency(videoPlayer);

// Always reset playback speed in reset mode, regardless of latency.
if (mode === "reset") {
this.changePlaybackSpeed(videoPlayer, 1);
return;
}

// When latency is zero, negative, or otherwise invalid, ensure playback speed is reset.
if (!latency || latency <= 0) {
this.changePlaybackSpeed(videoPlayer, 1);
return;
}
const {
minRate,
maxRate,
minThreshold: minSpeedThreshold,
maxThreshold: maxSpeedThreshold,
} = await this.getSettings();

if (mode === "catchUpMax") {
targetRate = maxRate;
} else if (mode === "catchUpMin") {
if (maxSpeedThreshold === minSpeedThreshold) {
// Avoid division by zero when thresholds are equal; fall back to a step between minRate and maxRate.
targetRate = latency >= maxSpeedThreshold ? maxRate : minRate;
} else {
targetRate =
minRate + ((maxRate - minRate) * (latency - minSpeedThreshold)) / (maxSpeedThreshold - minSpeedThreshold);
}
}

this.changePlaybackSpeed(videoPlayer, targetRate);
}

private async getPlaybackRateStatus() {
const videoPlayer = this.getPlayer();
if (!videoPlayer) return;
const latency = this.computeLatency(videoPlayer);
if (!latency) return "invalid";

const { maxThreshold, minThreshold } = await this.getSettings();
if (latency >= maxThreshold) return "catchingUpMax";
if (latency > minThreshold) return "catchingUpMin";
if (latency <= minThreshold) return "caughtUp";
return "invalid";
}

private async getSettings() {
const minRate = await this.settingsService().getSettingsKey("streamLatencyReducerMinRate");
const maxRate = await this.settingsService().getSettingsKey("streamLatencyReducerMaxRate");
const minThreshold = await this.settingsService().getSettingsKey("streamLatencyReducerMinThreshold");
const maxThreshold = await this.settingsService().getSettingsKey("streamLatencyReducerMaxThreshold");
return { minRate, maxRate, minThreshold, maxThreshold };
}

// Calculates latency based on average of past latency snapshots
private computeLatency(video: HTMLVideoElement): number {
const computedLatency = this.kickUtils().getLatency(video);
this.latencyTimings.value.push(computedLatency);

// Reset timings array if experiences sudden increase in latency
if (
this.latencyTimings.value.length > 1 &&
computedLatency - this.latencyTimings.value[this.latencyTimings.value.length - 2] > 2
) {
this.latencyTimings.value = [computedLatency];
}

const numberOfSamples = 10;
if (this.latencyTimings.value.length > numberOfSamples) this.latencyTimings.value.shift();

return (
this.latencyTimings.value.reduce((accumulator, currentValue) => accumulator + currentValue, 0) /
this.latencyTimings.value.length
);
}

private getPlayer() {
const video = document.querySelector("video");
if (!video || !this.kickUtils().isLiveVideo(video)) {
return null;
}
return video;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export default class StreamLatencyModule extends KickModule {
private latencyCounter = signal(-1);
private isLiveState = signal(false);
private updateInterval: NodeJS.Timeout | undefined;
private playbackRate = signal(1);
private latencyTimings = signal<number[]>([]);

readonly config: KickModuleConfig = {
name: "stream-latency",
Expand All @@ -31,6 +33,8 @@ export default class StreamLatencyModule extends KickModule {
this.logger.debug("Found multiple elements of chat room");
}

this.watchPlaybackRate();

if (this.updateInterval) clearInterval(this.updateInterval);
this.updateInterval = setInterval(() => this.updateLatency(), 1000);

Expand All @@ -43,6 +47,7 @@ export default class StreamLatencyModule extends KickModule {
<LatencyComponent
isLive={this.isLiveState}
latencyCounter={this.latencyCounter}
playbackRate={this.playbackRate}
click={this.resetPlayer.bind(this)}
/>,
span,
Expand All @@ -62,10 +67,32 @@ export default class StreamLatencyModule extends KickModule {
}

private computeLatency(video: HTMLVideoElement): number {
const { currentTime, buffered } = video;
if (buffered.length === 0) return -1;
const bufferEnd = buffered.end(buffered.length - 1);
return bufferEnd - currentTime;
const computedLatency = this.kickUtils().getLatency(video);
this.latencyTimings.value.push(computedLatency);

// Reset timings array if experiences sudden increase in latency
if (
this.latencyTimings.value.length > 1 &&
computedLatency - this.latencyTimings.value[this.latencyTimings.value.length - 2] > 2
) {
this.latencyTimings.value = [computedLatency];
}

const numberOfSamples = 10;
if (this.latencyTimings.value.length > numberOfSamples) this.latencyTimings.value.shift();

return (
this.latencyTimings.value.reduce((accumulator, currentValue) => accumulator + currentValue, 0) /
this.latencyTimings.value.length
Comment on lines +84 to +86
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The averaging calculation will fail when latencyTimings.value.length is 0, resulting in a division by zero and returning NaN. Although this shouldn't happen in normal flow, it's safer to add a guard:

const length = this.latencyTimings.value.length;
if (length === 0) return -1;
return this.latencyTimings.value.reduce((accumulator, currentValue) => accumulator + currentValue, 0) / length;
Suggested change
return (
this.latencyTimings.value.reduce((accumulator, currentValue) => accumulator + currentValue, 0) /
this.latencyTimings.value.length
const length = this.latencyTimings.value.length;
if (length === 0) return -1;
return (
this.latencyTimings.value.reduce((accumulator, currentValue) => accumulator + currentValue, 0) / length

Copilot uses AI. Check for mistakes.
);
}

private watchPlaybackRate() {
const video = this.getVideoElement();
if (!video) return;
video.addEventListener("ratechange", () => {
this.playbackRate.value = video.playbackRate;
});
}

private resetPlayer(): void {
Expand Down
Loading