mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(voice): add audio bitrate guild features and 96 kbps cap (#2703)
This commit is contained in:
@@ -9801,7 +9801,7 @@
|
||||
},
|
||||
"GuildFeatureSchema": {
|
||||
"type": "string",
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"AddGuildMembersAdminBulkJobCreateRequest": {
|
||||
"type": "object",
|
||||
|
||||
@@ -147,6 +147,9 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
|
||||
@@ -14,6 +14,9 @@ use maud::{Markup, html};
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
Permissions,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
@@ -260,13 +260,17 @@ export class ChannelOperationsService {
|
||||
validateCapacity: requestedParentId !== null && requestedParentId !== (channel.parentId ?? null),
|
||||
});
|
||||
}
|
||||
let nextBitrate = channel.bitrate;
|
||||
if (data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
nextBitrate = data.bitrate === null ? null : clampVoiceChannelBitrate(data.bitrate, guild.features ?? []);
|
||||
}
|
||||
const updatedChannelData = {
|
||||
...channel.toRow(),
|
||||
name: channelName,
|
||||
topic: data.topic !== undefined ? data.topic : channel.topic,
|
||||
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
||||
parent_id: requestedParentId,
|
||||
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
|
||||
bitrate: nextBitrate,
|
||||
user_limit:
|
||||
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.user_limit
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, GuildFeatures, resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_CHANNELS_PER_CATEGORY,
|
||||
MAX_GUILD_CHANNELS,
|
||||
@@ -119,12 +119,16 @@ export class ChannelOperationsService {
|
||||
);
|
||||
}
|
||||
let channelName = params.data.name;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
let guildFeatures: Array<string> | null = null;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_VOICE) {
|
||||
const guildData = await this.gatewayService.getGuildData({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
});
|
||||
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
guildFeatures = guildData.features;
|
||||
}
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(channelName);
|
||||
}
|
||||
@@ -156,7 +160,10 @@ export class ChannelOperationsService {
|
||||
content_warning_level: requestedContentWarningLevel,
|
||||
content_warning_text: requestedContentWarningText,
|
||||
rate_limit_per_user: params.data.rate_limit_per_user ?? 0,
|
||||
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
|
||||
bitrate:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
? resolveVoiceChannelBitrate(params.data.bitrate, guildFeatures)
|
||||
: null,
|
||||
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
|
||||
voice_connection_limit:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
GuildSplashCardAlignment,
|
||||
GuildVerificationLevel,
|
||||
JoinSourceTypes,
|
||||
resolveVoiceChannelBitrate,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_GUILD_CHANNELS,
|
||||
MAX_GUILD_ROLES,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -934,7 +936,14 @@ export class GuildOperationsService {
|
||||
addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0);
|
||||
addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1);
|
||||
addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0);
|
||||
addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000);
|
||||
addChannel(
|
||||
generalVoiceId,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
DEFAULT_VOICE_CHANNEL_NAME,
|
||||
voiceCategoryId,
|
||||
0,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildRoles.insert({
|
||||
guild_id: guildId,
|
||||
@@ -1105,7 +1114,7 @@ export class GuildOperationsService {
|
||||
content_warning_level: null,
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
|
||||
bitrate: isVoice ? (channel.bitrate ?? 64000) : null,
|
||||
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
|
||||
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
|
||||
voice_connection_limit: isVoice
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
@@ -25,6 +26,14 @@ describe('Guild Channel Management', () => {
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
async function addGuildFeaturesForTesting(guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder<{
|
||||
success: boolean;
|
||||
}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features})
|
||||
.execute();
|
||||
}
|
||||
describe('Channel Name Updates', () => {
|
||||
test('should normalize channel name with spaces to hyphens', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -582,7 +591,7 @@ describe('Guild Channel Management', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('should reject bitrate above maximum (320000)', async () => {
|
||||
test('should reject bitrate above maximum (384000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -594,7 +603,7 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384001})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
@@ -678,7 +687,7 @@ describe('Guild Channel Management', () => {
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(8000);
|
||||
});
|
||||
test('should accept maximum bitrate (320000)', async () => {
|
||||
test('should clamp bitrate to 96000 without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -690,9 +699,74 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(320000);
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should clamp bitrate to the feature the guild holds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_256_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(256000);
|
||||
});
|
||||
test('should accept maximum bitrate (384000) with the 384 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_384_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(384000);
|
||||
});
|
||||
test('should store the default bitrate on a new voice channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
expect(voiceChannel.bitrate).toBe(64000);
|
||||
});
|
||||
test('should clamp bitrate on create without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should keep bitrate on create with the 128 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 128000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(128000);
|
||||
});
|
||||
test('should accept maximum user limit (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -734,6 +808,7 @@ describe('Guild Channel Management', () => {
|
||||
test('should update both bitrate and user limit together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
|
||||
@@ -27348,8 +27348,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27442,8 +27442,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27526,8 +27526,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27610,8 +27610,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -28041,6 +28041,9 @@
|
||||
"x-enumNames": [
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
@@ -28071,6 +28074,9 @@
|
||||
"x-enumDescriptions": [
|
||||
"Guild can have an animated icon",
|
||||
"Guild can have an animated banner",
|
||||
"Guild can set a voice channel bitrate of up to 128 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 256 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 384 kbps",
|
||||
"Guild can have a banner",
|
||||
"Guild has the in-app one-click emoji clone shortcut disabled for non-members",
|
||||
"Guild has the in-app one-click sticker clone shortcut disabled for non-members",
|
||||
@@ -28098,7 +28104,7 @@
|
||||
"Guild has large guild overrides enabled",
|
||||
"Guild has increased member capacity enabled"
|
||||
],
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"ChannelPartialResponse": {
|
||||
"type": "object",
|
||||
@@ -30329,8 +30335,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30397,8 +30403,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30465,8 +30471,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30533,8 +30539,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
|
||||
+13
-6
@@ -16,8 +16,10 @@ import {MatureContentSection} from '@app/features/channel/components/modals/chan
|
||||
import {RtcRegionSelect} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect';
|
||||
import {SlowmodeControl} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl';
|
||||
import {
|
||||
BITRATE_KBPS_DEFAULT,
|
||||
CHANNEL_OVERVIEW_TAB_ID,
|
||||
type FormInputs,
|
||||
getMaxBitrateKbps,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import {
|
||||
VoiceConnectionLimitControl,
|
||||
@@ -81,6 +83,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
guildId !== null ? Permission.can(Permissions.UPDATE_RTC_REGION, {guildId, channelId}) : false;
|
||||
const canManageChannel = guildId !== null ? Permission.can(Permissions.MANAGE_CHANNELS, {guildId, channelId}) : false;
|
||||
const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE;
|
||||
const maxBitrateKbps = getMaxBitrateKbps(guild?.features);
|
||||
const [rtcRegions, setRtcRegions] = useState<Array<ChannelRtcRegion>>([]);
|
||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||
const form = useForm<FormInputs>({
|
||||
@@ -92,7 +95,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: null,
|
||||
content_warning_level: ContentWarningLevel.INHERIT,
|
||||
content_warning_text: '',
|
||||
bitrate: 64,
|
||||
bitrate: BITRATE_KBPS_DEFAULT,
|
||||
user_limit: 0,
|
||||
voice_connection_limit: VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
rtc_region: null,
|
||||
@@ -107,7 +110,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: channel.nsfwOverride,
|
||||
content_warning_level: channel.contentWarningLevel ?? ContentWarningLevel.INHERIT,
|
||||
content_warning_text: channel.contentWarningText ?? '',
|
||||
bitrate: channel.bitrate ? Math.round(channel.bitrate / 1000) : 64,
|
||||
bitrate: Math.min(channel.bitrate ? Math.round(channel.bitrate / 1000) : BITRATE_KBPS_DEFAULT, maxBitrateKbps),
|
||||
user_limit: channel.userLimit ?? 0,
|
||||
voice_connection_limit: channel.voiceConnectionLimit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
rtc_region: channel.rtcRegion ?? null,
|
||||
@@ -184,7 +187,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
updateData.rate_limit_per_user = data.slowmode;
|
||||
}
|
||||
if (channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
updateData.bitrate = (data.bitrate ?? 64) * 1000;
|
||||
updateData.bitrate = Math.min(data.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps) * 1000;
|
||||
updateData.user_limit = data.user_limit;
|
||||
updateData.voice_connection_limit = data.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT;
|
||||
} else if (channel.type === ChannelTypes.GUILD_LINK) {
|
||||
@@ -216,7 +219,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: data.nsfw_override,
|
||||
content_warning_level: data.content_warning_level,
|
||||
content_warning_text: data.content_warning_text ?? '',
|
||||
bitrate: data.bitrate ?? currentValues.bitrate ?? 64,
|
||||
bitrate: Math.min(data.bitrate ?? currentValues.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps),
|
||||
user_limit: data.user_limit ?? currentValues.user_limit ?? 0,
|
||||
voice_connection_limit:
|
||||
data.voice_connection_limit ?? currentValues.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
@@ -224,7 +227,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
});
|
||||
ToastCommands.createToast({type: 'success', children: <Trans>Channel updated</Trans>});
|
||||
},
|
||||
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues],
|
||||
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues, maxBitrateKbps],
|
||||
);
|
||||
const {handleSubmit: handleSave} = useFormSubmit({
|
||||
form,
|
||||
@@ -309,7 +312,11 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
{showVoiceSection && (
|
||||
<div className={styles.settingsGroup} data-flx="channel.channel-tabs.channel-overview-tab.settings-group--3">
|
||||
{canManageChannel && (
|
||||
<VoiceSettings form={form} data-flx="channel.channel-tabs.channel-overview-tab.voice-settings" />
|
||||
<VoiceSettings
|
||||
form={form}
|
||||
maxBitrateKbps={maxBitrateKbps}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings"
|
||||
/>
|
||||
)}
|
||||
{canUpdateRtcRegion && (
|
||||
<RtcRegionSelect
|
||||
|
||||
+14
-13
@@ -3,10 +3,10 @@
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow';
|
||||
import {
|
||||
BITRATE_KBPS_MARKERS,
|
||||
BITRATE_KBPS_MAX,
|
||||
BITRATE_KBPS_DEFAULT,
|
||||
BITRATE_KBPS_MIN,
|
||||
type FormInputs,
|
||||
getBitrateKbpsMarkers,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ const CONNECTION_LIMIT_VALUE_DESCRIPTOR = msg({
|
||||
|
||||
interface VoiceSettingsProps {
|
||||
form: UseFormReturn<FormInputs>;
|
||||
maxBitrateKbps: number;
|
||||
}
|
||||
|
||||
const formatIntegerValue = (value: number): string => String(Math.round(value));
|
||||
@@ -66,15 +67,14 @@ const formatConnectionLimitMarker = (value: number): string | null => {
|
||||
|
||||
const VoiceBitrateSlider: React.FC<{
|
||||
value: number | undefined;
|
||||
maxBitrateKbps: number;
|
||||
onChange: (value: number) => void;
|
||||
}> = ({value, onChange}) => {
|
||||
}> = ({value, maxBitrateKbps, onChange}) => {
|
||||
const {i18n} = useLingui();
|
||||
const voiceQualityLabel = i18n._(VOICE_QUALITY_DESCRIPTOR);
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
let currentValue = value;
|
||||
if (typeof currentValue !== 'number') {
|
||||
currentValue = 64;
|
||||
}
|
||||
const factoryDefaultValue = Math.min(BITRATE_KBPS_DEFAULT, maxBitrateKbps);
|
||||
const currentValue = Math.min(typeof value === 'number' ? value : BITRATE_KBPS_DEFAULT, maxBitrateKbps);
|
||||
return (
|
||||
<SettingsControlRow
|
||||
label={voiceQualityLabel}
|
||||
@@ -88,18 +88,18 @@ const VoiceBitrateSlider: React.FC<{
|
||||
<Slider
|
||||
value={currentValue}
|
||||
defaultValue={currentValue}
|
||||
factoryDefaultValue={64}
|
||||
factoryDefaultValue={factoryDefaultValue}
|
||||
minValue={BITRATE_KBPS_MIN}
|
||||
maxValue={BITRATE_KBPS_MAX}
|
||||
maxValue={maxBitrateKbps}
|
||||
step={1}
|
||||
markers={[...BITRATE_KBPS_MARKERS]}
|
||||
markers={getBitrateKbpsMarkers(maxBitrateKbps)}
|
||||
ariaLabel={voiceQualityLabel}
|
||||
ariaValueText={i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(currentValue)})}
|
||||
onMarkerRender={formatIntegerValue}
|
||||
onValueRender={(kilobits) => i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(kilobits)})}
|
||||
onValueChange={(kilobits) => onChange(Math.round(kilobits))}
|
||||
showResetButton={true}
|
||||
onReset={() => onChange(64)}
|
||||
onReset={() => onChange(factoryDefaultValue)}
|
||||
resetTooltip={resetSliderLabel}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.slider"
|
||||
/>
|
||||
@@ -108,7 +108,7 @@ const VoiceBitrateSlider: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form, maxBitrateKbps}) => {
|
||||
const {i18n} = useLingui();
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
const participantLimitLabel = i18n._(PARTICIPANT_LIMIT_LABEL_DESCRIPTOR);
|
||||
@@ -121,6 +121,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
render={({field}) => (
|
||||
<VoiceBitrateSlider
|
||||
value={field.value}
|
||||
maxBitrateKbps={maxBitrateKbps}
|
||||
onChange={field.onChange}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.change"
|
||||
/>
|
||||
@@ -172,7 +173,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceConnectionLimitControl: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
export const VoiceConnectionLimitControl: React.FC<{form: UseFormReturn<FormInputs>}> = ({form}) => {
|
||||
const {i18n} = useLingui();
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
const connectionLimitLabel = i18n._(CONNECTION_LIMIT_LABEL_DESCRIPTOR);
|
||||
|
||||
+13
-3
@@ -2,7 +2,8 @@
|
||||
|
||||
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
|
||||
import {getMaxVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {VOICE_CHANNEL_BITRATE_DEFAULT, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
export interface FormInputs {
|
||||
name: string;
|
||||
@@ -20,8 +21,17 @@ export interface FormInputs {
|
||||
|
||||
export const CHANNEL_OVERVIEW_TAB_ID = 'overview';
|
||||
export const BITRATE_KBPS_MIN = VOICE_CHANNEL_BITRATE_MIN / 1000;
|
||||
export const BITRATE_KBPS_MAX = VOICE_CHANNEL_BITRATE_MAX / 1000;
|
||||
export const BITRATE_KBPS_MARKERS: ReadonlyArray<number> = [BITRATE_KBPS_MIN, 64, 128, 256, BITRATE_KBPS_MAX];
|
||||
export const BITRATE_KBPS_DEFAULT = VOICE_CHANNEL_BITRATE_DEFAULT / 1000;
|
||||
|
||||
export function getMaxBitrateKbps(guildFeatures: Iterable<string> | null | undefined): number {
|
||||
return getMaxVoiceChannelBitrate(guildFeatures) / 1000;
|
||||
}
|
||||
|
||||
export function getBitrateKbpsMarkers(maxKbps: number): Array<number> {
|
||||
const markers = [BITRATE_KBPS_MIN, BITRATE_KBPS_DEFAULT, 128, 256].filter((marker) => marker < maxKbps);
|
||||
markers.push(maxKbps);
|
||||
return markers;
|
||||
}
|
||||
export const MAX_TOPIC_LENGTH = 1024;
|
||||
|
||||
export interface RtcRegionOption extends ComboboxOption<string | null> {
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {selectChannel} from '@app/features/navigation/commands/NavigationCommands';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
export interface FormInputs {
|
||||
name: string;
|
||||
@@ -52,7 +55,7 @@ export async function createChannel(guildId: string, data: FormInputs, parentId?
|
||||
url: data.url,
|
||||
type: channelType,
|
||||
parent_id: parentId || null,
|
||||
bitrate: channelType === ChannelTypes.GUILD_VOICE ? 64000 : null,
|
||||
bitrate: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_BITRATE_DEFAULT : null,
|
||||
user_limit: channelType === ChannelTypes.GUILD_VOICE ? 0 : null,
|
||||
voice_connection_limit: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
|
||||
});
|
||||
|
||||
@@ -143,6 +143,21 @@ const VERIFIED_GUILD_DESCRIPTOR = msg({
|
||||
message: 'Verified community',
|
||||
comment: 'Audit log community feature label for verified communities.',
|
||||
});
|
||||
const AUDIO_BITRATE_128_KBPS_DESCRIPTOR = msg({
|
||||
message: '128 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const AUDIO_BITRATE_256_KBPS_DESCRIPTOR = msg({
|
||||
message: '256 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const AUDIO_BITRATE_384_KBPS_DESCRIPTOR = msg({
|
||||
message: '384 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const VIP_VOICE_DESCRIPTOR = msg({
|
||||
message: 'VIP voice',
|
||||
comment: 'Audit log community feature label for elevated voice capacity/quality.',
|
||||
@@ -404,6 +419,9 @@ export function getSplashAlignmentLabel(value: unknown, i18n: I18n): string | nu
|
||||
const featureLabelMap: Record<string, MessageDescriptor> = {
|
||||
[GuildFeatures.ANIMATED_ICON]: ANIMATED_ICON_DESCRIPTOR,
|
||||
[GuildFeatures.ANIMATED_BANNER]: ANIMATED_BANNER_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_128_KBPS]: AUDIO_BITRATE_128_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_256_KBPS]: AUDIO_BITRATE_256_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_384_KBPS]: AUDIO_BITRATE_384_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.BANNER]: BANNER_DESCRIPTOR,
|
||||
[GuildFeatures.CLONE_EMOJI_DISABLED]: EMOJI_CLONING_DISABLED_DESCRIPTOR,
|
||||
[GuildFeatures.CLONE_STICKER_DISABLED]: STICKER_CLONING_DISABLED_DESCRIPTOR,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
||||
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {getVoiceContextEntranceSoundScope} from '@app/features/notification/utils/EntranceSoundScopes';
|
||||
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||
@@ -117,6 +118,7 @@ import {
|
||||
getActiveVoiceProcessingMode,
|
||||
resolveVoiceProcessingFromStateForDeviceLabel,
|
||||
} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
import {resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {
|
||||
VoiceEngineV2AudioControls,
|
||||
VoiceEngineV2AudioMode,
|
||||
@@ -606,7 +608,9 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||
}
|
||||
|
||||
private getMicrophonePublishOptions(channelId: string | null): TrackPublishOptions | undefined {
|
||||
const channelBitrate = channelId ? Channels.getChannel(channelId)?.bitrate : null;
|
||||
const channel = channelId ? Channels.getChannel(channelId) : null;
|
||||
const guild = channel?.guildId ? Guilds.getGuild(channel.guildId) : null;
|
||||
const channelBitrate = resolveVoiceChannelBitrate(channel?.bitrate, guild?.features);
|
||||
const profile = resolveVoiceProcessingFromStateForDeviceLabel(VoiceSettings, this.resolveActiveInputDeviceLabel());
|
||||
return buildMicrophonePublishOptions(channelBitrate, profile.mode);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
buildMicrophonePublishOptions,
|
||||
@@ -51,6 +52,15 @@ describe('buildMicrophonePublishOptions', () => {
|
||||
red: true,
|
||||
});
|
||||
});
|
||||
it('pins a call outside a guild to 64 kbps', () => {
|
||||
expect(buildMicrophonePublishOptions(resolveVoiceChannelBitrate(null, null), 'voice')).toEqual({
|
||||
audioPreset: {
|
||||
maxBitrate: 64000,
|
||||
priority: 'high',
|
||||
},
|
||||
red: true,
|
||||
});
|
||||
});
|
||||
it('does not force stereo below the stereo voice threshold', () => {
|
||||
expect(buildMicrophonePublishOptions(96000, 'studio')).toEqual({
|
||||
audioPreset: {
|
||||
|
||||
@@ -370,7 +370,7 @@ Every field is optional, and an omitted field preserves its current value. The f
|
||||
| topic?<sup>2</sup> | ?string | The topic of the channel (1-1,024 characters), or null to clear it |
|
||||
| url?<sup>3</sup> | ?string | The destination of a guild link channel (1-2,048 characters, `http` or `https`), or null to clear it |
|
||||
| parent_id?<sup>4</sup> | ?snowflake | The ID of the parent category, where null or the snowflake `0` moves the channel to the top level |
|
||||
| bitrate?<sup>5</sup> | ?integer | The voice bitrate in bits per second (8,000-320,000) |
|
||||
| bitrate?<sup>5</sup><sup>13</sup> | ?integer | The voice bitrate in bits per second (8,000-384,000) |
|
||||
| user_limit?<sup>5</sup> | ?integer | The voice occupancy limit (0-99, where `0` configures no limit) |
|
||||
| voice_connection_limit?<sup>5</sup> | ?integer | The number of simultaneous voice connections one user may hold (1-100) |
|
||||
| permission_overwrites?<sup>6</sup> | array[[permission overwrite](#permission-overwrite-object) object] | The complete overwrite collection that replaces the stored one |
|
||||
@@ -405,6 +405,8 @@ Every field is optional, and an omitted field preserves its current value. The f
|
||||
|
||||
<sup>12</sup> Applied only to a guild voice channel. Supplying the field at all, including as null, requires [UPDATE_RTC_REGION](/http-api/permissions/)
|
||||
|
||||
<sup>13</sup> The stored value is capped at 96,000 unless the guild holds an [audio bitrate feature](/http-api/guilds/#guild-features). A higher value is stored at the cap rather than rejected
|
||||
|
||||
A `url` that is not an absolute `http` or `https` URL with a host returns 400 `INVALID_FORM_BODY` with the code `INVALID_URL_FORMAT` on the path `url`.
|
||||
|
||||
A parent that does not exist in the same guild returns 400 `INVALID_FORM_BODY` with the code `INVALID_PARENT_CHANNEL` on the path `parent_id`. A parent that is not a category returns `PARENT_MUST_BE_CATEGORY` the same way.
|
||||
|
||||
@@ -157,7 +157,7 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
|
||||
| topic? | ?string | Channel topic (1-1024 characters) |
|
||||
| url?<sup>3</sup> | ?string | Destination URL for a link channel |
|
||||
| parent_id?<sup>4</sup> | ?snowflake | Parent category |
|
||||
| bitrate?<sup>5</sup> | ?integer | Voice channel bitrate in bits per second (8000-320000, default 64000) |
|
||||
| bitrate?<sup>5</sup><sup>11</sup> | ?integer | Voice channel bitrate in bits per second (8000-384000, default 64000) |
|
||||
| user_limit?<sup>5</sup><sup>6</sup> | ?integer | Voice channel occupancy limit (0-99, default 0) |
|
||||
| voice_connection_limit?<sup>5</sup> | ?integer | Simultaneous voice connections permitted for one user in a voice channel (1-100, default 5) |
|
||||
| rate_limit_per_user? | ?integer | Slowmode interval in seconds (0-21600, default 0) |
|
||||
@@ -187,6 +187,8 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
|
||||
|
||||
<sup>10</sup> The value is trimmed, and an empty result is stored as null
|
||||
|
||||
<sup>11</sup> The stored value is capped at 96000 unless the guild holds an [audio bitrate feature](/http-api/guilds/#guild-features). A higher value is stored at the cap rather than rejected
|
||||
|
||||
A value longer than 10000 characters is rejected with `STRING_LENGTH_INVALID` before normalisation runs. A parent that does not exist in this guild returns `INVALID_PARENT_CHANNEL`, and one that is not a category returns `PARENT_MUST_BE_CATEGORY`.
|
||||
|
||||
The guild holds at most the instance-configured `max_guild_channels` [limit](/http-api/instance/#limit-keys), defaulting to 500, and a parent category holds at most `max_channels_per_category`, defaulting to 50. Reaching the guild limit returns 400 `MAX_GUILD_CHANNELS` and reaching the category limit returns 400 `MAX_CATEGORY_CHANNELS`. Each message has the resolved limit. The new channel is created with no RTC region.
|
||||
|
||||
@@ -242,7 +242,7 @@ A creation template describes the roles and channels that [Create guild](#create
|
||||
| topic? | ?string | Channel topic |
|
||||
| position | integer | The sort position of the channel |
|
||||
| parent_id?<sup>3</sup> | ?decimal string \| integer | Template-local parent category ID |
|
||||
| bitrate?<sup>4</sup> | ?integer | Voice bitrate, defaulting to 64000 for a voice channel |
|
||||
| bitrate?<sup>4</sup> | ?integer | Voice bitrate, defaulting to 64000 for a voice channel and capped at 96000 |
|
||||
| user_limit?<sup>4</sup> | ?integer | Voice occupancy limit, defaulting to 0 for a voice channel |
|
||||
| voice_connection_limit?<sup>4</sup> | ?integer | Per-user voice connection limit, defaulting to 5 for a voice channel |
|
||||
| nsfw? | boolean | Whether the channel is age restricted (default false) |
|
||||
@@ -378,6 +378,9 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
| --- | --- |
|
||||
| ANIMATED_ICON | Guild can use an animated icon |
|
||||
| ANIMATED_BANNER | Guild can use an animated banner |
|
||||
| AUDIO_BITRATE_128_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 128000 |
|
||||
| AUDIO_BITRATE_256_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 256000 |
|
||||
| AUDIO_BITRATE_384_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 384000 |
|
||||
| BANNER | Guild can use a banner |
|
||||
| CLONE_EMOJI_DISABLED<sup>1</sup> | The one-click emoji clone shortcut is disabled for non-members |
|
||||
| CLONE_STICKER_DISABLED<sup>1</sup> | The one-click sticker clone shortcut is disabled for non-members |
|
||||
@@ -415,6 +418,8 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
|
||||
<sup>5</sup> The feature raises the default `max_guild_members` limit from 1000000 to 10000000 before the ordered [limit configuration](/http-api/instance/#limit-keys) is checked, so the raised ceiling applies even when no configured rule names the feature
|
||||
|
||||
<sup>6</sup> A guild holding none of the three caps a voice channel `bitrate` at 96000. The highest feature the guild holds wins, and a write above the cap is stored at the cap rather than rejected. A guild that loses the feature keeps the stored value, and the next write to `bitrate` lowers it to the cap
|
||||
|
||||
## Custom invite URL object
|
||||
|
||||
A custom invite URL object reports the guild's current custom invite code and how many times the invite behind it has been used. [Get guild custom invite URL](#get-guild-custom-invite-url) returns it.
|
||||
|
||||
@@ -79,6 +79,8 @@ A call never acks. A refused placement into a direct message or group direct mes
|
||||
|
||||
A guild voice channel stores its `bitrate`, `user_limit`, `voice_connection_limit`, and `rtc_region` on the [channel object](/http-api/channels/#channel-object). It also has ordinary messages, pins, and slowmode, so its text history is read and written through the [Messages resource](/http-api/messages/).
|
||||
|
||||
A new voice channel stores a `bitrate` of 64000. The ceiling is 96000, and the `AUDIO_BITRATE_128_KBPS`, `AUDIO_BITRATE_256_KBPS`, and `AUDIO_BITRATE_384_KBPS` [guild features](/http-api/guilds/#guild-features) raise it to 128000, 256000, and 384000. A direct message and a group direct message call carry no `bitrate` and always run at 64000.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Effect on voice |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
clampVoiceChannelBitrate,
|
||||
GuildFeatures,
|
||||
getMaxVoiceChannelBitrate,
|
||||
resolveVoiceChannelBitrate,
|
||||
} from './GuildConstants';
|
||||
|
||||
describe('getMaxVoiceChannelBitrate', () => {
|
||||
it('caps a guild holding no audio bitrate feature at 96 kbps', () => {
|
||||
expect(getMaxVoiceChannelBitrate([])).toBe(96000);
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.VIP_VOICE])).toBe(96000);
|
||||
});
|
||||
it('caps a call outside a guild at 96 kbps', () => {
|
||||
expect(getMaxVoiceChannelBitrate(null)).toBe(96000);
|
||||
expect(getMaxVoiceChannelBitrate(undefined)).toBe(96000);
|
||||
});
|
||||
it('raises the cap to the feature the guild holds', () => {
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_128_KBPS])).toBe(128000);
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
|
||||
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_384_KBPS])).toBe(384000);
|
||||
});
|
||||
it('takes the highest feature when a guild holds several', () => {
|
||||
expect(
|
||||
getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_384_KBPS, GuildFeatures.AUDIO_BITRATE_128_KBPS]),
|
||||
).toBe(384000);
|
||||
expect(
|
||||
getMaxVoiceChannelBitrate(new Set([GuildFeatures.AUDIO_BITRATE_128_KBPS, GuildFeatures.AUDIO_BITRATE_256_KBPS])),
|
||||
).toBe(256000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampVoiceChannelBitrate', () => {
|
||||
it('clamps above the cap the guild holds', () => {
|
||||
expect(clampVoiceChannelBitrate(384000, [])).toBe(96000);
|
||||
expect(clampVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_128_KBPS])).toBe(128000);
|
||||
expect(clampVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_384_KBPS])).toBe(384000);
|
||||
});
|
||||
it('leaves a value under the cap alone', () => {
|
||||
expect(clampVoiceChannelBitrate(64000, [])).toBe(64000);
|
||||
expect(clampVoiceChannelBitrate(8000, [])).toBe(8000);
|
||||
});
|
||||
it('raises a value under the minimum', () => {
|
||||
expect(clampVoiceChannelBitrate(1000, [])).toBe(8000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVoiceChannelBitrate', () => {
|
||||
it('falls back to 64 kbps when the channel stores no bitrate', () => {
|
||||
expect(resolveVoiceChannelBitrate(null, null)).toBe(64000);
|
||||
expect(resolveVoiceChannelBitrate(undefined, [])).toBe(64000);
|
||||
expect(resolveVoiceChannelBitrate(0, [])).toBe(64000);
|
||||
expect(resolveVoiceChannelBitrate(Number.NaN, [])).toBe(64000);
|
||||
});
|
||||
it('clamps a stored bitrate to what the guild currently holds', () => {
|
||||
expect(resolveVoiceChannelBitrate(384000, [])).toBe(96000);
|
||||
expect(resolveVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_BITRATE_MAX_128_KBPS,
|
||||
VOICE_CHANNEL_BITRATE_MAX_256_KBPS,
|
||||
VOICE_CHANNEL_BITRATE_MAX_384_KBPS,
|
||||
VOICE_CHANNEL_BITRATE_MAX_STANDARD,
|
||||
VOICE_CHANNEL_BITRATE_MIN,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const GuildVerificationLevel = {
|
||||
@@ -97,6 +105,9 @@ export const CONTENT_WARNING_TEXT_MAX_LENGTH = 200;
|
||||
export const GuildFeatures = {
|
||||
ANIMATED_ICON: 'ANIMATED_ICON',
|
||||
ANIMATED_BANNER: 'ANIMATED_BANNER',
|
||||
AUDIO_BITRATE_128_KBPS: 'AUDIO_BITRATE_128_KBPS',
|
||||
AUDIO_BITRATE_256_KBPS: 'AUDIO_BITRATE_256_KBPS',
|
||||
AUDIO_BITRATE_384_KBPS: 'AUDIO_BITRATE_384_KBPS',
|
||||
BANNER: 'BANNER',
|
||||
CLONE_EMOJI_DISABLED: 'CLONE_EMOJI_DISABLED',
|
||||
CLONE_STICKER_DISABLED: 'CLONE_STICKER_DISABLED',
|
||||
@@ -127,6 +138,37 @@ export const GuildFeatures = {
|
||||
|
||||
export type GuildFeature = ValueOf<typeof GuildFeatures>;
|
||||
|
||||
export function getMaxVoiceChannelBitrate(features: Iterable<string> | null | undefined): number {
|
||||
if (features == null) {
|
||||
return VOICE_CHANNEL_BITRATE_MAX_STANDARD;
|
||||
}
|
||||
let maximum = VOICE_CHANNEL_BITRATE_MAX_STANDARD;
|
||||
for (const feature of features) {
|
||||
if (feature === GuildFeatures.AUDIO_BITRATE_384_KBPS) {
|
||||
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_384_KBPS);
|
||||
} else if (feature === GuildFeatures.AUDIO_BITRATE_256_KBPS) {
|
||||
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_256_KBPS);
|
||||
} else if (feature === GuildFeatures.AUDIO_BITRATE_128_KBPS) {
|
||||
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_128_KBPS);
|
||||
}
|
||||
}
|
||||
return maximum;
|
||||
}
|
||||
|
||||
export function clampVoiceChannelBitrate(bitrate: number, features: Iterable<string> | null | undefined): number {
|
||||
return Math.min(Math.max(bitrate, VOICE_CHANNEL_BITRATE_MIN), getMaxVoiceChannelBitrate(features));
|
||||
}
|
||||
|
||||
export function resolveVoiceChannelBitrate(
|
||||
bitrate: number | null | undefined,
|
||||
features: Iterable<string> | null | undefined,
|
||||
): number {
|
||||
if (typeof bitrate !== 'number' || !Number.isFinite(bitrate) || bitrate <= 0) {
|
||||
return VOICE_CHANNEL_BITRATE_DEFAULT;
|
||||
}
|
||||
return clampVoiceChannelBitrate(Math.round(bitrate), features);
|
||||
}
|
||||
|
||||
export const JoinSourceTypes = {
|
||||
CREATOR: 0,
|
||||
INSTANT_INVITE: 1,
|
||||
|
||||
@@ -4,7 +4,12 @@ export const MAX_GUILDS_PREMIUM = 200;
|
||||
export const MAX_GUILD_CHANNELS = 500;
|
||||
export const MAX_CHANNELS_PER_CATEGORY = 50;
|
||||
export const VOICE_CHANNEL_BITRATE_MIN = 8000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX = 320000;
|
||||
export const VOICE_CHANNEL_BITRATE_DEFAULT = 64000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_STANDARD = 96000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_128_KBPS = 128000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_256_KBPS = 256000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX_384_KBPS = 384000;
|
||||
export const VOICE_CHANNEL_BITRATE_MAX = VOICE_CHANNEL_BITRATE_MAX_384_KBPS;
|
||||
export const VOICE_CHANNEL_USER_LIMIT_MIN = 0;
|
||||
export const VOICE_CHANNEL_USER_LIMIT_MAX = 99;
|
||||
export const VOICE_CHANNEL_CAMERA_USER_LIMIT = 25;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RTC_REGION_ID_MAX_LENGTH,
|
||||
RTC_REGION_ID_MIN_LENGTH,
|
||||
VOICE_CHANNEL_BITRATE_MAX,
|
||||
VOICE_CHANNEL_BITRATE_MAX_STANDARD,
|
||||
VOICE_CHANNEL_BITRATE_MIN,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_MAX,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_MIN,
|
||||
@@ -59,7 +60,9 @@ const ChannelCommonBase = z.object({
|
||||
.min(VOICE_CHANNEL_BITRATE_MIN)
|
||||
.max(VOICE_CHANNEL_BITRATE_MAX)
|
||||
.nullish()
|
||||
.describe(`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX})`),
|
||||
.describe(
|
||||
`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX}), clamped to ${VOICE_CHANNEL_BITRATE_MAX_STANDARD} unless the guild holds an AUDIO_BITRATE feature`,
|
||||
),
|
||||
user_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -41,6 +41,21 @@ export const GuildFeatureSchema = withOpenApiType(
|
||||
[
|
||||
[GuildFeatures.ANIMATED_ICON, 'ANIMATED_ICON', 'Guild can have an animated icon'],
|
||||
[GuildFeatures.ANIMATED_BANNER, 'ANIMATED_BANNER', 'Guild can have an animated banner'],
|
||||
[
|
||||
GuildFeatures.AUDIO_BITRATE_128_KBPS,
|
||||
'AUDIO_BITRATE_128_KBPS',
|
||||
'Guild can set a voice channel bitrate of up to 128 kbps',
|
||||
],
|
||||
[
|
||||
GuildFeatures.AUDIO_BITRATE_256_KBPS,
|
||||
'AUDIO_BITRATE_256_KBPS',
|
||||
'Guild can set a voice channel bitrate of up to 256 kbps',
|
||||
],
|
||||
[
|
||||
GuildFeatures.AUDIO_BITRATE_384_KBPS,
|
||||
'AUDIO_BITRATE_384_KBPS',
|
||||
'Guild can set a voice channel bitrate of up to 384 kbps',
|
||||
],
|
||||
[GuildFeatures.BANNER, 'BANNER', 'Guild can have a banner'],
|
||||
[
|
||||
GuildFeatures.CLONE_EMOJI_DISABLED,
|
||||
|
||||
Reference in New Issue
Block a user