diff --git a/app/app-services.ts b/app/app-services.ts index e3513c8c0d2b..5f9777cd7560 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -74,6 +74,7 @@ export { export { FacebookService } from 'services/platforms/facebook'; export { TikTokService } from 'services/platforms/tiktok'; export { TrovoService } from 'services/platforms/trovo'; +export { KickService } from 'services/platforms/kick'; export { RestreamService } from 'services/restream'; export { TwitterService } from 'services/integrations/twitter'; export { TwitterPlatformService } from 'services/platforms/twitter'; @@ -208,6 +209,7 @@ import { InstagramService } from 'services/platforms/instagram'; import { TwitchStudioImporterService } from 'services/ts-importer'; import { RemoteControlService } from 'services/api/remote-control-api'; import { UrlService } from 'services/hosts'; +import { KickService } from 'services/platforms/kick'; export const AppServices = { AppService, @@ -240,6 +242,7 @@ export const AppServices = { TwitchTagsService, TwitchContentClassificationService, TrovoService, + KickService, InstagramService, DismissablesService, HighlighterService, diff --git a/app/components-react/pages/onboarding/Connect.tsx b/app/components-react/pages/onboarding/Connect.tsx index d70690d6d088..f65c62f050ba 100644 --- a/app/components-react/pages/onboarding/Connect.tsx +++ b/app/components-react/pages/onboarding/Connect.tsx @@ -63,7 +63,7 @@ export function Connect() { // streamlabs and trovo are added separarely on markup below const platforms = RecordingModeService.views.isRecordingModeEnabled ? ['youtube'] - : ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok']; + : ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter']; const shouldAddTrovo = !RecordingModeService.views.isRecordingModeEnabled; @@ -132,7 +132,9 @@ export function Connect() { loading={loading} onClick={() => authPlatform(platform, afterLogin)} key={platform} - logoSize={['twitter', 'tiktok', 'youtube'].includes(platform) ? 15 : undefined} + logoSize={ + ['twitter', 'tiktok', 'youtube', 'kick'].includes(platform) ? 15 : undefined + } > %{platform}', { @@ -263,7 +265,9 @@ export class LoginModule { const result = await this.UserService.startAuth( platform, - ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) ? 'external' : 'internal', + ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) + ? 'external' + : 'internal', merge, ); diff --git a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx index c6fb8d9a1451..37ea80031882 100644 --- a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx +++ b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx @@ -22,7 +22,7 @@ export function PrimaryPlatformSelect() { isPrime: UserService.state.isPrime, })); const { loading, authInProgress, authPlatform, finishSLAuth } = useModule(LoginModule); - const platforms = ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok', 'trovo']; + const platforms = ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter', 'trovo']; const platformOptions = [ { value: 'twitch', @@ -54,6 +54,11 @@ export function PrimaryPlatformSelect() { label: 'TikTok', image: , }, + { + value: 'kick', + label: 'Kick', + image: , + }, ].filter(opt => { return linkedPlatforms.includes(opt.value as TPlatform); }); diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index 279e28b769c5..bec04ffb2125 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -27,6 +27,7 @@ class LiveDockController { private youtubeService = Services.YoutubeService; private facebookService = Services.FacebookService; private trovoService = Services.TrovoService; + private kickService = Services.KickService; private tiktokService = Services.TikTokService; private userService = Services.UserService; private customizationService = Services.CustomizationService; @@ -159,6 +160,7 @@ class LiveDockController { // Twitter & Tiktok don't support editing title after going live if (this.isPlatform('twitter') && !this.isRestreaming) return false; if (this.isPlatform('tiktok') && !this.isRestreaming) return false; + if (this.isPlatform('kick') && !this.isRestreaming) return false; return ( this.streamingService.views.isMidStreamMode || @@ -181,6 +183,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.streamPageUrl; if (this.platform === 'facebook') url = this.facebookService.streamPageUrl; if (this.platform === 'trovo') url = this.trovoService.streamPageUrl; + if (this.platform === 'kick') url = this.kickService.streamPageUrl; if (this.platform === 'tiktok') url = this.tiktokService.streamPageUrl; remote.shell.openExternal(url); } @@ -419,7 +422,7 @@ function LiveDock(p: { onLeft: boolean }) { ctrl.showEditStreamInfo()} className="icon-edit" /> )} - {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok']) && isStreaming && ( + {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok', 'kick']) && isStreaming && (
- {(isPlatform(['twitch', 'trovo', 'facebook']) || + {(isPlatform(['twitch', 'trovo', 'facebook', 'kick']) || (isPlatform(['youtube', 'twitter']) && isStreaming) || (isPlatform(['tiktok']) && isRestreaming)) && ( ctrl.refreshChat()}>{$t('Refresh Chat')} @@ -449,7 +452,8 @@ function LiveDock(p: { onLeft: boolean }) {
{!hideStyleBlockers && (isPlatform(['twitch', 'trovo']) || - (isStreaming && isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']))) && ( + (isStreaming && + isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']))) && (
{hasChatTabs && } @@ -465,7 +469,8 @@ function LiveDock(p: { onLeft: boolean }) {
)} {(!ctrl.platform || - (isPlatform(['youtube', 'facebook', 'twitter', 'tiktok']) && !isStreaming)) && ( + (isPlatform(['youtube', 'facebook', 'twitter', 'tiktok', 'kick']) && + !isStreaming)) && (
{!hideStyleBlockers && {$t('Your chat is currently offline')}} diff --git a/app/components-react/root/ShareStreamLink.m.less b/app/components-react/root/ShareStreamLink.m.less index 8583cf93568b..6425af9eb042 100644 --- a/app/components-react/root/ShareStreamLink.m.less +++ b/app/components-react/root/ShareStreamLink.m.less @@ -12,4 +12,9 @@ width: 16px; height: 16px; } + + :global(i.kick) { + width: 16px; + height: 16px; + } } diff --git a/app/components-react/shared/PlatformLogo.m.less b/app/components-react/shared/PlatformLogo.m.less index 481055810788..3009fb348aa5 100644 --- a/app/components-react/shared/PlatformLogo.m.less +++ b/app/components-react/shared/PlatformLogo.m.less @@ -77,3 +77,11 @@ background-size: contain; background-repeat: no-repeat; } +.kick { + background-image: url('https://slobs-cdn.streamlabs.com/media/kick-logo.png'); + display: inline-block; + width: 40px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; +} diff --git a/app/components-react/shared/PlatformLogo.tsx b/app/components-react/shared/PlatformLogo.tsx index bf63e5d3bddf..6d38ec5bfb57 100644 --- a/app/components-react/shared/PlatformLogo.tsx +++ b/app/components-react/shared/PlatformLogo.tsx @@ -35,6 +35,7 @@ export default function PlatformLogo(p: IProps & HTMLAttributes) { twitter: 'twitter', streamlabs: 'icon-streamlabs', instagram: 'instagram', + kick: 'kick', }[p.platform]; } const size = p.size && (sizeMap[p.size] ?? p.size); diff --git a/app/components-react/sidebar/NavTools.m.less b/app/components-react/sidebar/NavTools.m.less index 944a90cafc39..d12692d51031 100644 --- a/app/components-react/sidebar/NavTools.m.less +++ b/app/components-react/sidebar/NavTools.m.less @@ -156,6 +156,10 @@ width: 13px; height: 13px; } + &-kick { + width: 15px; + height: 15px; + } &-streamlabs { color: var(--teal) !important; } diff --git a/app/components-react/sidebar/PlatformIndicator.m.less b/app/components-react/sidebar/PlatformIndicator.m.less index 035e3f34cd69..d7372cb02fae 100644 --- a/app/components-react/sidebar/PlatformIndicator.m.less +++ b/app/components-react/sidebar/PlatformIndicator.m.less @@ -11,7 +11,12 @@ &-facebook { color: var(--facebook) !important; } - &-trovo, &-twitter, &-tiktok, &-instagram, &-youtube { + &-trovo, + &-twitter, + &-tiktok, + &-instagram, + &-youtube, + &-kick { width: 15px; height: 15px; } diff --git a/app/components-react/windows/MultistreamChatInfo.tsx b/app/components-react/windows/MultistreamChatInfo.tsx index 085dd07dd12f..f56543772328 100644 --- a/app/components-react/windows/MultistreamChatInfo.tsx +++ b/app/components-react/windows/MultistreamChatInfo.tsx @@ -57,6 +57,12 @@ export default function MultistreamChatInfo() { read: false, write: false, }, + { + icon: 'kick', + name: $t('Kick'), + read: true, + write: false, + }, ]; return ( diff --git a/app/components-react/windows/go-live/PlatformSettings.tsx b/app/components-react/windows/go-live/PlatformSettings.tsx index aa0cefef76db..b77aa55928e0 100644 --- a/app/components-react/windows/go-live/PlatformSettings.tsx +++ b/app/components-react/windows/go-live/PlatformSettings.tsx @@ -13,6 +13,7 @@ import { getDefined } from '../../../util/properties-type-guards'; import { TrovoEditStreamInfo } from './platforms/TrovoEditStreamInfo'; import { TwitterEditStreamInfo } from './platforms/TwitterEditStreamInfo'; import { InstagramEditStreamInfo } from './platforms/InstagramEditStreamInfo'; +import { KickEditStreamInfo } from './platforms/KickEditStreamInfo'; import AdvancedSettingsSwitch from './AdvancedSettingsSwitch'; export default function PlatformSettings() { @@ -104,6 +105,7 @@ export default function PlatformSettings() { {platform === 'tiktok' && isTikTokConnected && ( )} + {platform === 'kick' && } {platform === 'trovo' && } {platform === 'twitter' && ( diff --git a/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx new file mode 100644 index 000000000000..25a0722b75e4 --- /dev/null +++ b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { CommonPlatformFields } from '../CommonPlatformFields'; +import Form from '../../../shared/inputs/Form'; +import { createBinding, InputComponent } from '../../../shared/inputs'; +import PlatformSettingsLayout, { IPlatformComponentParams } from './PlatformSettingsLayout'; +import { IKickStartStreamOptions } from '../../../../services/platforms/kick'; + +/*** + * Stream Settings for Kick + */ +export const KickEditStreamInfo = InputComponent((p: IPlatformComponentParams<'kick'>) => { + function updateSettings(patch: Partial) { + p.onChange({ ...kickSettings, ...patch }); + } + + const kickSettings = p.value; + const bind = createBinding(kickSettings, newKickSettings => updateSettings(newKickSettings)); + + return ( +
+ + } + requiredFields={
} + /> + + ); +}); diff --git a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx index 1bf753460fa5..0efbbe59e47b 100644 --- a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx +++ b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx @@ -5,6 +5,7 @@ import { IYoutubeStartStreamOptions } from '../../../../services/platforms/youtu import { IFacebookStartStreamOptions } from '../../../../services/platforms/facebook'; import { ITikTokStartStreamOptions } from '../../../../services/platforms/tiktok'; import { ITrovoStartStreamOptions } from '../../../../services/platforms/trovo'; +import { IKickStartStreamOptions } from '../../../../services/platforms/kick'; export type TLayoutMode = 'singlePlatform' | 'multiplatformAdvanced' | 'multiplatformSimple'; @@ -36,6 +37,7 @@ export interface IPlatformSettings extends Partial> { facebook?: IFacebookStartStreamOptions; tiktok?: ITikTokStartStreamOptions; trovo?: ITrovoStartStreamOptions; + kick?: IKickStartStreamOptions; } export interface IPlatformComponentParams { diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index 5291cac81238..67c1fa05e58f 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -217,7 +217,7 @@ class StreamSettingsModule { } async platformMergeInline(platform: TPlatform) { - const mode = ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) + const mode = ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) ? 'external' : 'internal'; @@ -472,7 +472,7 @@ function Platform(p: { platform: TPlatform }) { style={{ backgroundColor: `var(--${platform})`, borderColor: 'transparent', - color: ['trovo', 'instagram'].includes(platform) ? 'black' : 'inherit', + color: ['trovo', 'instagram', 'kick'].includes(platform) ? 'black' : 'inherit', }} > {$t('Connect')} diff --git a/app/components/shared/PlatformLogo.m.less b/app/components/shared/PlatformLogo.m.less index b82d7edd2005..57afedab6ace 100644 --- a/app/components/shared/PlatformLogo.m.less +++ b/app/components/shared/PlatformLogo.m.less @@ -36,4 +36,11 @@ background-size: contain; background-repeat: no-repeat; } - +.kick { + background-image: url('https://slobs-cdn.streamlabs.com/media/kick-logo.png'); + display: inline-block; + width: 40px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; +} diff --git a/app/components/shared/PlatformLogo.tsx b/app/components/shared/PlatformLogo.tsx index 5973a3327733..a7a1bf7e4814 100644 --- a/app/components/shared/PlatformLogo.tsx +++ b/app/components/shared/PlatformLogo.tsx @@ -21,6 +21,7 @@ export default class PlatformLogo extends TsxComponent { nimotv: 'nimotv', streamlabs: 'icon-streamlabs', trovo: 'trovo', + kick: 'kick', twitter: 'twitter', instagram: 'instagram', }[this.props.platform]; diff --git a/app/i18n/en-US/common.json b/app/i18n/en-US/common.json index bc231c707383..8933f53c62a8 100644 --- a/app/i18n/en-US/common.json +++ b/app/i18n/en-US/common.json @@ -154,6 +154,7 @@ "Instagram": "Instagram", "Instagram Live": "Instagram Live", "X (Twitter)": "X (Twitter)", + "Kick": "Kick", "Facebook Profiles": "Facebook Profiles", "Facebook Pages": "Facebook Pages", "Alert Box": "Alert Box", diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index a53d9cb1044f..6040680f4924 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -10,6 +10,7 @@ import { WidgetType } from '../widgets'; import { ITrovoStartStreamOptions, TrovoService } from './trovo'; import { TDisplayType } from 'services/settings-v2'; import { $t } from 'services/i18n'; +import { KickService, IKickStartStreamOptions } from './kick'; export type Tag = string; export interface IGame { @@ -151,7 +152,8 @@ export type TStartStreamOptions = | Partial | Partial | Partial - | Partial; + | Partial + | Partial; // state applicable for all platforms export interface IPlatformState { @@ -242,6 +244,7 @@ export enum EPlatform { Trovo = 'trovo', Twitter = 'twitter', Instagram = 'instagram', + Kick = 'kick', } export type TPlatform = @@ -251,7 +254,8 @@ export type TPlatform = | 'tiktok' | 'trovo' | 'twitter' - | 'instagram'; + | 'instagram' + | 'kick'; export const platformList = [ EPlatform.Facebook, @@ -261,6 +265,7 @@ export const platformList = [ EPlatform.YouTube, EPlatform.Twitter, EPlatform.Instagram, + EPlatform.Kick, ]; export const platformLabels = (platform: TPlatform | string) => @@ -273,6 +278,7 @@ export const platformLabels = (platform: TPlatform | string) => // TODO: translate [EPlatform.Twitter]: 'Twitter', [EPlatform.Instagram]: $t('Instagram'), + [EPlatform.Kick]: $t('Kick'), }[platform]); export function getPlatformService(platform: TPlatform): IPlatformService { @@ -282,6 +288,7 @@ export function getPlatformService(platform: TPlatform): IPlatformService { facebook: FacebookService.instance, tiktok: TikTokService.instance, trovo: TrovoService.instance, + kick: KickService.instance, twitter: TwitterPlatformService.instance, instagram: InstagramService.instance, }[platform]; diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts new file mode 100644 index 000000000000..6961f32fcbb4 --- /dev/null +++ b/app/services/platforms/kick.ts @@ -0,0 +1,324 @@ +import { InheritMutations, Inject, mutation } from '../core'; +import { BasePlatformService } from './base-platform'; +import { IPlatformRequest, IPlatformService, IPlatformState, TPlatformCapability } from './index'; +import { authorizedHeaders, jfetch } from '../../util/requests'; +import { throwStreamError } from '../streaming/stream-error'; +import { platformAuthorizedRequest } from './utils'; +import { IGoLiveSettings } from '../streaming'; +import { TOutputOrientation } from 'services/restream'; +import { IVideo } from 'obs-studio-node'; +import { TDisplayType } from 'services/settings-v2'; +import { I18nService } from 'services/i18n'; +import { getDefined } from 'util/properties-type-guards'; +import { WindowsService } from 'services/windows'; +import { DiagnosticsService } from 'services/diagnostics'; + +interface IKickStartStreamResponse { + id?: string; + key: string; + rtmp: string; + chat_url: string; + broadcast_id?: string; + channel_name: string; + platform_id: string; + region?: string; + chat_id?: string; +} +interface IKickEndStreamResponse { + id: string; +} + +interface IKickError { + success: boolean; + error: boolean; + message: string; + data: any[]; +} +interface IKickServiceState extends IPlatformState { + settings: IKickStartStreamSettings; + broadcastId: string; + ingest: string; + chatUrl: string; + channelName: string; + platformId?: string; +} + +interface IKickStartStreamSettings { + title: string; + display: TDisplayType; + video?: IVideo; + mode?: TOutputOrientation; +} + +export interface IKickStartStreamOptions { + title: string; +} + +interface IKickRequestHeaders extends Dictionary { + Accept: string; + 'Content-Type': string; + Authorization: string; +} + +@InheritMutations() +export class KickService + extends BasePlatformService + implements IPlatformService { + static initialState: IKickServiceState = { + ...BasePlatformService.initialState, + settings: { + title: '', + display: 'horizontal', + mode: 'landscape', + }, + broadcastId: '', + ingest: '', + chatUrl: '', + channelName: '', + }; + + @Inject() windowsService: WindowsService; + @Inject() diagnosticsService: DiagnosticsService; + + readonly apiBase = ''; + readonly domain = 'https://kick.com'; + readonly platform = 'kick'; + readonly displayName = 'Kick'; + readonly capabilities = new Set(['title', 'viewerCount']); + + authWindowOptions: Electron.BrowserWindowConstructorOptions = { + width: 600, + height: 800, + }; + + private get oauthToken() { + return this.userService.views.state.auth?.platforms?.kick?.token; + } + + /** + * Kick's API currently does not provide viewer count. + * To prevent errors, return 0 for now; + */ + get viewersCount(): number { + return 0; + } + + async beforeGoLive(goLiveSettings: IGoLiveSettings, display?: TDisplayType) { + const kickSettings = getDefined(goLiveSettings.platforms.kick); + const context = display ?? kickSettings?.display; + + try { + const streamInfo = await this.startStream( + goLiveSettings.platforms.kick ?? this.state.settings, + ); + + if (!streamInfo.broadcast_id) { + throwStreamError('PLATFORM_REQUEST_FAILED', { + status: 403, + statusText: 'Kick Error: no broadcast ID returned, unable to start stream.', + }); + } + + this.SET_BROADCAST_ID(streamInfo.broadcast_id); + this.SET_INGEST(streamInfo.rtmp); + this.SET_STREAM_KEY(streamInfo.key); + this.SET_CHAT_URL(streamInfo.chat_url); + this.SET_PLATFORM_ID(streamInfo.platform_id); + + if (!this.streamingService.views.isMultiplatformMode) { + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key: streamInfo.key, + server: streamInfo.rtmp, + }, + context, + ); + } + + await this.putChannelInfo(kickSettings); + this.setPlatformContext('kick'); + } catch (e: unknown) { + console.error('Error starting stream: ', e); + throwStreamError('PLATFORM_REQUEST_FAILED', e as any); + } + } + + async afterStopStream(): Promise { + if (this.state.broadcastId) { + await this.endStream(this.state.broadcastId); + } + + // clear server url and stream key + this.SET_INGEST(''); + this.SET_STREAM_KEY(''); + } + + // Note, this needs to be here but should never be called, because we + // currently don't make any calls directly to Kick + async fetchNewToken(): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/refresh`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers }); + + return jfetch<{ access_token: string }>(request) + .then(response => { + return this.userService.updatePlatformToken('kick', response.access_token); + }) + .catch(e => { + console.error('Error fetching new token.'); + return Promise.reject(e); + }); + } + + /** + * Request Kick API and wrap failed response to a unified error model + */ + async requestKick(reqInfo: IPlatformRequest | string): Promise { + try { + return await platformAuthorizedRequest('kick', reqInfo); + } catch (e: unknown) { + const code = (e as any).result?.error?.code; + + const details = (e as any).result?.error + ? `${(e as any).result.error.type} ${(e as any).result.error.message}` + : 'Connection failed'; + + console.error('Error fetching Kick API: ', details, code); + + return Promise.reject(e); + } + } + + /** + * Starts the stream + * @remark If a user is live and attempts to go live via another + * another streaming method such as Kick's app, this stream will continue + * and the other stream will be prevented from going live. If another instance + * of Streamlabs attempts to go live to Kick, the first stream will be ended + * and Desktop will enter a reconnecting state, which eventually times out. + */ + async startStream(opts: IKickStartStreamOptions): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/start`; + const headers = authorizedHeaders(this.userService.apiToken!); + + const body = new FormData(); + body.append('title', opts.title); + + const request = new Request(url, { headers, method: 'POST', body }); + + return jfetch(request).catch((e: unknown) => { + console.error('Error starting Kick stream: ', e); + + // check if the error is an IKickError + if (e.hasOwnProperty('success')) { + const error = e as IKickError; + + throwStreamError('PLATFORM_REQUEST_FAILED', { + status: 403, + statusText: `Unable to start Kick stream. ${error.message}`, + }); + } + + throwStreamError('PLATFORM_REQUEST_FAILED', e); + }); + } + + async endStream(id: string) { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/${id}/end`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers, method: 'POST' }); + + return jfetch(request); + } + + async fetchViewerCount(): Promise { + return 0; + } + + /** + * prepopulate channel info and save it to the store + */ + async prepopulateInfo(): Promise { + this.SET_PREPOPULATED(true); + } + + async putChannelInfo(settings: IKickStartStreamOptions): Promise { + this.SET_STREAM_SETTINGS(settings); + } + + getHeaders(req: IPlatformRequest, useToken?: string | boolean): IKickRequestHeaders { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.oauthToken}`, + }; + } + + getErrorMessage(error?: any) { + switch (error) { + case error?.message: + return error?.message; + case error?.error_description: + return error?.error_description; + case error?.http_status_code: + return error?.http_status_code; + default: + return 'Error processing Kick request.'; + } + } + + get authUrl() { + const host = this.hostsService.streamlabs; + const query = `_=${Date.now()}&skip_splash=true&external=electron&kick&force_verify&origin=slobs`; + return `https://${host}/slobs/login?${query}`; + } + + get mergeUrl(): string { + const host = this.hostsService.streamlabs; + return `https://${host}/dashboard#/settings/account-settings/platforms`; + } + + get liveDockEnabled(): boolean { + return true; + } + + get chatUrl(): string { + return this.state.chatUrl; + } + + get streamPageUrl(): string { + const username = this.userService.state.auth?.platforms?.kick?.username; + if (!username) return ''; + + return `${this.domain}/${username}`; + } + + get locale(): string { + return I18nService.instance.state.locale; + } + + @mutation() + SET_BROADCAST_ID(broadcastId?: string) { + if (!broadcastId) return; + this.state.broadcastId = broadcastId; + } + + @mutation() + SET_INGEST(ingest: string) { + this.state.ingest = ingest; + } + + @mutation() + SET_CHAT_URL(chatUrl: string) { + this.state.chatUrl = chatUrl; + } + + @mutation() + SET_PLATFORM_ID(platformId: string) { + this.state.platformId = platformId; + } +} diff --git a/app/services/restream.ts b/app/services/restream.ts index b7811f255152..aeaf4024f761 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -12,6 +12,7 @@ import { StreamingService } from './streaming'; import { FacebookService } from './platforms/facebook'; import { TikTokService } from './platforms/tiktok'; import { TrovoService } from './platforms/trovo'; +import { KickService } from './platforms/kick'; import * as remote from '@electron/remote'; import { VideoSettingsService, TDisplayType } from './settings-v2/video'; import { DualOutputService } from './dual-output'; @@ -55,6 +56,7 @@ export class RestreamService extends StatefulService { @Inject() facebookService: FacebookService; @Inject('TikTokService') tiktokService: TikTokService; @Inject() trovoService: TrovoService; + @Inject() kickService: KickService; @Inject() instagramService: InstagramService; @Inject() videoSettingsService: VideoSettingsService; @Inject() dualOutputService: DualOutputService; @@ -316,6 +318,16 @@ export class RestreamService extends StatefulService { : 'landscape'; } + // treat kick as a custom destination + const kickTarget = newTargets.find(t => t.platform === 'kick'); + if (kickTarget) { + kickTarget.platform = 'relay'; + kickTarget.streamKey = `${this.kickService.state.ingest}/${this.kickService.state.streamKey}`; + kickTarget.mode = isDualOutputMode + ? this.dualOutputService.views.getPlatformMode('kick') + : 'landscape'; + } + await this.createTargets(newTargets); } diff --git a/app/services/settings/streaming/stream-settings.ts b/app/services/settings/streaming/stream-settings.ts index abd15911d18b..801edae58002 100644 --- a/app/services/settings/streaming/stream-settings.ts +++ b/app/services/settings/streaming/stream-settings.ts @@ -22,6 +22,7 @@ interface ISavedGoLiveSettings { youtube?: IPlatformFlags; trovo?: IPlatformFlags; tiktok?: IPlatformFlags; + kick?: IPlatformFlags; }; customDestinations?: ICustomStreamDestination[]; advancedMode: boolean; @@ -97,6 +98,7 @@ const platformToServiceNameMap: { [key in TPlatform]: string } = { tiktok: 'Custom', twitter: 'Custom', instagram: 'Custom', + kick: 'Custom', }; /** diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index c49898d5af15..9baec256eb06 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -7,9 +7,10 @@ import { IStreamError } from './stream-error'; import { ICustomStreamDestination } from '../settings/streaming'; import { ITikTokStartStreamOptions } from '../platforms/tiktok'; import { ITrovoStartStreamOptions } from '../platforms/trovo'; -import { IVideo } from 'obs-studio-node'; +import { IKickStartStreamOptions } from 'services/platforms/kick'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; +import { IVideo } from 'obs-studio-node'; import { TDisplayType } from 'services/settings-v2'; export enum EStreamingState { @@ -52,6 +53,7 @@ export interface IStreamInfo { facebook: TGoLiveChecklistItemState; tiktok: TGoLiveChecklistItemState; trovo: TGoLiveChecklistItemState; + kick: TGoLiveChecklistItemState; twitter: TGoLiveChecklistItemState; instagram: TGoLiveChecklistItemState; setupMultistream: TGoLiveChecklistItemState; @@ -69,6 +71,7 @@ export interface IStreamSettings { facebook?: IPlatformFlags & IFacebookStartStreamOptions; tiktok?: IPlatformFlags & ITikTokStartStreamOptions; trovo?: IPlatformFlags & ITrovoStartStreamOptions; + kick?: IPlatformFlags & IKickStartStreamOptions; twitter?: IPlatformFlags & ITwitterStartStreamOptions; instagram?: IPlatformFlags & IInstagramStartStreamOptions; }; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index d57266367d84..4b2ddc9f471e 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -140,6 +140,7 @@ export class StreamingService facebook: 'not-started', tiktok: 'not-started', trovo: 'not-started', + kick: 'not-started', twitter: 'not-started', instagram: 'not-started', setupMultistream: 'not-started', diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 727b7292af99..bffd6772184b 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -116,6 +116,7 @@ interface ILinkedPlatformsResponse { youtube_account?: ILinkedPlatform; tiktok_account?: ILinkedPlatform; trovo_account?: ILinkedPlatform; + kick_account?: ILinkedPlatform; streamlabs_account?: ILinkedPlatform; twitter_account?: ILinkedPlatform; user_id: number; @@ -753,6 +754,17 @@ export class UserService extends PersistentStatefulService { this.UNLINK_PLATFORM('trovo'); } + if (linkedPlatforms.kick_account) { + this.UPDATE_PLATFORM({ + type: 'kick', + username: linkedPlatforms.kick_account.platform_name, + id: linkedPlatforms.kick_account.platform_id, + token: linkedPlatforms.kick_account.access_token, + }); + } else if (this.state.auth.primaryPlatform !== 'kick') { + this.UNLINK_PLATFORM('kick'); + } + if (linkedPlatforms.streamlabs_account) { this.SET_SLID({ id: linkedPlatforms.streamlabs_account.platform_id, @@ -1283,7 +1295,9 @@ export class UserService extends PersistentStatefulService { hasRelogged: true, }; - this.UPDATE_PLATFORM(auth.platforms[auth.primaryPlatform]); + this.UPDATE_PLATFORM( + (auth.platforms as Record)[auth.primaryPlatform], + ); return EPlatformCallResult.Success; } diff --git a/app/services/widgets/settings/event-list.ts b/app/services/widgets/settings/event-list.ts index fcebcfd8fc10..df7d5666525f 100644 --- a/app/services/widgets/settings/event-list.ts +++ b/app/services/widgets/settings/event-list.ts @@ -95,7 +95,7 @@ export class EventListService extends WidgetSettingsService { eventsByPlatform(): { key: string; title: string }[] { const platform = this.userService.platform.type as Exclude< TPlatform, - 'tiktok' | 'twitter' | 'instagram' + 'tiktok' | 'twitter' | 'instagram' | 'kick' >; return { twitch: [ diff --git a/app/services/widgets/settings/stream-boss.ts b/app/services/widgets/settings/stream-boss.ts index 2d1100e8ffd2..b66592144b8c 100644 --- a/app/services/widgets/settings/stream-boss.ts +++ b/app/services/widgets/settings/stream-boss.ts @@ -169,7 +169,7 @@ export class StreamBossService extends BaseGoalService; return { twitch: [ diff --git a/app/styles/buttons.less b/app/styles/buttons.less index 78eacf38c3b0..38238b7ccfdf 100644 --- a/app/styles/buttons.less +++ b/app/styles/buttons.less @@ -271,6 +271,17 @@ button { } } +.square-button--kick, +.button--kick { + background: var(--kick); + color: black; + + &:hover, + &:active { + background: var(--kick-hover) !important; + } +} + .button--dlive { background: #ffd300; diff --git a/app/styles/colors.less b/app/styles/colors.less index a944124b7ff7..a40a26acf00b 100644 --- a/app/styles/colors.less +++ b/app/styles/colors.less @@ -66,4 +66,5 @@ @twitter: #1DA1F2; @tiktok: white; @trovo: #19D06D; +@kick: #54FC1F; @instagram: white; diff --git a/app/themes.g.less b/app/themes.g.less index 7fe2d56beb16..91046582f0f9 100644 --- a/app/themes.g.less +++ b/app/themes.g.less @@ -94,6 +94,8 @@ --instagram-hover: lighten(@instagram, 4%); --trovo: @trovo; --trovo-hover: lighten(@trovo, 4%); + --kick: @kick; + --kick-hover: lighten(@kick, 4%); } .night-theme { diff --git a/media/images/platforms/kick-logo.png b/media/images/platforms/kick-logo.png new file mode 100644 index 000000000000..d29fa0dadfd6 Binary files /dev/null and b/media/images/platforms/kick-logo.png differ