@powly/sdk 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/powly.d.ts +180 -4
- package/dist/powly.js +313 -11
- package/dist/powly.mjs +313 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The SDK for games on [Powly](https://powly.gg): the launch handshake with Powly, the player's
|
|
4
4
|
profile, session tokens and the platform services (saves, leaderboards, achievements, friends,
|
|
5
|
-
invites, multiplayer rooms, Twitch chat).
|
|
5
|
+
invites, multiplayer rooms, Twitch chat, sound settings).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -31,7 +31,7 @@ await powly.leaderboards.submit("high-score", 1200);
|
|
|
31
31
|
Without a bundler, load the classic script instead; it defines `window.Powly`:
|
|
32
32
|
|
|
33
33
|
```html
|
|
34
|
-
<script src="https://cdn.jsdelivr.net/npm/@powly/sdk@0.
|
|
34
|
+
<script src="https://cdn.jsdelivr.net/npm/@powly/sdk@0.9.0/dist/powly.js"></script>
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
## Testing
|
|
@@ -58,7 +58,7 @@ your backend verifies session tokens or exchanges them for an access token.
|
|
|
58
58
|
## This repository
|
|
59
59
|
|
|
60
60
|
The SDK is developed in Powly's main repository and mirrored here with each release; this is
|
|
61
|
-
`@powly/sdk` 0.
|
|
61
|
+
`@powly/sdk` 0.9.0. Issues are welcome; changes land through the main repository.
|
|
62
62
|
|
|
63
63
|
To build it yourself (Node.js 24 or newer):
|
|
64
64
|
|
package/dist/powly.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/* Powly SDK 0.
|
|
1
|
+
/* Powly SDK 0.9.0: types for powly.js and powly.mjs */
|
|
2
2
|
/** This SDK's version; Powly's Studio shows it next to the downloads. */
|
|
3
|
-
export declare const SDK_VERSION = "0.
|
|
3
|
+
export declare const SDK_VERSION = "0.9.0";
|
|
4
4
|
|
|
5
5
|
export declare const PROTOCOL = "powly-sdk";
|
|
6
6
|
export declare const PROTOCOL_VERSION = 1;
|
|
@@ -8,6 +8,44 @@ export declare const PROTOCOL_VERSION = 1;
|
|
|
8
8
|
export declare const OPTIONS_BUTTON_CORNERS: readonly ["top-left", "top-right", "bottom-left", "bottom-right"];
|
|
9
9
|
export type OptionsButtonCorner = (typeof OPTIONS_BUTTON_CORNERS)[number];
|
|
10
10
|
export declare const isOptionsButtonCorner: (value: unknown) => value is OptionsButtonCorner;
|
|
11
|
+
/**
|
|
12
|
+
* A kind of sound the game has (music, drawing sounds…), which Powly's sound menu shows with a
|
|
13
|
+
* volume slider (`volume`, default true), an on/off switch (`toggle`, default true), or both.
|
|
14
|
+
*/
|
|
15
|
+
export type AudioChannel = {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
volume?: boolean;
|
|
19
|
+
toggle?: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** One channel's setting: its volume, 0 to 1, and whether it's on. */
|
|
22
|
+
export type ChannelState = {
|
|
23
|
+
volume: number;
|
|
24
|
+
on: boolean;
|
|
25
|
+
};
|
|
26
|
+
/** Channel ids: letters, digits, _ and -, starting with a letter, up to 32. */
|
|
27
|
+
export declare const AUDIO_CHANNEL_ID: RegExp;
|
|
28
|
+
export declare const MAX_AUDIO_CHANNELS = 8;
|
|
29
|
+
export declare const MAX_AUDIO_LABEL = 32;
|
|
30
|
+
/**
|
|
31
|
+
* The player's sound settings from Powly's sound menu. `volume` (0 to 1) is the player's overall
|
|
32
|
+
* volume, `channels` the game's own (by channel id); `muted` silences everything.
|
|
33
|
+
*/
|
|
34
|
+
export type AudioState = {
|
|
35
|
+
volume: number;
|
|
36
|
+
muted: boolean;
|
|
37
|
+
channels: Record<string, ChannelState>;
|
|
38
|
+
};
|
|
39
|
+
/** The channels a game sent, cleaned up: valid ids, short labels, no repeats, at most 8. */
|
|
40
|
+
export declare function audioChannelsOf(value: unknown): AudioChannel[];
|
|
41
|
+
/** What a game changes from its own sound menu; channels by id, only the fields given. */
|
|
42
|
+
export type AudioChanges = {
|
|
43
|
+
volume?: number;
|
|
44
|
+
muted?: boolean;
|
|
45
|
+
channels?: Record<string, Partial<ChannelState>>;
|
|
46
|
+
};
|
|
47
|
+
/** A volume between 0 and 1, or null. */
|
|
48
|
+
export declare const volumeOf: (value: unknown) => number | null;
|
|
11
49
|
export type InitMessage = {
|
|
12
50
|
protocol: typeof PROTOCOL;
|
|
13
51
|
version: number;
|
|
@@ -23,6 +61,13 @@ export type InitMessage = {
|
|
|
23
61
|
* player's own choice wins. Left out: Powly's default (top left).
|
|
24
62
|
*/
|
|
25
63
|
optionsButton?: OptionsButtonCorner;
|
|
64
|
+
/**
|
|
65
|
+
* The SDK follows Powly's sound menu (SDK 0.8.0 and later): `channels` are the game's own
|
|
66
|
+
* volume controls, shown as sliders. Older SDKs leave it out.
|
|
67
|
+
*/
|
|
68
|
+
audio?: {
|
|
69
|
+
channels: AudioChannel[];
|
|
70
|
+
};
|
|
26
71
|
};
|
|
27
72
|
export type PlayerProfile = {
|
|
28
73
|
/** This player's ID in this game: stable, and different in every game. */
|
|
@@ -49,12 +94,19 @@ export type ReadyMessage = {
|
|
|
49
94
|
pac?: {
|
|
50
95
|
guard: boolean;
|
|
51
96
|
};
|
|
97
|
+
/** The player's sound settings when the game starts. Missing from older Powly hosts. */
|
|
98
|
+
audio?: AudioState;
|
|
52
99
|
};
|
|
53
100
|
export type FailedMessage = {
|
|
54
101
|
type: "failed";
|
|
55
102
|
error: string;
|
|
56
103
|
};
|
|
57
|
-
|
|
104
|
+
/** What a game may ask the Powly page for. */
|
|
105
|
+
export declare const HOST_METHODS: readonly ["getSessionToken", "purchases.open", "fullscreen.toggle", "invites.open", "players.open", "twitch.connect", "audio.set", "audio.channels", "audio.open", "quit"];
|
|
106
|
+
export type HostMethod = (typeof HOST_METHODS)[number];
|
|
107
|
+
/** "players.open": what Powly's player dialog opens on. */
|
|
108
|
+
export declare const PLAYER_VIEWS: readonly ["profile", "report"];
|
|
109
|
+
export type PlayerView = (typeof PLAYER_VIEWS)[number];
|
|
58
110
|
export type RequestMessage = {
|
|
59
111
|
type: "request";
|
|
60
112
|
id: number;
|
|
@@ -66,11 +118,33 @@ export type RequestMessage = {
|
|
|
66
118
|
* dialog shows them as in the party, without an Invite button.
|
|
67
119
|
*/
|
|
68
120
|
members?: string[];
|
|
121
|
+
/** "players.open": the player (their player ID in this game) to show. */
|
|
122
|
+
playerId?: string;
|
|
123
|
+
/** "players.open": open on the profile (the default) or straight on reporting them. */
|
|
124
|
+
view?: PlayerView;
|
|
125
|
+
/** "audio.set": what the game's own sound menu changed. */
|
|
126
|
+
audio?: AudioChanges;
|
|
127
|
+
/** "audio.channels": every channel the game has now (registered later than init). */
|
|
128
|
+
channels?: AudioChannel[];
|
|
69
129
|
};
|
|
70
130
|
/** What "invites.open" answers once Powly's dialog closed: the friends the player invited. */
|
|
71
131
|
export type InvitesOpenResult = {
|
|
72
132
|
invited: string[];
|
|
73
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* What "players.open" answers once Powly's player dialog closed: whether the player has that
|
|
136
|
+
* player blocked now (the game may hide their chat, say). Whether they reported them stays private.
|
|
137
|
+
*/
|
|
138
|
+
export type PlayersOpenResult = {
|
|
139
|
+
blocked: boolean;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* What "twitch.connect" answers once Powly's Connect Twitch dialog closed: whether the player's
|
|
143
|
+
* Twitch is connected now (the SDK then reads GET /twitch again).
|
|
144
|
+
*/
|
|
145
|
+
export type TwitchConnectResult = {
|
|
146
|
+
connected: boolean;
|
|
147
|
+
};
|
|
74
148
|
export type ResponseMessage = {
|
|
75
149
|
type: "response";
|
|
76
150
|
id: number;
|
|
@@ -90,9 +164,20 @@ export type HostEventName = "shown" | "pause" | "resume" | "ended";
|
|
|
90
164
|
export type EventMessage = {
|
|
91
165
|
type: "event";
|
|
92
166
|
event: HostEventName;
|
|
167
|
+
}
|
|
168
|
+
/** The player changed the sound settings (Powly's sound menu, or its M key). */
|
|
169
|
+
| {
|
|
170
|
+
type: "event";
|
|
171
|
+
event: "audio";
|
|
172
|
+
audio: AudioState;
|
|
93
173
|
};
|
|
94
174
|
export type HostMessage = ReadyMessage | FailedMessage | ResponseMessage | EventMessage;
|
|
95
175
|
export declare const isInitMessage: (data: unknown) => data is InitMessage;
|
|
176
|
+
/** A request with a method this Powly doesn't know (from a newer SDK): answered with an error. */
|
|
177
|
+
export declare const isRequestShaped: (data: unknown) => data is {
|
|
178
|
+
type: "request";
|
|
179
|
+
id: number;
|
|
180
|
+
};
|
|
96
181
|
export declare const isRequestMessage: (data: unknown) => data is RequestMessage;
|
|
97
182
|
|
|
98
183
|
export declare const POWLY_ORIGINS: readonly string[];
|
|
@@ -481,14 +566,21 @@ type Events = {
|
|
|
481
566
|
};
|
|
482
567
|
state: TwitchChatState;
|
|
483
568
|
};
|
|
569
|
+
export type ConnectOptions = {
|
|
570
|
+
/** Ask the player to connect Twitch in Powly's dialog when they haven't. Default false. */
|
|
571
|
+
prompt?: boolean;
|
|
572
|
+
};
|
|
484
573
|
export type TwitchApi = {
|
|
485
574
|
/** The game's setting and the player's channel, without opening chat. */
|
|
486
575
|
info(): Promise<TwitchInfo>;
|
|
487
576
|
/**
|
|
488
577
|
* Opens the player's chat when they have a channel to read (`status: "connected"`). Resolves
|
|
489
578
|
* with the info, once chat is joined (or failed to; it keeps retrying in the background).
|
|
579
|
+
* `prompt`: when the player hasn't connected Twitch, Powly first asks them to in its own dialog
|
|
580
|
+
* (they can connect it there without leaving the game); `status` is still "not_connected" if
|
|
581
|
+
* they closed it.
|
|
490
582
|
*/
|
|
491
|
-
connect(): Promise<TwitchInfo>;
|
|
583
|
+
connect(options?: ConnectOptions): Promise<TwitchInfo>;
|
|
492
584
|
/** Closes chat; `connect()` opens it again. */
|
|
493
585
|
disconnect(): void;
|
|
494
586
|
readonly state: TwitchChatState;
|
|
@@ -509,6 +601,8 @@ export declare function parseIrcLine(line: string): IrcMessage | null;
|
|
|
509
601
|
export declare function chatMessageOf(message: IrcMessage): TwitchChatMessage | null;
|
|
510
602
|
export declare function createTwitch(options: {
|
|
511
603
|
getInfo: () => Promise<TwitchInfo>;
|
|
604
|
+
/** Opens Powly's Connect Twitch dialog; resolves with whether Twitch is connected now. */
|
|
605
|
+
prompt?: () => Promise<boolean>;
|
|
512
606
|
/** Twitch chat's WebSocket URL (tests point it elsewhere). */
|
|
513
607
|
url?: string;
|
|
514
608
|
}): TwitchApi & {
|
|
@@ -516,6 +610,63 @@ export declare function createTwitch(options: {
|
|
|
516
610
|
};
|
|
517
611
|
|
|
518
612
|
|
|
613
|
+
export type AudioOptions = {
|
|
614
|
+
/**
|
|
615
|
+
* The game's kinds of sound, shown in Powly's sound menu (up to 8): `{ id, label }`, with a
|
|
616
|
+
* volume slider (`volume: false` leaves it out) and an on/off switch (`toggle: false`).
|
|
617
|
+
*/
|
|
618
|
+
channels?: AudioChannel[];
|
|
619
|
+
/**
|
|
620
|
+
* Apply the overall volume and mute to the page's sound by itself. Default true; turn it off
|
|
621
|
+
* when the game applies `level()` to everything itself.
|
|
622
|
+
*/
|
|
623
|
+
auto?: boolean;
|
|
624
|
+
};
|
|
625
|
+
export type AudioApi = {
|
|
626
|
+
/** The player's overall volume, 0 to 1. */
|
|
627
|
+
readonly volume: number;
|
|
628
|
+
/** Everything silenced (Powly's mute, or its M key). */
|
|
629
|
+
readonly muted: boolean;
|
|
630
|
+
/** The game's channels by id: their volume (0 to 1) and whether they're on. */
|
|
631
|
+
readonly channels: Record<string, ChannelState>;
|
|
632
|
+
/**
|
|
633
|
+
* What to play at, 0 to 1: a channel's volume (0 while it's off), or (no channel) full volume.
|
|
634
|
+
* With `auto` off, the overall volume and mute are in it too.
|
|
635
|
+
*/
|
|
636
|
+
level(channel?: string): number;
|
|
637
|
+
/**
|
|
638
|
+
* Adds a channel to Powly's sound menu after init (or changes its label or controls), e.g.
|
|
639
|
+
* once a mode with its own sounds starts. Up to 8 channels.
|
|
640
|
+
*/
|
|
641
|
+
register(channel: AudioChannel): Promise<void>;
|
|
642
|
+
/**
|
|
643
|
+
* Changes the settings from the game's own sound menu, so Powly's stays the same:
|
|
644
|
+
* `{ volume, muted, channels: { music: { volume: 0.5 }, drawing: { on: false } } }`. Unknown
|
|
645
|
+
* channels are ignored.
|
|
646
|
+
*/
|
|
647
|
+
set(changes: AudioChanges): Promise<void>;
|
|
648
|
+
/**
|
|
649
|
+
* Opens Powly's sound menu over the game (in its options bar), e.g. from a Sound button in
|
|
650
|
+
* the game's own menu. Resolves when the player closes it; "change" tells what they set.
|
|
651
|
+
*/
|
|
652
|
+
open(): Promise<void>;
|
|
653
|
+
/** The settings changed (in Powly's sound menu, or with `set`). */
|
|
654
|
+
on(event: "change", listener: (audio: AudioState) => void): () => void;
|
|
655
|
+
};
|
|
656
|
+
/** Volume of the page's sound overall: 0 when muted. */
|
|
657
|
+
export declare const masterLevel: (state: AudioState) => number;
|
|
658
|
+
/**
|
|
659
|
+
* Puts a volume control in front of the page's speakers: Web Audio connections to a real-time
|
|
660
|
+
* AudioContext's destination go through a gain node per context, and media elements' volume is
|
|
661
|
+
* scaled (the volume the game set still reads back unchanged; elements played through Web Audio
|
|
662
|
+
* are left to the gain node). Returns how to set it.
|
|
663
|
+
*/
|
|
664
|
+
export declare function installMasterGain(win?: Window & typeof globalThis): {
|
|
665
|
+
set(level: number): void;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
|
|
519
670
|
|
|
520
671
|
|
|
521
672
|
|
|
@@ -536,6 +687,12 @@ export type InitOptions = {
|
|
|
536
687
|
* UI leaves free. Players can still move it, and their choice wins.
|
|
537
688
|
*/
|
|
538
689
|
optionsButton?: OptionsButtonCorner;
|
|
690
|
+
/**
|
|
691
|
+
* Powly's sound menu (src/audio.ts): the game's own volume `channels` (sliders next to the
|
|
692
|
+
* player's overall volume), and whether the SDK applies the overall volume and mute to the
|
|
693
|
+
* page's sound itself (`auto`, default true).
|
|
694
|
+
*/
|
|
695
|
+
audio?: AudioOptions;
|
|
539
696
|
};
|
|
540
697
|
export type LeaderboardEntry = {
|
|
541
698
|
rank: number;
|
|
@@ -607,6 +764,25 @@ export type PowlySession = {
|
|
|
607
764
|
invited: string[];
|
|
608
765
|
}>;
|
|
609
766
|
};
|
|
767
|
+
/**
|
|
768
|
+
* Other players of this game, by their player ID (from rooms, leaderboards, friends…).
|
|
769
|
+
* `open` shows Powly's own dialog over the game (drawn by Powly, not the game) with their
|
|
770
|
+
* profile, where the player can add them as a friend, block or report them; with
|
|
771
|
+
* `{ view: "report" }` it opens straight on reporting them. Games can't file reports
|
|
772
|
+
* themselves: the player always confirms in Powly's dialog. Resolves when it closes, with
|
|
773
|
+
* whether the player has them blocked now (hide their chat, say). Fails for player IDs that
|
|
774
|
+
* aren't from this game.
|
|
775
|
+
*/
|
|
776
|
+
players: {
|
|
777
|
+
open(playerId: string, options?: {
|
|
778
|
+
view?: PlayerView;
|
|
779
|
+
}): Promise<PlayersOpenResult>;
|
|
780
|
+
};
|
|
781
|
+
/**
|
|
782
|
+
* The player's sound settings from Powly's options bar: overall volume, mute and the game's
|
|
783
|
+
* channels (`Powly.init({ audio: { channels } })`). Play each channel at `level(channel)`.
|
|
784
|
+
*/
|
|
785
|
+
audio: AudioApi;
|
|
610
786
|
/** Realtime rooms (parties) with other players of this game. */
|
|
611
787
|
rooms: RoomsApi;
|
|
612
788
|
/**
|
package/dist/powly.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/* Powly SDK 0.
|
|
1
|
+
/* Powly SDK 0.9.0: defines window.Powly */
|
|
2
2
|
(function () {
|
|
3
3
|
"use strict";
|
|
4
4
|
/** This SDK's version; Powly's Studio shows it next to the downloads. */
|
|
5
|
-
const SDK_VERSION = "0.
|
|
5
|
+
const SDK_VERSION = "0.9.0";
|
|
6
6
|
|
|
7
7
|
// The messages between a game (this SDK, inside Powly's iframe) and the Powly page that framed it.
|
|
8
8
|
// Shared with the host side (apps/web/components/player/sdk-host.ts).
|
|
@@ -22,16 +22,62 @@ const OPTIONS_BUTTON_CORNERS = [
|
|
|
22
22
|
"bottom-right",
|
|
23
23
|
];
|
|
24
24
|
const isOptionsButtonCorner = (value) => OPTIONS_BUTTON_CORNERS.includes(value);
|
|
25
|
+
/** Channel ids: letters, digits, _ and -, starting with a letter, up to 32. */
|
|
26
|
+
const AUDIO_CHANNEL_ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
27
|
+
const MAX_AUDIO_CHANNELS = 8;
|
|
28
|
+
const MAX_AUDIO_LABEL = 32;
|
|
29
|
+
/** The channels a game sent, cleaned up: valid ids, short labels, no repeats, at most 8. */
|
|
30
|
+
function audioChannelsOf(value) {
|
|
31
|
+
if (!Array.isArray(value))
|
|
32
|
+
return [];
|
|
33
|
+
const channels = [];
|
|
34
|
+
for (const entry of value) {
|
|
35
|
+
if (typeof entry !== "object" || entry === null)
|
|
36
|
+
continue;
|
|
37
|
+
const { id, label, volume, toggle } = entry;
|
|
38
|
+
if (typeof id !== "string" || !AUDIO_CHANNEL_ID.test(id))
|
|
39
|
+
continue;
|
|
40
|
+
if (channels.some((channel) => channel.id === id))
|
|
41
|
+
continue;
|
|
42
|
+
const text = typeof label === "string" ? label.trim().slice(0, MAX_AUDIO_LABEL) : "";
|
|
43
|
+
const slider = volume !== false;
|
|
44
|
+
// Neither a slider nor a switch would show nothing: both, then.
|
|
45
|
+
const onOff = toggle !== false || !slider;
|
|
46
|
+
channels.push({ id, label: text || id, volume: slider, toggle: onOff });
|
|
47
|
+
if (channels.length === MAX_AUDIO_CHANNELS)
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
return channels;
|
|
51
|
+
}
|
|
52
|
+
/** A volume between 0 and 1, or null. */
|
|
53
|
+
const volumeOf = (value) => typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : null;
|
|
54
|
+
/** What a game may ask the Powly page for. */
|
|
55
|
+
const HOST_METHODS = [
|
|
56
|
+
"getSessionToken",
|
|
57
|
+
"purchases.open",
|
|
58
|
+
"fullscreen.toggle",
|
|
59
|
+
"invites.open",
|
|
60
|
+
"players.open",
|
|
61
|
+
"twitch.connect",
|
|
62
|
+
"audio.set",
|
|
63
|
+
"audio.channels",
|
|
64
|
+
"audio.open",
|
|
65
|
+
"quit",
|
|
66
|
+
];
|
|
67
|
+
/** "players.open": what Powly's player dialog opens on. */
|
|
68
|
+
const PLAYER_VIEWS = ["profile", "report"];
|
|
25
69
|
const isInitMessage = (data) => typeof data === "object" &&
|
|
26
70
|
data !== null &&
|
|
27
71
|
data.protocol === PROTOCOL &&
|
|
28
72
|
data.type === "init" &&
|
|
29
73
|
data.version === PROTOCOL_VERSION;
|
|
30
|
-
|
|
74
|
+
/** A request with a method this Powly doesn't know (from a newer SDK): answered with an error. */
|
|
75
|
+
const isRequestShaped = (data) => typeof data === "object" &&
|
|
31
76
|
data !== null &&
|
|
32
77
|
data.type === "request" &&
|
|
33
|
-
Number.isInteger(data.id)
|
|
34
|
-
|
|
78
|
+
Number.isInteger(data.id);
|
|
79
|
+
const isRequestMessage = (data) => isRequestShaped(data) &&
|
|
80
|
+
HOST_METHODS.includes(data.method);
|
|
35
81
|
|
|
36
82
|
// The Powly origins this build of the SDK trusts by default: a game only handshakes with a parent
|
|
37
83
|
// page on one of them (unless it passes `powlyOrigin` to `Powly.init()` itself). Set at build
|
|
@@ -861,6 +907,11 @@ function createRooms(options) {
|
|
|
861
907
|
// });
|
|
862
908
|
// }
|
|
863
909
|
//
|
|
910
|
+
// A game with a Twitch mode among others (Twitch chat "optional" in Studio) passes
|
|
911
|
+
// `{ prompt: true }` when the player picks that mode: if they haven't connected Twitch, Powly asks
|
|
912
|
+
// them to in its own dialog (drawn by Powly, outside the game), and `connect()` resolves once
|
|
913
|
+
// they did or closed it.
|
|
914
|
+
//
|
|
864
915
|
// Chat is Twitch IRC over a WebSocket (https://dev.twitch.tv/docs/chat/irc/). The connection comes
|
|
865
916
|
// back by itself when it drops, and stops when the launch ends.
|
|
866
917
|
const TWITCH_CHAT_URL = "wss://irc-ws.chat.twitch.tv:443";
|
|
@@ -1101,8 +1152,12 @@ function createTwitch(options) {
|
|
|
1101
1152
|
};
|
|
1102
1153
|
return {
|
|
1103
1154
|
info: options.getInfo,
|
|
1104
|
-
async connect() {
|
|
1105
|
-
|
|
1155
|
+
async connect(connectOptions = {}) {
|
|
1156
|
+
let info = await options.getInfo();
|
|
1157
|
+
if (info.status === "not_connected" && connectOptions.prompt && options.prompt) {
|
|
1158
|
+
if (await options.prompt())
|
|
1159
|
+
info = await options.getInfo();
|
|
1160
|
+
}
|
|
1106
1161
|
const next = info.status === "connected" ? info.channel : null;
|
|
1107
1162
|
if (!next || stopped) {
|
|
1108
1163
|
disconnect();
|
|
@@ -1141,6 +1196,154 @@ function createTwitch(options) {
|
|
|
1141
1196
|
};
|
|
1142
1197
|
}
|
|
1143
1198
|
|
|
1199
|
+
// Powly's sound menu for games (`powly.audio`): the player's overall volume, a mute (Powly's M
|
|
1200
|
+
// key), and the kinds of sound the game registers (channels: music, drawing sounds…), each with a
|
|
1201
|
+
// volume slider and an on/off switch, or one of them. The player sets them in Powly's options
|
|
1202
|
+
// bar; the game hears "change".
|
|
1203
|
+
//
|
|
1204
|
+
// const powly = await Powly.init({
|
|
1205
|
+
// audio: { channels: [{ id: "music", label: "Music" }] },
|
|
1206
|
+
// });
|
|
1207
|
+
// await powly.audio.register({ id: "drawing", label: "Drawing sounds", volume: false });
|
|
1208
|
+
// music.volume = powly.audio.level("music");
|
|
1209
|
+
// powly.audio.on("change", () => { music.volume = powly.audio.level("music"); });
|
|
1210
|
+
// if (powly.audio.level("drawing") > 0) playScribble();
|
|
1211
|
+
//
|
|
1212
|
+
// The overall volume and mute apply by themselves (`auto`, on by default): the SDK puts a volume
|
|
1213
|
+
// control in front of the page's speakers, for Web Audio (every connection to an AudioContext's
|
|
1214
|
+
// destination goes through it) and for <audio>/<video> (their volume is scaled). So every game
|
|
1215
|
+
// on this SDK follows Powly's volume, and channels only need the channel's own value:
|
|
1216
|
+
// `level(channel)` is what to play at either way.
|
|
1217
|
+
/** Volume of the page's sound overall: 0 when muted. */
|
|
1218
|
+
const masterLevel = (state) => (state.muted ? 0 : state.volume);
|
|
1219
|
+
/** Live references to objects, for walking them later without keeping them alive. */
|
|
1220
|
+
function weakList() {
|
|
1221
|
+
const seen = new WeakSet();
|
|
1222
|
+
const refs = new Set();
|
|
1223
|
+
return {
|
|
1224
|
+
add(item) {
|
|
1225
|
+
if (seen.has(item))
|
|
1226
|
+
return false;
|
|
1227
|
+
seen.add(item);
|
|
1228
|
+
refs.add(new WeakRef(item));
|
|
1229
|
+
return true;
|
|
1230
|
+
},
|
|
1231
|
+
has: (item) => seen.has(item),
|
|
1232
|
+
*[Symbol.iterator]() {
|
|
1233
|
+
for (const ref of refs) {
|
|
1234
|
+
const item = ref.deref();
|
|
1235
|
+
if (item)
|
|
1236
|
+
yield item;
|
|
1237
|
+
else
|
|
1238
|
+
refs.delete(ref);
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Puts a volume control in front of the page's speakers: Web Audio connections to a real-time
|
|
1245
|
+
* AudioContext's destination go through a gain node per context, and media elements' volume is
|
|
1246
|
+
* scaled (the volume the game set still reads back unchanged; elements played through Web Audio
|
|
1247
|
+
* are left to the gain node). Returns how to set it.
|
|
1248
|
+
*/
|
|
1249
|
+
function installMasterGain(win = window) {
|
|
1250
|
+
let level = 1;
|
|
1251
|
+
// Web Audio.
|
|
1252
|
+
const gainOf = new WeakMap();
|
|
1253
|
+
const gains = weakList();
|
|
1254
|
+
const Destination = win.AudioDestinationNode;
|
|
1255
|
+
const Offline = win.OfflineAudioContext;
|
|
1256
|
+
const nodeProto = win.AudioNode?.prototype;
|
|
1257
|
+
const connect = nodeProto?.connect;
|
|
1258
|
+
const disconnect = nodeProto?.disconnect;
|
|
1259
|
+
/** The gain node standing in for `target` when it's a real-time context's destination. */
|
|
1260
|
+
const standIn = (target) => {
|
|
1261
|
+
if (!Destination || !(target instanceof Destination))
|
|
1262
|
+
return null;
|
|
1263
|
+
const context = target.context;
|
|
1264
|
+
if (Offline && context instanceof Offline)
|
|
1265
|
+
return null;
|
|
1266
|
+
let gain = gainOf.get(context);
|
|
1267
|
+
if (!gain) {
|
|
1268
|
+
gain = context.createGain();
|
|
1269
|
+
gain.gain.value = level;
|
|
1270
|
+
gains.add(gain);
|
|
1271
|
+
connect?.call(gain, target);
|
|
1272
|
+
gainOf.set(context, gain);
|
|
1273
|
+
}
|
|
1274
|
+
return gain;
|
|
1275
|
+
};
|
|
1276
|
+
if (nodeProto && connect && disconnect) {
|
|
1277
|
+
nodeProto.connect = function (target, ...rest) {
|
|
1278
|
+
const gain = gains.has(this) ? null : standIn(target);
|
|
1279
|
+
if (!gain)
|
|
1280
|
+
return connect.call(this, target, ...rest);
|
|
1281
|
+
connect.call(this, gain, ...rest);
|
|
1282
|
+
// Chaining keeps working: connect() answers what it was given.
|
|
1283
|
+
return target;
|
|
1284
|
+
};
|
|
1285
|
+
nodeProto.disconnect = function (...args) {
|
|
1286
|
+
const gain = gains.has(this) ? null : standIn(args[0]);
|
|
1287
|
+
return gain ? disconnect.call(this, gain, ...args.slice(1)) : disconnect.apply(this, args);
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
// Media elements: the volume the game set, and the one played (times `level`).
|
|
1291
|
+
const media = win.HTMLMediaElement?.prototype;
|
|
1292
|
+
const volume = media ? Object.getOwnPropertyDescriptor(media, "volume") : undefined;
|
|
1293
|
+
const wanted = new WeakMap();
|
|
1294
|
+
const elements = weakList();
|
|
1295
|
+
const routed = new WeakSet();
|
|
1296
|
+
const apply = (element) => {
|
|
1297
|
+
const own = wanted.get(element) ?? 1;
|
|
1298
|
+
volume?.set?.call(element, routed.has(element) ? own : own * level);
|
|
1299
|
+
};
|
|
1300
|
+
const track = (element) => {
|
|
1301
|
+
if (!wanted.has(element))
|
|
1302
|
+
wanted.set(element, volume?.get?.call(element) ?? 1);
|
|
1303
|
+
elements.add(element);
|
|
1304
|
+
apply(element);
|
|
1305
|
+
};
|
|
1306
|
+
if (media && volume?.get && volume.set) {
|
|
1307
|
+
const { get, set } = volume;
|
|
1308
|
+
Object.defineProperty(media, "volume", {
|
|
1309
|
+
configurable: true,
|
|
1310
|
+
enumerable: volume.enumerable,
|
|
1311
|
+
get() {
|
|
1312
|
+
return wanted.get(this) ?? get.call(this);
|
|
1313
|
+
},
|
|
1314
|
+
set(value) {
|
|
1315
|
+
// Out of range throws, like the browser's own setter.
|
|
1316
|
+
set.call(this, value);
|
|
1317
|
+
wanted.set(this, value);
|
|
1318
|
+
track(this);
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
const play = media.play;
|
|
1322
|
+
media.play = function () {
|
|
1323
|
+
track(this);
|
|
1324
|
+
return play.call(this);
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
const createSource = win.AudioContext?.prototype.createMediaElementSource;
|
|
1328
|
+
if (createSource) {
|
|
1329
|
+
win.AudioContext.prototype.createMediaElementSource = function (element) {
|
|
1330
|
+
routed.add(element);
|
|
1331
|
+
track(element);
|
|
1332
|
+
return createSource.call(this, element);
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
return {
|
|
1336
|
+
set(next) {
|
|
1337
|
+
level = next;
|
|
1338
|
+
for (const gain of gains)
|
|
1339
|
+
gain.gain.value = level;
|
|
1340
|
+
for (const element of elements)
|
|
1341
|
+
apply(element);
|
|
1342
|
+
},
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
|
|
1144
1347
|
|
|
1145
1348
|
|
|
1146
1349
|
|
|
@@ -1158,6 +1361,8 @@ function createTwitch(options) {
|
|
|
1158
1361
|
// const token = await powly.getSessionToken(); // send it to your backend, which verifies it
|
|
1159
1362
|
// const room = await powly.rooms.create(); // realtime parties (src/rooms.ts)
|
|
1160
1363
|
// await powly.twitch.connect(); // the streamer's Twitch chat (src/twitch.ts)
|
|
1364
|
+
// await powly.players.open(playerId); // Powly's profile dialog: add, block, report
|
|
1365
|
+
// powly.audio.level("music"); // Powly's sound menu (src/audio.ts)
|
|
1161
1366
|
//
|
|
1162
1367
|
// Nothing here is a security boundary: the game runs in the player's browser. Your backend must
|
|
1163
1368
|
// verify session tokens (JWKS) or exchange them, and Powly checks everything server-side.
|
|
@@ -1202,6 +1407,12 @@ function init(options = {}) {
|
|
|
1202
1407
|
if (origins.length === 0) {
|
|
1203
1408
|
return Promise.reject(new PowlyError("This game only runs when launched from Powly."));
|
|
1204
1409
|
}
|
|
1410
|
+
const audioChannels = audioChannelsOf(options.audio?.channels ?? []);
|
|
1411
|
+
// Before the game's engine makes a sound, so its connections go through the volume control.
|
|
1412
|
+
const auto = options.audio?.auto !== false;
|
|
1413
|
+
if (auto)
|
|
1414
|
+
masterGain ??= installMasterGain();
|
|
1415
|
+
const master = auto ? masterGain : null;
|
|
1205
1416
|
return new Promise((resolve, reject) => {
|
|
1206
1417
|
const channels = origins.map((origin) => {
|
|
1207
1418
|
const channel = new MessageChannel();
|
|
@@ -1214,6 +1425,7 @@ function init(options = {}) {
|
|
|
1214
1425
|
...(isOptionsButtonCorner(options.optionsButton) && {
|
|
1215
1426
|
optionsButton: options.optionsButton,
|
|
1216
1427
|
}),
|
|
1428
|
+
audio: { channels: audioChannels },
|
|
1217
1429
|
};
|
|
1218
1430
|
channel.port1.onmessage = (event) => {
|
|
1219
1431
|
const data = event.data;
|
|
@@ -1222,7 +1434,7 @@ function init(options = {}) {
|
|
|
1222
1434
|
for (const other of channels)
|
|
1223
1435
|
if (other !== channel)
|
|
1224
1436
|
other.port1.close();
|
|
1225
|
-
resolve(createSession(channel.port1, data));
|
|
1437
|
+
resolve(createSession(channel.port1, data, { channels: audioChannels, master }));
|
|
1226
1438
|
}
|
|
1227
1439
|
else if (data?.type === "failed") {
|
|
1228
1440
|
clearTimeout(timer);
|
|
@@ -1240,7 +1452,22 @@ function init(options = {}) {
|
|
|
1240
1452
|
}, options.timeoutMs ?? 10_000);
|
|
1241
1453
|
});
|
|
1242
1454
|
}
|
|
1243
|
-
|
|
1455
|
+
/** The page's volume control (src/audio.ts), put in once even if init runs again. */
|
|
1456
|
+
let masterGain = null;
|
|
1457
|
+
/** The sound settings Powly sent, with every channel (on, at full volume, when it sent none). */
|
|
1458
|
+
function audioStateOf(value, channels) {
|
|
1459
|
+
const sent = (typeof value === "object" && value !== null ? value : {});
|
|
1460
|
+
const levels = ((typeof sent.channels === "object" && sent.channels) || {});
|
|
1461
|
+
return {
|
|
1462
|
+
volume: volumeOf(sent.volume) ?? 1,
|
|
1463
|
+
muted: sent.muted === true,
|
|
1464
|
+
channels: Object.fromEntries(channels.map((channel) => {
|
|
1465
|
+
const level = levels[channel.id];
|
|
1466
|
+
return [channel.id, { volume: volumeOf(level?.volume) ?? 1, on: level?.on !== false }];
|
|
1467
|
+
})),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
function createSession(port, ready, sound) {
|
|
1244
1471
|
let token = ready.token;
|
|
1245
1472
|
let expiresAt = Date.now() + ready.expiresIn * 1000;
|
|
1246
1473
|
let refreshing = null;
|
|
@@ -1249,6 +1476,23 @@ function createSession(port, ready) {
|
|
|
1249
1476
|
const listeners = new Map();
|
|
1250
1477
|
// "shown" comes once; games that listen for it later still hear it.
|
|
1251
1478
|
let shown = false;
|
|
1479
|
+
// The player's sound settings; the page's volume follows them (auto).
|
|
1480
|
+
let channels = sound.channels;
|
|
1481
|
+
let audio = audioStateOf(ready.audio, channels);
|
|
1482
|
+
const audioListeners = new Set();
|
|
1483
|
+
sound.master?.set(masterLevel(audio));
|
|
1484
|
+
const setAudio = (next) => {
|
|
1485
|
+
audio = next;
|
|
1486
|
+
sound.master?.set(masterLevel(audio));
|
|
1487
|
+
for (const listener of audioListeners) {
|
|
1488
|
+
try {
|
|
1489
|
+
listener(audio);
|
|
1490
|
+
}
|
|
1491
|
+
catch (error) {
|
|
1492
|
+
console.error("[powly] an audio listener failed", error);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1252
1496
|
port.onmessage = (event) => {
|
|
1253
1497
|
const data = event.data;
|
|
1254
1498
|
if (data?.type === "response") {
|
|
@@ -1262,7 +1506,13 @@ function createSession(port, ready) {
|
|
|
1262
1506
|
waiting.reject(new PowlyError(data.error));
|
|
1263
1507
|
}
|
|
1264
1508
|
else if (data?.type === "event") {
|
|
1265
|
-
const
|
|
1509
|
+
const message = data;
|
|
1510
|
+
if (message.event === "audio") {
|
|
1511
|
+
if ("audio" in message)
|
|
1512
|
+
setAudio(audioStateOf(message.audio, channels));
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const name = message.event;
|
|
1266
1516
|
if (name === "shown") {
|
|
1267
1517
|
if (shown)
|
|
1268
1518
|
return;
|
|
@@ -1328,7 +1578,10 @@ function createSession(port, ready) {
|
|
|
1328
1578
|
invite: sendInvite,
|
|
1329
1579
|
});
|
|
1330
1580
|
listeners.get("ended")?.add(() => realtime.stop());
|
|
1331
|
-
const twitch = createTwitch({
|
|
1581
|
+
const twitch = createTwitch({
|
|
1582
|
+
getInfo: () => api("/twitch"),
|
|
1583
|
+
prompt: async () => (await call("twitch.connect")).connected,
|
|
1584
|
+
});
|
|
1332
1585
|
listeners.get("ended")?.add(() => twitch.stop());
|
|
1333
1586
|
const saves = {
|
|
1334
1587
|
get: async (key) => {
|
|
@@ -1375,6 +1628,55 @@ function createSession(port, ready) {
|
|
|
1375
1628
|
return (await call("invites.open", { params, members }));
|
|
1376
1629
|
},
|
|
1377
1630
|
},
|
|
1631
|
+
players: {
|
|
1632
|
+
open: async (playerId, options = {}) => (await call("players.open", {
|
|
1633
|
+
playerId,
|
|
1634
|
+
...(options.view && { view: options.view }),
|
|
1635
|
+
})),
|
|
1636
|
+
},
|
|
1637
|
+
audio: {
|
|
1638
|
+
get volume() {
|
|
1639
|
+
return audio.volume;
|
|
1640
|
+
},
|
|
1641
|
+
get muted() {
|
|
1642
|
+
return audio.muted;
|
|
1643
|
+
},
|
|
1644
|
+
get channels() {
|
|
1645
|
+
return Object.fromEntries(Object.entries(audio.channels).map(([id, state]) => [id, { ...state }]));
|
|
1646
|
+
},
|
|
1647
|
+
level(channel) {
|
|
1648
|
+
const state = channel === undefined ? undefined : audio.channels[channel];
|
|
1649
|
+
const own = state ? (state.on ? state.volume : 0) : 1;
|
|
1650
|
+
return sound.master ? own : own * masterLevel(audio);
|
|
1651
|
+
},
|
|
1652
|
+
async register(channel) {
|
|
1653
|
+
const next = audioChannelsOf([
|
|
1654
|
+
...channels.filter((existing) => existing.id !== channel?.id),
|
|
1655
|
+
channel,
|
|
1656
|
+
]);
|
|
1657
|
+
if (!next.some((entry) => entry.id === channel?.id)) {
|
|
1658
|
+
throw new PowlyError(`A channel needs an id of letters, digits, _ and -, and a game has up to ${MAX_AUDIO_CHANNELS}.`);
|
|
1659
|
+
}
|
|
1660
|
+
// Same order as before; a changed channel keeps its place.
|
|
1661
|
+
channels = [
|
|
1662
|
+
...channels.map((existing) => next.find((entry) => entry.id === existing.id) ?? existing),
|
|
1663
|
+
...next.filter((entry) => !channels.some((existing) => existing.id === entry.id)),
|
|
1664
|
+
];
|
|
1665
|
+
const result = await call("audio.channels", { channels });
|
|
1666
|
+
setAudio(audioStateOf(result, channels));
|
|
1667
|
+
},
|
|
1668
|
+
async set(changes) {
|
|
1669
|
+
const result = await call("audio.set", { audio: changes });
|
|
1670
|
+
setAudio(audioStateOf(result, channels));
|
|
1671
|
+
},
|
|
1672
|
+
async open() {
|
|
1673
|
+
await call("audio.open");
|
|
1674
|
+
},
|
|
1675
|
+
on(_event, listener) {
|
|
1676
|
+
audioListeners.add(listener);
|
|
1677
|
+
return () => audioListeners.delete(listener);
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1378
1680
|
rooms: realtime.rooms,
|
|
1379
1681
|
twitch: {
|
|
1380
1682
|
info: twitch.info,
|
package/dist/powly.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/* Powly SDK 0.
|
|
1
|
+
/* Powly SDK 0.9.0: ES module */
|
|
2
2
|
/** This SDK's version; Powly's Studio shows it next to the downloads. */
|
|
3
|
-
const SDK_VERSION = "0.
|
|
3
|
+
const SDK_VERSION = "0.9.0";
|
|
4
4
|
|
|
5
5
|
// The messages between a game (this SDK, inside Powly's iframe) and the Powly page that framed it.
|
|
6
6
|
// Shared with the host side (apps/web/components/player/sdk-host.ts).
|
|
@@ -20,16 +20,62 @@ const OPTIONS_BUTTON_CORNERS = [
|
|
|
20
20
|
"bottom-right",
|
|
21
21
|
];
|
|
22
22
|
const isOptionsButtonCorner = (value) => OPTIONS_BUTTON_CORNERS.includes(value);
|
|
23
|
+
/** Channel ids: letters, digits, _ and -, starting with a letter, up to 32. */
|
|
24
|
+
const AUDIO_CHANNEL_ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
25
|
+
const MAX_AUDIO_CHANNELS = 8;
|
|
26
|
+
const MAX_AUDIO_LABEL = 32;
|
|
27
|
+
/** The channels a game sent, cleaned up: valid ids, short labels, no repeats, at most 8. */
|
|
28
|
+
function audioChannelsOf(value) {
|
|
29
|
+
if (!Array.isArray(value))
|
|
30
|
+
return [];
|
|
31
|
+
const channels = [];
|
|
32
|
+
for (const entry of value) {
|
|
33
|
+
if (typeof entry !== "object" || entry === null)
|
|
34
|
+
continue;
|
|
35
|
+
const { id, label, volume, toggle } = entry;
|
|
36
|
+
if (typeof id !== "string" || !AUDIO_CHANNEL_ID.test(id))
|
|
37
|
+
continue;
|
|
38
|
+
if (channels.some((channel) => channel.id === id))
|
|
39
|
+
continue;
|
|
40
|
+
const text = typeof label === "string" ? label.trim().slice(0, MAX_AUDIO_LABEL) : "";
|
|
41
|
+
const slider = volume !== false;
|
|
42
|
+
// Neither a slider nor a switch would show nothing: both, then.
|
|
43
|
+
const onOff = toggle !== false || !slider;
|
|
44
|
+
channels.push({ id, label: text || id, volume: slider, toggle: onOff });
|
|
45
|
+
if (channels.length === MAX_AUDIO_CHANNELS)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return channels;
|
|
49
|
+
}
|
|
50
|
+
/** A volume between 0 and 1, or null. */
|
|
51
|
+
const volumeOf = (value) => typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : null;
|
|
52
|
+
/** What a game may ask the Powly page for. */
|
|
53
|
+
const HOST_METHODS = [
|
|
54
|
+
"getSessionToken",
|
|
55
|
+
"purchases.open",
|
|
56
|
+
"fullscreen.toggle",
|
|
57
|
+
"invites.open",
|
|
58
|
+
"players.open",
|
|
59
|
+
"twitch.connect",
|
|
60
|
+
"audio.set",
|
|
61
|
+
"audio.channels",
|
|
62
|
+
"audio.open",
|
|
63
|
+
"quit",
|
|
64
|
+
];
|
|
65
|
+
/** "players.open": what Powly's player dialog opens on. */
|
|
66
|
+
const PLAYER_VIEWS = ["profile", "report"];
|
|
23
67
|
const isInitMessage = (data) => typeof data === "object" &&
|
|
24
68
|
data !== null &&
|
|
25
69
|
data.protocol === PROTOCOL &&
|
|
26
70
|
data.type === "init" &&
|
|
27
71
|
data.version === PROTOCOL_VERSION;
|
|
28
|
-
|
|
72
|
+
/** A request with a method this Powly doesn't know (from a newer SDK): answered with an error. */
|
|
73
|
+
const isRequestShaped = (data) => typeof data === "object" &&
|
|
29
74
|
data !== null &&
|
|
30
75
|
data.type === "request" &&
|
|
31
|
-
Number.isInteger(data.id)
|
|
32
|
-
|
|
76
|
+
Number.isInteger(data.id);
|
|
77
|
+
const isRequestMessage = (data) => isRequestShaped(data) &&
|
|
78
|
+
HOST_METHODS.includes(data.method);
|
|
33
79
|
|
|
34
80
|
// The Powly origins this build of the SDK trusts by default: a game only handshakes with a parent
|
|
35
81
|
// page on one of them (unless it passes `powlyOrigin` to `Powly.init()` itself). Set at build
|
|
@@ -859,6 +905,11 @@ function createRooms(options) {
|
|
|
859
905
|
// });
|
|
860
906
|
// }
|
|
861
907
|
//
|
|
908
|
+
// A game with a Twitch mode among others (Twitch chat "optional" in Studio) passes
|
|
909
|
+
// `{ prompt: true }` when the player picks that mode: if they haven't connected Twitch, Powly asks
|
|
910
|
+
// them to in its own dialog (drawn by Powly, outside the game), and `connect()` resolves once
|
|
911
|
+
// they did or closed it.
|
|
912
|
+
//
|
|
862
913
|
// Chat is Twitch IRC over a WebSocket (https://dev.twitch.tv/docs/chat/irc/). The connection comes
|
|
863
914
|
// back by itself when it drops, and stops when the launch ends.
|
|
864
915
|
const TWITCH_CHAT_URL = "wss://irc-ws.chat.twitch.tv:443";
|
|
@@ -1099,8 +1150,12 @@ function createTwitch(options) {
|
|
|
1099
1150
|
};
|
|
1100
1151
|
return {
|
|
1101
1152
|
info: options.getInfo,
|
|
1102
|
-
async connect() {
|
|
1103
|
-
|
|
1153
|
+
async connect(connectOptions = {}) {
|
|
1154
|
+
let info = await options.getInfo();
|
|
1155
|
+
if (info.status === "not_connected" && connectOptions.prompt && options.prompt) {
|
|
1156
|
+
if (await options.prompt())
|
|
1157
|
+
info = await options.getInfo();
|
|
1158
|
+
}
|
|
1104
1159
|
const next = info.status === "connected" ? info.channel : null;
|
|
1105
1160
|
if (!next || stopped) {
|
|
1106
1161
|
disconnect();
|
|
@@ -1139,6 +1194,154 @@ function createTwitch(options) {
|
|
|
1139
1194
|
};
|
|
1140
1195
|
}
|
|
1141
1196
|
|
|
1197
|
+
// Powly's sound menu for games (`powly.audio`): the player's overall volume, a mute (Powly's M
|
|
1198
|
+
// key), and the kinds of sound the game registers (channels: music, drawing sounds…), each with a
|
|
1199
|
+
// volume slider and an on/off switch, or one of them. The player sets them in Powly's options
|
|
1200
|
+
// bar; the game hears "change".
|
|
1201
|
+
//
|
|
1202
|
+
// const powly = await Powly.init({
|
|
1203
|
+
// audio: { channels: [{ id: "music", label: "Music" }] },
|
|
1204
|
+
// });
|
|
1205
|
+
// await powly.audio.register({ id: "drawing", label: "Drawing sounds", volume: false });
|
|
1206
|
+
// music.volume = powly.audio.level("music");
|
|
1207
|
+
// powly.audio.on("change", () => { music.volume = powly.audio.level("music"); });
|
|
1208
|
+
// if (powly.audio.level("drawing") > 0) playScribble();
|
|
1209
|
+
//
|
|
1210
|
+
// The overall volume and mute apply by themselves (`auto`, on by default): the SDK puts a volume
|
|
1211
|
+
// control in front of the page's speakers, for Web Audio (every connection to an AudioContext's
|
|
1212
|
+
// destination goes through it) and for <audio>/<video> (their volume is scaled). So every game
|
|
1213
|
+
// on this SDK follows Powly's volume, and channels only need the channel's own value:
|
|
1214
|
+
// `level(channel)` is what to play at either way.
|
|
1215
|
+
/** Volume of the page's sound overall: 0 when muted. */
|
|
1216
|
+
const masterLevel = (state) => (state.muted ? 0 : state.volume);
|
|
1217
|
+
/** Live references to objects, for walking them later without keeping them alive. */
|
|
1218
|
+
function weakList() {
|
|
1219
|
+
const seen = new WeakSet();
|
|
1220
|
+
const refs = new Set();
|
|
1221
|
+
return {
|
|
1222
|
+
add(item) {
|
|
1223
|
+
if (seen.has(item))
|
|
1224
|
+
return false;
|
|
1225
|
+
seen.add(item);
|
|
1226
|
+
refs.add(new WeakRef(item));
|
|
1227
|
+
return true;
|
|
1228
|
+
},
|
|
1229
|
+
has: (item) => seen.has(item),
|
|
1230
|
+
*[Symbol.iterator]() {
|
|
1231
|
+
for (const ref of refs) {
|
|
1232
|
+
const item = ref.deref();
|
|
1233
|
+
if (item)
|
|
1234
|
+
yield item;
|
|
1235
|
+
else
|
|
1236
|
+
refs.delete(ref);
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Puts a volume control in front of the page's speakers: Web Audio connections to a real-time
|
|
1243
|
+
* AudioContext's destination go through a gain node per context, and media elements' volume is
|
|
1244
|
+
* scaled (the volume the game set still reads back unchanged; elements played through Web Audio
|
|
1245
|
+
* are left to the gain node). Returns how to set it.
|
|
1246
|
+
*/
|
|
1247
|
+
function installMasterGain(win = window) {
|
|
1248
|
+
let level = 1;
|
|
1249
|
+
// Web Audio.
|
|
1250
|
+
const gainOf = new WeakMap();
|
|
1251
|
+
const gains = weakList();
|
|
1252
|
+
const Destination = win.AudioDestinationNode;
|
|
1253
|
+
const Offline = win.OfflineAudioContext;
|
|
1254
|
+
const nodeProto = win.AudioNode?.prototype;
|
|
1255
|
+
const connect = nodeProto?.connect;
|
|
1256
|
+
const disconnect = nodeProto?.disconnect;
|
|
1257
|
+
/** The gain node standing in for `target` when it's a real-time context's destination. */
|
|
1258
|
+
const standIn = (target) => {
|
|
1259
|
+
if (!Destination || !(target instanceof Destination))
|
|
1260
|
+
return null;
|
|
1261
|
+
const context = target.context;
|
|
1262
|
+
if (Offline && context instanceof Offline)
|
|
1263
|
+
return null;
|
|
1264
|
+
let gain = gainOf.get(context);
|
|
1265
|
+
if (!gain) {
|
|
1266
|
+
gain = context.createGain();
|
|
1267
|
+
gain.gain.value = level;
|
|
1268
|
+
gains.add(gain);
|
|
1269
|
+
connect?.call(gain, target);
|
|
1270
|
+
gainOf.set(context, gain);
|
|
1271
|
+
}
|
|
1272
|
+
return gain;
|
|
1273
|
+
};
|
|
1274
|
+
if (nodeProto && connect && disconnect) {
|
|
1275
|
+
nodeProto.connect = function (target, ...rest) {
|
|
1276
|
+
const gain = gains.has(this) ? null : standIn(target);
|
|
1277
|
+
if (!gain)
|
|
1278
|
+
return connect.call(this, target, ...rest);
|
|
1279
|
+
connect.call(this, gain, ...rest);
|
|
1280
|
+
// Chaining keeps working: connect() answers what it was given.
|
|
1281
|
+
return target;
|
|
1282
|
+
};
|
|
1283
|
+
nodeProto.disconnect = function (...args) {
|
|
1284
|
+
const gain = gains.has(this) ? null : standIn(args[0]);
|
|
1285
|
+
return gain ? disconnect.call(this, gain, ...args.slice(1)) : disconnect.apply(this, args);
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
// Media elements: the volume the game set, and the one played (times `level`).
|
|
1289
|
+
const media = win.HTMLMediaElement?.prototype;
|
|
1290
|
+
const volume = media ? Object.getOwnPropertyDescriptor(media, "volume") : undefined;
|
|
1291
|
+
const wanted = new WeakMap();
|
|
1292
|
+
const elements = weakList();
|
|
1293
|
+
const routed = new WeakSet();
|
|
1294
|
+
const apply = (element) => {
|
|
1295
|
+
const own = wanted.get(element) ?? 1;
|
|
1296
|
+
volume?.set?.call(element, routed.has(element) ? own : own * level);
|
|
1297
|
+
};
|
|
1298
|
+
const track = (element) => {
|
|
1299
|
+
if (!wanted.has(element))
|
|
1300
|
+
wanted.set(element, volume?.get?.call(element) ?? 1);
|
|
1301
|
+
elements.add(element);
|
|
1302
|
+
apply(element);
|
|
1303
|
+
};
|
|
1304
|
+
if (media && volume?.get && volume.set) {
|
|
1305
|
+
const { get, set } = volume;
|
|
1306
|
+
Object.defineProperty(media, "volume", {
|
|
1307
|
+
configurable: true,
|
|
1308
|
+
enumerable: volume.enumerable,
|
|
1309
|
+
get() {
|
|
1310
|
+
return wanted.get(this) ?? get.call(this);
|
|
1311
|
+
},
|
|
1312
|
+
set(value) {
|
|
1313
|
+
// Out of range throws, like the browser's own setter.
|
|
1314
|
+
set.call(this, value);
|
|
1315
|
+
wanted.set(this, value);
|
|
1316
|
+
track(this);
|
|
1317
|
+
},
|
|
1318
|
+
});
|
|
1319
|
+
const play = media.play;
|
|
1320
|
+
media.play = function () {
|
|
1321
|
+
track(this);
|
|
1322
|
+
return play.call(this);
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
const createSource = win.AudioContext?.prototype.createMediaElementSource;
|
|
1326
|
+
if (createSource) {
|
|
1327
|
+
win.AudioContext.prototype.createMediaElementSource = function (element) {
|
|
1328
|
+
routed.add(element);
|
|
1329
|
+
track(element);
|
|
1330
|
+
return createSource.call(this, element);
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
return {
|
|
1334
|
+
set(next) {
|
|
1335
|
+
level = next;
|
|
1336
|
+
for (const gain of gains)
|
|
1337
|
+
gain.gain.value = level;
|
|
1338
|
+
for (const element of elements)
|
|
1339
|
+
apply(element);
|
|
1340
|
+
},
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
|
|
1142
1345
|
|
|
1143
1346
|
|
|
1144
1347
|
|
|
@@ -1156,6 +1359,8 @@ function createTwitch(options) {
|
|
|
1156
1359
|
// const token = await powly.getSessionToken(); // send it to your backend, which verifies it
|
|
1157
1360
|
// const room = await powly.rooms.create(); // realtime parties (src/rooms.ts)
|
|
1158
1361
|
// await powly.twitch.connect(); // the streamer's Twitch chat (src/twitch.ts)
|
|
1362
|
+
// await powly.players.open(playerId); // Powly's profile dialog: add, block, report
|
|
1363
|
+
// powly.audio.level("music"); // Powly's sound menu (src/audio.ts)
|
|
1159
1364
|
//
|
|
1160
1365
|
// Nothing here is a security boundary: the game runs in the player's browser. Your backend must
|
|
1161
1366
|
// verify session tokens (JWKS) or exchange them, and Powly checks everything server-side.
|
|
@@ -1200,6 +1405,12 @@ function init(options = {}) {
|
|
|
1200
1405
|
if (origins.length === 0) {
|
|
1201
1406
|
return Promise.reject(new PowlyError("This game only runs when launched from Powly."));
|
|
1202
1407
|
}
|
|
1408
|
+
const audioChannels = audioChannelsOf(options.audio?.channels ?? []);
|
|
1409
|
+
// Before the game's engine makes a sound, so its connections go through the volume control.
|
|
1410
|
+
const auto = options.audio?.auto !== false;
|
|
1411
|
+
if (auto)
|
|
1412
|
+
masterGain ??= installMasterGain();
|
|
1413
|
+
const master = auto ? masterGain : null;
|
|
1203
1414
|
return new Promise((resolve, reject) => {
|
|
1204
1415
|
const channels = origins.map((origin) => {
|
|
1205
1416
|
const channel = new MessageChannel();
|
|
@@ -1212,6 +1423,7 @@ function init(options = {}) {
|
|
|
1212
1423
|
...(isOptionsButtonCorner(options.optionsButton) && {
|
|
1213
1424
|
optionsButton: options.optionsButton,
|
|
1214
1425
|
}),
|
|
1426
|
+
audio: { channels: audioChannels },
|
|
1215
1427
|
};
|
|
1216
1428
|
channel.port1.onmessage = (event) => {
|
|
1217
1429
|
const data = event.data;
|
|
@@ -1220,7 +1432,7 @@ function init(options = {}) {
|
|
|
1220
1432
|
for (const other of channels)
|
|
1221
1433
|
if (other !== channel)
|
|
1222
1434
|
other.port1.close();
|
|
1223
|
-
resolve(createSession(channel.port1, data));
|
|
1435
|
+
resolve(createSession(channel.port1, data, { channels: audioChannels, master }));
|
|
1224
1436
|
}
|
|
1225
1437
|
else if (data?.type === "failed") {
|
|
1226
1438
|
clearTimeout(timer);
|
|
@@ -1238,7 +1450,22 @@ function init(options = {}) {
|
|
|
1238
1450
|
}, options.timeoutMs ?? 10_000);
|
|
1239
1451
|
});
|
|
1240
1452
|
}
|
|
1241
|
-
|
|
1453
|
+
/** The page's volume control (src/audio.ts), put in once even if init runs again. */
|
|
1454
|
+
let masterGain = null;
|
|
1455
|
+
/** The sound settings Powly sent, with every channel (on, at full volume, when it sent none). */
|
|
1456
|
+
function audioStateOf(value, channels) {
|
|
1457
|
+
const sent = (typeof value === "object" && value !== null ? value : {});
|
|
1458
|
+
const levels = ((typeof sent.channels === "object" && sent.channels) || {});
|
|
1459
|
+
return {
|
|
1460
|
+
volume: volumeOf(sent.volume) ?? 1,
|
|
1461
|
+
muted: sent.muted === true,
|
|
1462
|
+
channels: Object.fromEntries(channels.map((channel) => {
|
|
1463
|
+
const level = levels[channel.id];
|
|
1464
|
+
return [channel.id, { volume: volumeOf(level?.volume) ?? 1, on: level?.on !== false }];
|
|
1465
|
+
})),
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
function createSession(port, ready, sound) {
|
|
1242
1469
|
let token = ready.token;
|
|
1243
1470
|
let expiresAt = Date.now() + ready.expiresIn * 1000;
|
|
1244
1471
|
let refreshing = null;
|
|
@@ -1247,6 +1474,23 @@ function createSession(port, ready) {
|
|
|
1247
1474
|
const listeners = new Map();
|
|
1248
1475
|
// "shown" comes once; games that listen for it later still hear it.
|
|
1249
1476
|
let shown = false;
|
|
1477
|
+
// The player's sound settings; the page's volume follows them (auto).
|
|
1478
|
+
let channels = sound.channels;
|
|
1479
|
+
let audio = audioStateOf(ready.audio, channels);
|
|
1480
|
+
const audioListeners = new Set();
|
|
1481
|
+
sound.master?.set(masterLevel(audio));
|
|
1482
|
+
const setAudio = (next) => {
|
|
1483
|
+
audio = next;
|
|
1484
|
+
sound.master?.set(masterLevel(audio));
|
|
1485
|
+
for (const listener of audioListeners) {
|
|
1486
|
+
try {
|
|
1487
|
+
listener(audio);
|
|
1488
|
+
}
|
|
1489
|
+
catch (error) {
|
|
1490
|
+
console.error("[powly] an audio listener failed", error);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1250
1494
|
port.onmessage = (event) => {
|
|
1251
1495
|
const data = event.data;
|
|
1252
1496
|
if (data?.type === "response") {
|
|
@@ -1260,7 +1504,13 @@ function createSession(port, ready) {
|
|
|
1260
1504
|
waiting.reject(new PowlyError(data.error));
|
|
1261
1505
|
}
|
|
1262
1506
|
else if (data?.type === "event") {
|
|
1263
|
-
const
|
|
1507
|
+
const message = data;
|
|
1508
|
+
if (message.event === "audio") {
|
|
1509
|
+
if ("audio" in message)
|
|
1510
|
+
setAudio(audioStateOf(message.audio, channels));
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const name = message.event;
|
|
1264
1514
|
if (name === "shown") {
|
|
1265
1515
|
if (shown)
|
|
1266
1516
|
return;
|
|
@@ -1326,7 +1576,10 @@ function createSession(port, ready) {
|
|
|
1326
1576
|
invite: sendInvite,
|
|
1327
1577
|
});
|
|
1328
1578
|
listeners.get("ended")?.add(() => realtime.stop());
|
|
1329
|
-
const twitch = createTwitch({
|
|
1579
|
+
const twitch = createTwitch({
|
|
1580
|
+
getInfo: () => api("/twitch"),
|
|
1581
|
+
prompt: async () => (await call("twitch.connect")).connected,
|
|
1582
|
+
});
|
|
1330
1583
|
listeners.get("ended")?.add(() => twitch.stop());
|
|
1331
1584
|
const saves = {
|
|
1332
1585
|
get: async (key) => {
|
|
@@ -1373,6 +1626,55 @@ function createSession(port, ready) {
|
|
|
1373
1626
|
return (await call("invites.open", { params, members }));
|
|
1374
1627
|
},
|
|
1375
1628
|
},
|
|
1629
|
+
players: {
|
|
1630
|
+
open: async (playerId, options = {}) => (await call("players.open", {
|
|
1631
|
+
playerId,
|
|
1632
|
+
...(options.view && { view: options.view }),
|
|
1633
|
+
})),
|
|
1634
|
+
},
|
|
1635
|
+
audio: {
|
|
1636
|
+
get volume() {
|
|
1637
|
+
return audio.volume;
|
|
1638
|
+
},
|
|
1639
|
+
get muted() {
|
|
1640
|
+
return audio.muted;
|
|
1641
|
+
},
|
|
1642
|
+
get channels() {
|
|
1643
|
+
return Object.fromEntries(Object.entries(audio.channels).map(([id, state]) => [id, { ...state }]));
|
|
1644
|
+
},
|
|
1645
|
+
level(channel) {
|
|
1646
|
+
const state = channel === undefined ? undefined : audio.channels[channel];
|
|
1647
|
+
const own = state ? (state.on ? state.volume : 0) : 1;
|
|
1648
|
+
return sound.master ? own : own * masterLevel(audio);
|
|
1649
|
+
},
|
|
1650
|
+
async register(channel) {
|
|
1651
|
+
const next = audioChannelsOf([
|
|
1652
|
+
...channels.filter((existing) => existing.id !== channel?.id),
|
|
1653
|
+
channel,
|
|
1654
|
+
]);
|
|
1655
|
+
if (!next.some((entry) => entry.id === channel?.id)) {
|
|
1656
|
+
throw new PowlyError(`A channel needs an id of letters, digits, _ and -, and a game has up to ${MAX_AUDIO_CHANNELS}.`);
|
|
1657
|
+
}
|
|
1658
|
+
// Same order as before; a changed channel keeps its place.
|
|
1659
|
+
channels = [
|
|
1660
|
+
...channels.map((existing) => next.find((entry) => entry.id === existing.id) ?? existing),
|
|
1661
|
+
...next.filter((entry) => !channels.some((existing) => existing.id === entry.id)),
|
|
1662
|
+
];
|
|
1663
|
+
const result = await call("audio.channels", { channels });
|
|
1664
|
+
setAudio(audioStateOf(result, channels));
|
|
1665
|
+
},
|
|
1666
|
+
async set(changes) {
|
|
1667
|
+
const result = await call("audio.set", { audio: changes });
|
|
1668
|
+
setAudio(audioStateOf(result, channels));
|
|
1669
|
+
},
|
|
1670
|
+
async open() {
|
|
1671
|
+
await call("audio.open");
|
|
1672
|
+
},
|
|
1673
|
+
on(_event, listener) {
|
|
1674
|
+
audioListeners.add(listener);
|
|
1675
|
+
return () => audioListeners.delete(listener);
|
|
1676
|
+
},
|
|
1677
|
+
},
|
|
1376
1678
|
rooms: realtime.rooms,
|
|
1377
1679
|
twitch: {
|
|
1378
1680
|
info: twitch.info,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@powly/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Powly game SDK: launch handshake, session tokens and platform services.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,4 +46,4 @@
|
|
|
46
46
|
"example:zip": "node scripts/example-zip.ts",
|
|
47
47
|
"mirror": "node scripts/mirror.ts"
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
}
|