@thestatic-tv/dcl-sdk 1.0.7 → 2.0.0-beta.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/dist/index.mjs CHANGED
@@ -96,8 +96,8 @@ function getPlayerWallet() {
96
96
  return cachedWallet;
97
97
  }
98
98
  try {
99
- const { getPlayer } = __require("@dcl/sdk/players");
100
- const player = getPlayer();
99
+ const { getPlayer: getPlayer2 } = __require("@dcl/sdk/players");
100
+ const player = getPlayer2();
101
101
  return player?.userId ?? null;
102
102
  } catch {
103
103
  return null;
@@ -108,8 +108,8 @@ function getPlayerDisplayName() {
108
108
  return cachedDisplayName;
109
109
  }
110
110
  try {
111
- const { getPlayer } = __require("@dcl/sdk/players");
112
- const player = getPlayer();
111
+ const { getPlayer: getPlayer2 } = __require("@dcl/sdk/players");
112
+ const player = getPlayer2();
113
113
  return player?.name ?? null;
114
114
  } catch {
115
115
  return null;
@@ -158,6 +158,50 @@ function dclClearInterval(timerId) {
158
158
  timers.delete(timerId);
159
159
  }
160
160
  }
161
+ var timeouts = /* @__PURE__ */ new Map();
162
+ var timeoutSystemAdded = false;
163
+ function ensureTimeoutSystem() {
164
+ if (timeoutSystemAdded) return;
165
+ engine.addSystem((dt) => {
166
+ const toRemove = [];
167
+ for (const timeout of timeouts.values()) {
168
+ if (!timeout.active) continue;
169
+ timeout.elapsedTime += dt;
170
+ if (timeout.elapsedTime >= timeout.delaySeconds) {
171
+ timeout.active = false;
172
+ toRemove.push(timeout.id);
173
+ try {
174
+ timeout.callback();
175
+ } catch (e) {
176
+ console.error("[StaticTV Timer] Timeout callback error:", e);
177
+ }
178
+ }
179
+ }
180
+ for (const id of toRemove) {
181
+ timeouts.delete(id);
182
+ }
183
+ });
184
+ timeoutSystemAdded = true;
185
+ }
186
+ function dclSetTimeout(callback, delayMs) {
187
+ ensureTimeoutSystem();
188
+ const id = nextTimerId++;
189
+ timeouts.set(id, {
190
+ id,
191
+ callback,
192
+ delaySeconds: delayMs / 1e3,
193
+ elapsedTime: 0,
194
+ active: true
195
+ });
196
+ return id;
197
+ }
198
+ function dclClearTimeout(timeoutId) {
199
+ const timeout = timeouts.get(timeoutId);
200
+ if (timeout) {
201
+ timeout.active = false;
202
+ timeouts.delete(timeoutId);
203
+ }
204
+ }
161
205
 
162
206
  // src/modules/session.ts
163
207
  var SessionModule = class {
@@ -470,6 +514,1548 @@ var InteractionsModule = class {
470
514
  }
471
515
  };
472
516
 
517
+ // src/ui/guide-ui.tsx
518
+ import ReactEcs2, { UiEntity as UiEntity2, Input as Input2, Label as Label2 } from "@dcl/sdk/react-ecs";
519
+ import { Color4 as Color43 } from "@dcl/sdk/math";
520
+ import { openExternalUrl } from "~system/RestrictedActions";
521
+
522
+ // src/ui/theme.ts
523
+ import { Color4 } from "@dcl/sdk/math";
524
+ var THEME = {
525
+ colors: {
526
+ panel: Color4.create(0.05, 0.05, 0.08, 0.98),
527
+ panelDark: Color4.create(0.04, 0.04, 0.06, 1),
528
+ panelBorder: Color4.create(0.2, 0.2, 0.2, 1),
529
+ inputBackground: Color4.create(0.1, 0.1, 0.12, 1),
530
+ buttonBackground: Color4.create(0.15, 0.15, 0.18, 1),
531
+ buttonHover: Color4.create(0.2, 0.2, 0.24, 1),
532
+ activeBackground: Color4.create(0, 1, 1, 0.1),
533
+ cyan: Color4.create(0, 1, 1, 1),
534
+ magenta: Color4.create(1, 0, 1, 1),
535
+ yellow: Color4.create(1, 0.8, 0, 1),
536
+ red: Color4.create(1, 0.2, 0.2, 1),
537
+ green: Color4.create(0, 1, 0, 1),
538
+ white: Color4.White(),
539
+ gray: Color4.create(0.5, 0.5, 0.5, 1),
540
+ grayText: Color4.create(0.35, 0.35, 0.35, 1),
541
+ darkGray: Color4.create(0.15, 0.15, 0.15, 1)
542
+ }
543
+ };
544
+ var UI_DIMENSIONS = {
545
+ // Guide UI
546
+ guide: {
547
+ width: 900,
548
+ height: "55%",
549
+ bottom: 55,
550
+ right: 20,
551
+ sidebar: {
552
+ width: 300,
553
+ buttonHeight: 42,
554
+ fontSize: 16,
555
+ headerSize: 24,
556
+ subHeaderSize: 14
557
+ },
558
+ content: {
559
+ cardHeight: 150,
560
+ cardWidth: 260,
561
+ headerSize: 20,
562
+ bodySize: 16,
563
+ padding: 15
564
+ }
565
+ },
566
+ // Chat UI
567
+ chat: {
568
+ width: 340,
569
+ height: 480,
570
+ bottom: 55,
571
+ right: 20,
572
+ messagesPerPage: 5,
573
+ channelsPerPage: 6
574
+ },
575
+ // Shared
576
+ closeButton: {
577
+ size: 40,
578
+ fontSize: 24
579
+ }
580
+ };
581
+ var DEFAULT_CHAT_THEME = {
582
+ header: 22,
583
+ channelButton: 14,
584
+ channelDropdown: 14,
585
+ systemMessage: 14,
586
+ chatUsername: 16,
587
+ chatTimestamp: 11,
588
+ chatMessage: 16,
589
+ input: 16,
590
+ sendButton: 14,
591
+ userInfo: 14,
592
+ authStatus: 12,
593
+ notification: 18,
594
+ closeButton: 16
595
+ };
596
+ function scaleChatTheme(theme, fontScale) {
597
+ return {
598
+ header: Math.round(theme.header * fontScale),
599
+ channelButton: Math.round(theme.channelButton * fontScale),
600
+ channelDropdown: Math.round(theme.channelDropdown * fontScale),
601
+ systemMessage: Math.round(theme.systemMessage * fontScale),
602
+ chatUsername: Math.round(theme.chatUsername * fontScale),
603
+ chatTimestamp: Math.round(theme.chatTimestamp * fontScale),
604
+ chatMessage: Math.round(theme.chatMessage * fontScale),
605
+ input: Math.round(theme.input * fontScale),
606
+ sendButton: Math.round(theme.sendButton * fontScale),
607
+ userInfo: Math.round(theme.userInfo * fontScale),
608
+ authStatus: Math.round(theme.authStatus * fontScale),
609
+ notification: Math.round(theme.notification * fontScale),
610
+ closeButton: Math.round(theme.closeButton * fontScale)
611
+ };
612
+ }
613
+
614
+ // src/ui/components.tsx
615
+ import ReactEcs, { UiEntity, Label, Input } from "@dcl/sdk/react-ecs";
616
+ import { Color4 as Color42 } from "@dcl/sdk/math";
617
+ var PanelHeader = (props) => {
618
+ const fontSize = props.fontSize || 14;
619
+ const fontScale = props.fontScale || 1;
620
+ const scaledFontSize = Math.round(fontSize * fontScale);
621
+ const currentPos = props.position || "right";
622
+ return ReactEcs.createElement(UiEntity, {
623
+ uiTransform: {
624
+ height: 36,
625
+ minHeight: 36,
626
+ flexShrink: 0,
627
+ width: "100%",
628
+ flexDirection: "row",
629
+ justifyContent: "space-between",
630
+ alignItems: "center",
631
+ padding: { left: 10, right: 6 }
632
+ },
633
+ uiBackground: { color: THEME.colors.panelDark },
634
+ children: [
635
+ // Title
636
+ ReactEcs.createElement(UiEntity, {
637
+ uiText: { value: props.title, fontSize: scaledFontSize, color: THEME.colors.cyan }
638
+ }),
639
+ // Controls
640
+ ReactEcs.createElement(UiEntity, {
641
+ uiTransform: { flexDirection: "row", gap: 4, alignItems: "center" },
642
+ children: [
643
+ // Position controls
644
+ ...props.showPositionControls ? [
645
+ ReactEcs.createElement(UiEntity, {
646
+ key: "pos-left",
647
+ uiTransform: { width: 22, height: 22, justifyContent: "center", alignItems: "center" },
648
+ uiBackground: { color: currentPos === "left" ? Color42.create(0, 0.3, 0.3, 1) : THEME.colors.buttonBackground },
649
+ onMouseDown: () => props.onPositionChange?.("left"),
650
+ children: [ReactEcs.createElement(UiEntity, { uiText: { value: "\u25C0", fontSize: 10, color: THEME.colors.cyan } })]
651
+ }),
652
+ ReactEcs.createElement(UiEntity, {
653
+ key: "pos-center",
654
+ uiTransform: { width: 22, height: 22, justifyContent: "center", alignItems: "center" },
655
+ uiBackground: { color: currentPos === "center" ? Color42.create(0, 0.3, 0.3, 1) : THEME.colors.buttonBackground },
656
+ onMouseDown: () => props.onPositionChange?.("center"),
657
+ children: [ReactEcs.createElement(UiEntity, { uiText: { value: "\u25CF", fontSize: 10, color: THEME.colors.cyan } })]
658
+ }),
659
+ ReactEcs.createElement(UiEntity, {
660
+ key: "pos-right",
661
+ uiTransform: { width: 22, height: 22, justifyContent: "center", alignItems: "center" },
662
+ uiBackground: { color: currentPos === "right" ? Color42.create(0, 0.3, 0.3, 1) : THEME.colors.buttonBackground },
663
+ onMouseDown: () => props.onPositionChange?.("right"),
664
+ children: [ReactEcs.createElement(UiEntity, { uiText: { value: "\u25B6", fontSize: 10, color: THEME.colors.cyan } })]
665
+ }),
666
+ ReactEcs.createElement(UiEntity, { key: "pos-spacer", uiTransform: { width: 6 } })
667
+ ] : [],
668
+ // Font controls
669
+ ...props.showFontControls ? [
670
+ ReactEcs.createElement(UiEntity, {
671
+ key: "font-down",
672
+ uiTransform: { width: 22, height: 22, justifyContent: "center", alignItems: "center" },
673
+ uiBackground: { color: THEME.colors.buttonBackground },
674
+ onMouseDown: () => props.onFontScaleDown?.(),
675
+ children: [ReactEcs.createElement(UiEntity, { uiText: { value: "\u2212", fontSize: 14, color: THEME.colors.cyan } })]
676
+ }),
677
+ ReactEcs.createElement(UiEntity, {
678
+ key: "font-up",
679
+ uiTransform: { width: 22, height: 22, justifyContent: "center", alignItems: "center" },
680
+ uiBackground: { color: THEME.colors.buttonBackground },
681
+ onMouseDown: () => props.onFontScaleUp?.(),
682
+ children: [ReactEcs.createElement(UiEntity, { uiText: { value: "+", fontSize: 14, color: THEME.colors.cyan } })]
683
+ }),
684
+ ReactEcs.createElement(UiEntity, { key: "font-spacer", uiTransform: { width: 6 } })
685
+ ] : [],
686
+ // Close button
687
+ ...props.onClose ? [
688
+ ReactEcs.createElement(UiEntity, {
689
+ key: "close",
690
+ uiTransform: { width: 24, height: 22, justifyContent: "center", alignItems: "center" },
691
+ uiBackground: { color: THEME.colors.buttonBackground },
692
+ onMouseDown: () => props.onClose?.(),
693
+ children: [ReactEcs.createElement(UiEntity, { uiText: { value: "X", fontSize: 12, color: THEME.colors.red } })]
694
+ })
695
+ ] : []
696
+ ]
697
+ })
698
+ ]
699
+ });
700
+ };
701
+ function truncateText(text, maxLength) {
702
+ if (text.length <= maxLength) return text;
703
+ return text.substring(0, maxLength - 3) + "...";
704
+ }
705
+
706
+ // src/ui/guide-ui.tsx
707
+ var GuideUIModule = class {
708
+ constructor(client, config = {}) {
709
+ // Visibility state
710
+ this._isVisible = false;
711
+ // Data state
712
+ this.videos = [];
713
+ this.featuredPlaylists = [];
714
+ this.liveVideos = [];
715
+ // UI state
716
+ this.activeTab = "SIGNALS";
717
+ this.currentPage = 0;
718
+ this.itemsPerPage = 6;
719
+ this.searchQuery = "";
720
+ this.uiScale = 1;
721
+ // Current video tracking (for "PLAYING" indicator)
722
+ this._currentVideoId = null;
723
+ // =============================================================================
724
+ // --- UI RENDERING ---
725
+ // =============================================================================
726
+ /**
727
+ * Get the guide UI component for rendering
728
+ */
729
+ this.getComponent = () => {
730
+ if (!this._isVisible) return null;
731
+ const windowW = this.s(UI_DIMENSIONS.guide.width);
732
+ return ReactEcs2.createElement(UiEntity2, {
733
+ uiTransform: {
734
+ width: windowW,
735
+ height: UI_DIMENSIONS.guide.height,
736
+ positionType: "absolute",
737
+ position: { bottom: UI_DIMENSIONS.guide.bottom, right: UI_DIMENSIONS.guide.right },
738
+ flexDirection: "row",
739
+ border: { top: 2, bottom: 2, left: 2, right: 2 },
740
+ borderColor: THEME.colors.panelBorder
741
+ },
742
+ uiBackground: { color: THEME.colors.panel },
743
+ children: [
744
+ this.renderLeftPanel(),
745
+ this.renderRightPanel(),
746
+ this.renderCloseButton()
747
+ ]
748
+ });
749
+ };
750
+ this.client = client;
751
+ this.config = config;
752
+ this.uiScale = config.uiScale || 1;
753
+ this._currentVideoId = config.currentVideoId || null;
754
+ }
755
+ /**
756
+ * Initialize the guide - fetch channel data
757
+ */
758
+ async init() {
759
+ await this.fetchGuideData();
760
+ this.client.log("GuideUI initialized");
761
+ }
762
+ /**
763
+ * Show the guide UI
764
+ */
765
+ show() {
766
+ this._isVisible = true;
767
+ this.fetchGuideData().catch(() => {
768
+ });
769
+ }
770
+ /**
771
+ * Hide the guide UI
772
+ */
773
+ hide() {
774
+ this._isVisible = false;
775
+ this.activeTab = "SIGNALS";
776
+ this.searchQuery = "";
777
+ this.currentPage = 0;
778
+ }
779
+ /**
780
+ * Toggle guide visibility
781
+ */
782
+ toggle() {
783
+ if (this._isVisible) {
784
+ this.hide();
785
+ } else {
786
+ this.show();
787
+ }
788
+ }
789
+ /**
790
+ * Check if guide is visible
791
+ */
792
+ get isVisible() {
793
+ return this._isVisible;
794
+ }
795
+ /**
796
+ * Set the currently playing video ID (for "PLAYING" indicator)
797
+ */
798
+ set currentVideoId(id) {
799
+ this._currentVideoId = id;
800
+ }
801
+ /**
802
+ * Get the currently playing video ID
803
+ */
804
+ get currentVideoId() {
805
+ return this._currentVideoId;
806
+ }
807
+ /**
808
+ * Get all videos
809
+ */
810
+ getVideos() {
811
+ return this.videos;
812
+ }
813
+ /**
814
+ * Get live videos only
815
+ */
816
+ getLiveVideos() {
817
+ return this.liveVideos;
818
+ }
819
+ /**
820
+ * Refresh guide data
821
+ */
822
+ async refresh() {
823
+ await this.fetchGuideData();
824
+ }
825
+ // =============================================================================
826
+ // --- INTERNAL DATA FETCHING ---
827
+ // =============================================================================
828
+ getBaseUrl() {
829
+ const config = this.client.getConfig();
830
+ const baseUrl = config.baseUrl || "https://thestatic.tv/api/v1/dcl";
831
+ return baseUrl.replace("/api/v1/dcl", "");
832
+ }
833
+ async fetchGuideData() {
834
+ try {
835
+ const baseUrl = this.getBaseUrl();
836
+ const response = await fetch(`${baseUrl}/api/v1/guide`);
837
+ if (!response.ok) {
838
+ this.client.log("Guide API request failed");
839
+ return;
840
+ }
841
+ const data = await response.json();
842
+ const videos = [];
843
+ const liveVideos = [];
844
+ if (data.videoMap) {
845
+ for (const [slug, video] of Object.entries(data.videoMap)) {
846
+ const v = video;
847
+ const mapped = {
848
+ id: v.id || slug,
849
+ name: v.name || v.title || "Unknown",
850
+ src: v.src || v.liveStreamUrl || "",
851
+ isLive: v.isLive === true || v.type === "live",
852
+ isCreator: v.isCreator || v.type === "live",
853
+ channelId: v.channelId || slug,
854
+ duration: v.duration,
855
+ requiresAuth: v.requiresAuth
856
+ };
857
+ videos.push(mapped);
858
+ if (mapped.isLive) {
859
+ liveVideos.push(mapped);
860
+ }
861
+ }
862
+ }
863
+ const playlists = [];
864
+ if (data.featuredPlaylists) {
865
+ for (const pl of data.featuredPlaylists) {
866
+ playlists.push({
867
+ name: pl.name,
868
+ videos: (pl.videos || []).map((v) => ({
869
+ id: v.id,
870
+ name: v.name || v.title || "Unknown",
871
+ src: v.src || v.liveStreamUrl || "",
872
+ isLive: v.isLive === true,
873
+ isCreator: v.isCreator,
874
+ channelId: v.channelId || v.id,
875
+ duration: v.duration,
876
+ requiresAuth: v.requiresAuth
877
+ }))
878
+ });
879
+ }
880
+ }
881
+ this.videos = videos;
882
+ this.liveVideos = liveVideos;
883
+ this.featuredPlaylists = playlists;
884
+ this.client.log(`Guide loaded: ${videos.length} videos, ${playlists.length} playlists`);
885
+ } catch (e) {
886
+ this.client.log(`Failed to fetch guide data: ${e}`);
887
+ }
888
+ }
889
+ // =============================================================================
890
+ // --- UTILITIES ---
891
+ // =============================================================================
892
+ s(value) {
893
+ return Math.round(value * this.uiScale);
894
+ }
895
+ handleVideoSelect(video) {
896
+ if (this.config.onVideoSelect) {
897
+ this.config.onVideoSelect(video);
898
+ }
899
+ }
900
+ handleChannelClick(video) {
901
+ const baseUrl = this.getBaseUrl();
902
+ const channelUrl = `${baseUrl}/${video.channelId || video.id}`;
903
+ openExternalUrl({ url: channelUrl });
904
+ }
905
+ renderLeftPanel() {
906
+ const sidebarW = this.s(UI_DIMENSIONS.guide.sidebar.width);
907
+ const liveCount = this.liveVideos.length;
908
+ const signalCount = this.videos.filter((v) => !v.isLive).length;
909
+ const nodesPlaylist = this.featuredPlaylists.find((p) => p.name === "NODES");
910
+ const nodesCount = nodesPlaylist ? nodesPlaylist.videos.filter((v) => v.isCreator).length : 0;
911
+ const displayedPlaylists = this.featuredPlaylists.filter((p) => p.name !== "NODES").slice(0, 6);
912
+ return ReactEcs2.createElement(UiEntity2, {
913
+ uiTransform: {
914
+ width: sidebarW,
915
+ minWidth: sidebarW,
916
+ maxWidth: sidebarW,
917
+ height: "100%",
918
+ flexDirection: "column",
919
+ border: { right: 1 },
920
+ borderColor: THEME.colors.panelBorder,
921
+ justifyContent: "flex-start"
922
+ },
923
+ uiBackground: { color: Color43.create(0, 0, 0, 0.4) },
924
+ children: [
925
+ // Header & Search
926
+ ReactEcs2.createElement(UiEntity2, {
927
+ uiTransform: { width: "100%", flexDirection: "column", padding: 10 },
928
+ children: [
929
+ // Title
930
+ ReactEcs2.createElement(UiEntity2, {
931
+ uiText: {
932
+ value: "THE STATIC TV",
933
+ fontSize: this.s(UI_DIMENSIONS.guide.sidebar.headerSize),
934
+ color: THEME.colors.cyan,
935
+ textAlign: "middle-center"
936
+ },
937
+ uiTransform: { height: this.s(40), marginBottom: 10 }
938
+ }),
939
+ // Random Signal button
940
+ ReactEcs2.createElement(UiEntity2, {
941
+ uiTransform: {
942
+ width: "100%",
943
+ height: this.s(40),
944
+ marginBottom: 10,
945
+ justifyContent: "center",
946
+ alignItems: "center",
947
+ border: { top: 1, bottom: 1, left: 1, right: 1 },
948
+ borderColor: THEME.colors.cyan
949
+ },
950
+ uiBackground: { color: Color43.create(0, 0.2, 0.2, 0.3) },
951
+ onMouseDown: () => {
952
+ if (this.videos.length > 0) {
953
+ const randomVideo = this.videos[Math.floor(Math.random() * this.videos.length)];
954
+ this.handleVideoSelect(randomVideo);
955
+ }
956
+ },
957
+ children: [
958
+ ReactEcs2.createElement(UiEntity2, {
959
+ uiText: { value: ">> RANDOM SIGNAL <<", fontSize: this.s(14), color: THEME.colors.cyan }
960
+ })
961
+ ]
962
+ }),
963
+ // Search input
964
+ ReactEcs2.createElement(Input2, {
965
+ placeholder: "SCAN FREQ...",
966
+ fontSize: this.s(UI_DIMENSIONS.guide.sidebar.fontSize),
967
+ uiTransform: {
968
+ width: "100%",
969
+ height: this.s(40),
970
+ border: { top: 1, bottom: 1, left: 1, right: 1 },
971
+ borderColor: THEME.colors.grayText
972
+ },
973
+ color: THEME.colors.cyan,
974
+ placeholderColor: THEME.colors.grayText,
975
+ uiBackground: { color: Color43.create(0, 0, 0, 1) },
976
+ onChange: (val) => {
977
+ this.searchQuery = val;
978
+ this.currentPage = 0;
979
+ }
980
+ })
981
+ ]
982
+ }),
983
+ // Menu items
984
+ ReactEcs2.createElement(UiEntity2, {
985
+ uiTransform: { flexGrow: 1, flexDirection: "column", width: "100%", padding: { top: 5 } },
986
+ children: [
987
+ // Section header
988
+ ReactEcs2.createElement(UiEntity2, {
989
+ uiTransform: { padding: { left: 10, bottom: 8, top: 5 } },
990
+ uiText: { value: "GLOBAL NETWORK", fontSize: this.s(12), color: THEME.colors.cyan }
991
+ }),
992
+ // Menu buttons
993
+ this.renderMenuButton("LIVE NOW", liveCount, THEME.colors.red, liveCount > 0 ? "\u25CF" : void 0, "ACTIVE STREAMS"),
994
+ this.renderMenuButton("SIGNALS", signalCount, THEME.colors.white, void 0, "ALL VIDEOS"),
995
+ this.renderMenuButton("NODES", nodesCount, THEME.colors.magenta, void 0, "CHANNELS & CREATORS"),
996
+ // Divider
997
+ ReactEcs2.createElement(UiEntity2, {
998
+ uiTransform: { height: 1, width: "90%", margin: { top: 12, bottom: 8, left: 5 } },
999
+ uiBackground: { color: THEME.colors.panelBorder }
1000
+ }),
1001
+ // Archives section
1002
+ ReactEcs2.createElement(UiEntity2, {
1003
+ uiTransform: { padding: { left: 10, bottom: 8 } },
1004
+ uiText: { value: "ARCHIVES", fontSize: this.s(12), color: THEME.colors.cyan }
1005
+ }),
1006
+ // Playlist buttons
1007
+ ...displayedPlaylists.map(
1008
+ (pl) => this.renderMenuButton(pl.name.toUpperCase(), pl.videos.length, THEME.colors.white, void 0, "PLAYLIST")
1009
+ )
1010
+ ]
1011
+ })
1012
+ ]
1013
+ });
1014
+ }
1015
+ renderMenuButton(label, count, color, badge, subtitle) {
1016
+ const isActive = this.activeTab === label;
1017
+ const buttonHeight = this.s(UI_DIMENSIONS.guide.sidebar.buttonHeight);
1018
+ return ReactEcs2.createElement(UiEntity2, {
1019
+ key: label,
1020
+ uiTransform: {
1021
+ width: "100%",
1022
+ height: buttonHeight,
1023
+ marginBottom: 2,
1024
+ flexDirection: "row",
1025
+ alignItems: "center",
1026
+ justifyContent: "space-between",
1027
+ padding: { left: 10, right: 10 },
1028
+ border: { left: 4 },
1029
+ borderColor: isActive ? color : Color43.Clear()
1030
+ },
1031
+ uiBackground: { color: isActive ? Color43.create(color.r, color.g, color.b, 0.15) : Color43.Clear() },
1032
+ onMouseDown: () => {
1033
+ this.activeTab = label;
1034
+ this.currentPage = 0;
1035
+ },
1036
+ children: [
1037
+ ReactEcs2.createElement(UiEntity2, {
1038
+ uiTransform: { flexDirection: "column", alignItems: "flex-start", justifyContent: "center", gap: 0 },
1039
+ children: [
1040
+ ReactEcs2.createElement(UiEntity2, {
1041
+ uiTransform: { height: this.s(18) },
1042
+ uiText: {
1043
+ value: `${label} [${count}]`,
1044
+ fontSize: this.s(UI_DIMENSIONS.guide.sidebar.fontSize),
1045
+ color: isActive ? color : THEME.colors.white,
1046
+ textAlign: "middle-left"
1047
+ }
1048
+ }),
1049
+ ...subtitle ? [
1050
+ ReactEcs2.createElement(UiEntity2, {
1051
+ key: "subtitle",
1052
+ uiTransform: { height: this.s(12), marginTop: -2 },
1053
+ uiText: {
1054
+ value: subtitle,
1055
+ fontSize: this.s(9),
1056
+ color: THEME.colors.grayText,
1057
+ textAlign: "middle-left"
1058
+ }
1059
+ })
1060
+ ] : []
1061
+ ]
1062
+ }),
1063
+ ...badge ? [
1064
+ ReactEcs2.createElement(UiEntity2, {
1065
+ key: "badge",
1066
+ uiText: { value: badge, fontSize: this.s(UI_DIMENSIONS.guide.sidebar.fontSize), color: THEME.colors.red }
1067
+ })
1068
+ ] : []
1069
+ ]
1070
+ });
1071
+ }
1072
+ renderRightPanel() {
1073
+ let videosToShow = [];
1074
+ const nodePl = this.featuredPlaylists.find((p) => p.name === "NODES");
1075
+ if (this.activeTab === "SIGNALS") {
1076
+ videosToShow = this.videos.filter((v) => !v.isLive);
1077
+ } else if (this.activeTab === "NODES") {
1078
+ videosToShow = nodePl ? nodePl.videos.filter((v) => v.isCreator) : [];
1079
+ } else if (this.activeTab === "LIVE NOW") {
1080
+ videosToShow = this.liveVideos;
1081
+ } else {
1082
+ const pl = this.featuredPlaylists.find((p) => p.name.toUpperCase() === this.activeTab);
1083
+ if (pl) videosToShow = pl.videos;
1084
+ }
1085
+ if (this.searchQuery) {
1086
+ videosToShow = videosToShow.filter(
1087
+ (v) => v.name?.toLowerCase().includes(this.searchQuery.toLowerCase())
1088
+ );
1089
+ }
1090
+ const start = this.currentPage * this.itemsPerPage;
1091
+ const paged = videosToShow.slice(start, start + this.itemsPerPage);
1092
+ const totalPages = Math.ceil(videosToShow.length / this.itemsPerPage);
1093
+ return ReactEcs2.createElement(UiEntity2, {
1094
+ uiTransform: {
1095
+ flexGrow: 1,
1096
+ flexDirection: "column",
1097
+ height: "100%",
1098
+ padding: this.s(UI_DIMENSIONS.guide.content.padding)
1099
+ },
1100
+ children: [
1101
+ this.renderCardGrid(paged),
1102
+ // Pagination
1103
+ ...totalPages > 1 ? [
1104
+ ReactEcs2.createElement(UiEntity2, {
1105
+ key: "pagination",
1106
+ uiTransform: {
1107
+ width: "100%",
1108
+ height: this.s(40),
1109
+ flexShrink: 0,
1110
+ flexDirection: "row",
1111
+ justifyContent: "center",
1112
+ alignItems: "center",
1113
+ gap: 15
1114
+ },
1115
+ children: [
1116
+ ...this.currentPage > 0 ? [
1117
+ ReactEcs2.createElement(UiEntity2, {
1118
+ key: "prev",
1119
+ uiText: {
1120
+ value: "< PREV",
1121
+ fontSize: this.s(UI_DIMENSIONS.guide.content.bodySize),
1122
+ color: THEME.colors.cyan
1123
+ },
1124
+ onMouseDown: () => this.currentPage--
1125
+ })
1126
+ ] : [],
1127
+ ReactEcs2.createElement(UiEntity2, {
1128
+ uiText: {
1129
+ value: `${this.currentPage + 1}/${totalPages}`,
1130
+ fontSize: this.s(UI_DIMENSIONS.guide.content.bodySize),
1131
+ color: THEME.colors.grayText
1132
+ }
1133
+ }),
1134
+ ...this.currentPage < totalPages - 1 ? [
1135
+ ReactEcs2.createElement(UiEntity2, {
1136
+ key: "next",
1137
+ uiText: {
1138
+ value: "NEXT >",
1139
+ fontSize: this.s(UI_DIMENSIONS.guide.content.bodySize),
1140
+ color: THEME.colors.cyan
1141
+ },
1142
+ onMouseDown: () => this.currentPage++
1143
+ })
1144
+ ] : []
1145
+ ]
1146
+ })
1147
+ ] : []
1148
+ ]
1149
+ });
1150
+ }
1151
+ renderCardGrid(videos) {
1152
+ if (videos.length === 0) {
1153
+ return ReactEcs2.createElement(UiEntity2, {
1154
+ uiTransform: { width: "100%", flexGrow: 1, justifyContent: "center", alignItems: "center" },
1155
+ uiText: { value: "NO SIGNAL", fontSize: this.s(UI_DIMENSIONS.guide.content.bodySize), color: THEME.colors.gray }
1156
+ });
1157
+ }
1158
+ const rows = [];
1159
+ for (let i = 0; i < videos.length; i += 2) {
1160
+ rows.push(videos.slice(i, i + 2));
1161
+ }
1162
+ const cardH = this.s(UI_DIMENSIONS.guide.content.cardHeight);
1163
+ return ReactEcs2.createElement(UiEntity2, {
1164
+ uiTransform: {
1165
+ width: "100%",
1166
+ flexGrow: 1,
1167
+ flexDirection: "column",
1168
+ alignItems: "flex-start",
1169
+ gap: 8
1170
+ },
1171
+ children: rows.map(
1172
+ (row, rowIndex) => ReactEcs2.createElement(UiEntity2, {
1173
+ key: `row-${rowIndex}`,
1174
+ uiTransform: {
1175
+ width: "100%",
1176
+ height: cardH + 8,
1177
+ flexDirection: "row",
1178
+ gap: 10
1179
+ },
1180
+ children: row.map((v) => this.renderGuideCard(v))
1181
+ })
1182
+ )
1183
+ });
1184
+ }
1185
+ renderGuideCard(video) {
1186
+ const cardW = this.s(UI_DIMENSIONS.guide.content.cardWidth);
1187
+ const cardH = this.s(UI_DIMENSIONS.guide.content.cardHeight);
1188
+ const isActive = this._currentVideoId === video.id;
1189
+ return ReactEcs2.createElement(UiEntity2, {
1190
+ key: video.id,
1191
+ uiTransform: {
1192
+ width: cardW,
1193
+ minWidth: cardW,
1194
+ maxWidth: cardW,
1195
+ height: cardH,
1196
+ minHeight: cardH,
1197
+ flexShrink: 0,
1198
+ flexDirection: "column",
1199
+ margin: 4
1200
+ },
1201
+ uiBackground: { color: isActive ? Color43.create(0, 0.4, 0, 0.5) : Color43.create(0.15, 0.15, 0.15, 0.8) },
1202
+ children: [
1203
+ // Main click area
1204
+ ReactEcs2.createElement(UiEntity2, {
1205
+ uiTransform: {
1206
+ width: "100%",
1207
+ height: "70%",
1208
+ justifyContent: "center",
1209
+ alignItems: "center",
1210
+ flexDirection: "column"
1211
+ },
1212
+ onMouseDown: () => this.handleVideoSelect(video),
1213
+ children: [
1214
+ ReactEcs2.createElement(UiEntity2, {
1215
+ uiText: {
1216
+ value: truncateText(video.name || "Unknown", 22),
1217
+ fontSize: this.s(UI_DIMENSIONS.guide.content.headerSize),
1218
+ color: THEME.colors.white
1219
+ }
1220
+ }),
1221
+ ...isActive ? [
1222
+ ReactEcs2.createElement(UiEntity2, {
1223
+ key: "playing",
1224
+ uiText: {
1225
+ value: "PLAYING",
1226
+ fontSize: this.s(UI_DIMENSIONS.guide.content.bodySize),
1227
+ color: THEME.colors.green
1228
+ }
1229
+ })
1230
+ ] : video.isLive ? [
1231
+ ReactEcs2.createElement(UiEntity2, {
1232
+ key: "live",
1233
+ uiTransform: { padding: { left: 4, right: 4, top: 2, bottom: 2 }, marginTop: 4 },
1234
+ uiBackground: { color: THEME.colors.red },
1235
+ children: [
1236
+ ReactEcs2.createElement(Label2, { value: "LIVE", fontSize: this.s(10), color: THEME.colors.white })
1237
+ ]
1238
+ })
1239
+ ] : []
1240
+ ]
1241
+ }),
1242
+ // Channel link
1243
+ ReactEcs2.createElement(UiEntity2, {
1244
+ uiTransform: {
1245
+ width: "100%",
1246
+ height: "30%",
1247
+ justifyContent: "center",
1248
+ alignItems: "center",
1249
+ padding: 4
1250
+ },
1251
+ uiBackground: { color: Color43.create(0, 0, 0, 0.3) },
1252
+ onMouseDown: () => this.handleChannelClick(video),
1253
+ children: [
1254
+ ReactEcs2.createElement(UiEntity2, {
1255
+ uiText: {
1256
+ value: "CHANNEL",
1257
+ fontSize: this.s(UI_DIMENSIONS.guide.content.bodySize),
1258
+ color: THEME.colors.cyan
1259
+ }
1260
+ })
1261
+ ]
1262
+ })
1263
+ ]
1264
+ });
1265
+ }
1266
+ renderCloseButton() {
1267
+ const closeSize = this.s(UI_DIMENSIONS.closeButton.size);
1268
+ return ReactEcs2.createElement(UiEntity2, {
1269
+ uiTransform: {
1270
+ positionType: "absolute",
1271
+ position: { top: -5, right: -5 },
1272
+ width: closeSize,
1273
+ height: closeSize,
1274
+ justifyContent: "center",
1275
+ alignItems: "center"
1276
+ },
1277
+ uiBackground: { color: THEME.colors.panel },
1278
+ onMouseDown: () => this.hide(),
1279
+ children: [
1280
+ ReactEcs2.createElement(UiEntity2, {
1281
+ uiText: { value: "X", fontSize: this.s(UI_DIMENSIONS.closeButton.fontSize), color: THEME.colors.red }
1282
+ })
1283
+ ]
1284
+ });
1285
+ }
1286
+ };
1287
+
1288
+ // src/ui/chat-ui.tsx
1289
+ import ReactEcs3, { UiEntity as UiEntity3, Label as Label3, Input as Input3, Button as Button2 } from "@dcl/sdk/react-ecs";
1290
+ import { Color4 as Color44 } from "@dcl/sdk/math";
1291
+ import { signedFetch } from "~system/SignedFetch";
1292
+ import { getPlayer } from "@dcl/sdk/players";
1293
+ import { openExternalUrl as openExternalUrl2 } from "~system/RestrictedActions";
1294
+ var FIREBASE_API_KEY = "AIzaSyCX5jViDWUSagUmAX7S4OXgXyJZ9shaC5Y";
1295
+ var POLL_INTERVAL_ACTIVE = 2500;
1296
+ var POLL_INTERVAL_PASSIVE = 2e4;
1297
+ var ChatUIModule = class {
1298
+ constructor(client, config = {}) {
1299
+ // Visibility state
1300
+ this._isVisible = false;
1301
+ this._unreadCount = 0;
1302
+ // Authentication state
1303
+ this.idToken = null;
1304
+ this.isSigningIn = false;
1305
+ this.isRegisteredUser = false;
1306
+ this.currentUsername = "Guest";
1307
+ this.currentUserId = "";
1308
+ // Channel state
1309
+ this.channels = [{ id: "global-community", name: "GLOBAL COMMUNITY" }];
1310
+ this.currentChannelId = "global-community";
1311
+ this.showChannelDropdown = false;
1312
+ this.channelPage = 0;
1313
+ // Message state
1314
+ this.realMessages = [];
1315
+ this.systemMessages = [];
1316
+ this.inputText = "";
1317
+ this.chatScrollOffset = 0;
1318
+ // UI preferences
1319
+ this.position = "right";
1320
+ this.fontScale = 1;
1321
+ // Timers
1322
+ this.chatTimerId = null;
1323
+ this.playerInfoTimerId = null;
1324
+ this.isInitialized = false;
1325
+ // =============================================================================
1326
+ // --- UI RENDERING ---
1327
+ // =============================================================================
1328
+ /**
1329
+ * Get the chat UI component for rendering
1330
+ */
1331
+ this.getComponent = () => {
1332
+ if (!this._isVisible) return null;
1333
+ const scaledTheme = scaleChatTheme(DEFAULT_CHAT_THEME, this.fontScale);
1334
+ const positionStyle = this.getPositionStyle();
1335
+ return ReactEcs3.createElement(UiEntity3, {
1336
+ uiTransform: {
1337
+ width: UI_DIMENSIONS.chat.width,
1338
+ height: UI_DIMENSIONS.chat.height,
1339
+ positionType: "absolute",
1340
+ ...positionStyle,
1341
+ flexDirection: "column",
1342
+ border: { top: 2, bottom: 2, left: 2, right: 2 },
1343
+ borderColor: THEME.colors.panelBorder
1344
+ },
1345
+ uiBackground: { color: THEME.colors.panel },
1346
+ children: [
1347
+ this.renderHeader(),
1348
+ this.renderChannelButton(scaledTheme),
1349
+ this.renderMessagesArea(scaledTheme),
1350
+ ReactEcs3.createElement(UiEntity3, {
1351
+ uiTransform: { width: "100%", height: 1, flexShrink: 0 },
1352
+ uiBackground: { color: THEME.colors.panelBorder }
1353
+ }),
1354
+ this.renderUserInfoBar(scaledTheme),
1355
+ this.renderInputArea(scaledTheme),
1356
+ this.renderChannelDropdown(scaledTheme)
1357
+ ]
1358
+ });
1359
+ };
1360
+ this.client = client;
1361
+ this.config = config;
1362
+ this.position = config.position || "right";
1363
+ this.fontScale = config.fontScale || 1;
1364
+ }
1365
+ /**
1366
+ * Initialize the chat system
1367
+ */
1368
+ async init() {
1369
+ if (this.isInitialized) return;
1370
+ this.isInitialized = true;
1371
+ this.generateBootSequence();
1372
+ this.fetchPlayerInfo();
1373
+ await this.fetchChannels();
1374
+ this.performAuth();
1375
+ this.startChatLoop();
1376
+ this.client.log("ChatUI initialized");
1377
+ }
1378
+ /**
1379
+ * Show the chat UI
1380
+ */
1381
+ show() {
1382
+ this._isVisible = true;
1383
+ this._unreadCount = 0;
1384
+ this.chatScrollOffset = 0;
1385
+ this.resetChatTimer();
1386
+ }
1387
+ /**
1388
+ * Hide the chat UI
1389
+ */
1390
+ hide() {
1391
+ this._isVisible = false;
1392
+ }
1393
+ /**
1394
+ * Toggle chat visibility
1395
+ */
1396
+ toggle() {
1397
+ if (this._isVisible) {
1398
+ this.hide();
1399
+ } else {
1400
+ this.show();
1401
+ }
1402
+ }
1403
+ /**
1404
+ * Check if chat is visible
1405
+ */
1406
+ get isVisible() {
1407
+ return this._isVisible;
1408
+ }
1409
+ /**
1410
+ * Get unread message count
1411
+ */
1412
+ get unreadCount() {
1413
+ return this._unreadCount;
1414
+ }
1415
+ /**
1416
+ * Get current channel ID
1417
+ */
1418
+ get currentChannel() {
1419
+ return this.currentChannelId;
1420
+ }
1421
+ /**
1422
+ * Switch to a different channel
1423
+ */
1424
+ setChannel(channelId) {
1425
+ if (this.currentChannelId !== channelId) {
1426
+ this.switchChannel(channelId);
1427
+ }
1428
+ }
1429
+ /**
1430
+ * Cleanup resources
1431
+ */
1432
+ destroy() {
1433
+ if (this.chatTimerId !== null) {
1434
+ dclClearTimeout(this.chatTimerId);
1435
+ this.chatTimerId = null;
1436
+ }
1437
+ if (this.playerInfoTimerId !== null) {
1438
+ dclClearTimeout(this.playerInfoTimerId);
1439
+ this.playerInfoTimerId = null;
1440
+ }
1441
+ }
1442
+ // =============================================================================
1443
+ // --- INTERNAL API LOGIC ---
1444
+ // =============================================================================
1445
+ getBaseUrl() {
1446
+ const config = this.client.getConfig();
1447
+ const baseUrl = config.baseUrl || "https://thestatic.tv/api/v1/dcl";
1448
+ return baseUrl.replace("/api/v1/dcl", "");
1449
+ }
1450
+ async performAuth() {
1451
+ if (this.isSigningIn || this.idToken) return;
1452
+ this.isSigningIn = true;
1453
+ try {
1454
+ const baseUrl = this.getBaseUrl();
1455
+ const response = await signedFetch({
1456
+ url: `${baseUrl}/api/auth/dcl`,
1457
+ init: {
1458
+ method: "POST",
1459
+ headers: { "Content-Type": "application/json" },
1460
+ body: JSON.stringify({})
1461
+ }
1462
+ });
1463
+ if (!response.ok) throw new Error("Backend refused login");
1464
+ const authData = JSON.parse(response.body);
1465
+ if (authData.username) this.currentUsername = authData.username;
1466
+ this.isRegisteredUser = !!authData.isRegistered;
1467
+ const exchangeRes = await fetch(
1468
+ `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`,
1469
+ {
1470
+ method: "POST",
1471
+ headers: { "Content-Type": "application/json" },
1472
+ body: JSON.stringify({ token: authData.token, returnSecureToken: true })
1473
+ }
1474
+ );
1475
+ const exchangeData = await exchangeRes.json();
1476
+ if (exchangeData.idToken) {
1477
+ this.idToken = exchangeData.idToken;
1478
+ if (this.inputText && this.isRegisteredUser) {
1479
+ this.handleSend();
1480
+ }
1481
+ }
1482
+ this.client.log("Chat auth successful");
1483
+ } catch (e) {
1484
+ this.client.log(`Chat auth failed: ${e}`);
1485
+ } finally {
1486
+ this.isSigningIn = false;
1487
+ }
1488
+ }
1489
+ async fetchChannels() {
1490
+ try {
1491
+ const baseChannels = [{ id: "global-community", name: "GLOBAL COMMUNITY" }];
1492
+ const baseUrl = this.getBaseUrl();
1493
+ const response = await fetch(`${baseUrl}/api/v1/guide`);
1494
+ if (response.ok) {
1495
+ const guideData = await response.json();
1496
+ if (guideData.videoMap) {
1497
+ const creatorChannels = [];
1498
+ for (const [slug, video] of Object.entries(guideData.videoMap)) {
1499
+ const v = video;
1500
+ if ((v.type === "live" || v.isCreator === true) && slug !== "global-community") {
1501
+ creatorChannels.push({
1502
+ id: slug,
1503
+ name: (v.name || v.creator_name || slug).toUpperCase()
1504
+ });
1505
+ }
1506
+ }
1507
+ creatorChannels.sort((a, b) => a.name.localeCompare(b.name));
1508
+ this.channels = [...baseChannels, ...creatorChannels];
1509
+ }
1510
+ }
1511
+ } catch (e) {
1512
+ this.client.log(`Failed to fetch channels: ${e}`);
1513
+ }
1514
+ }
1515
+ async fetchMessages() {
1516
+ try {
1517
+ const baseUrl = this.getBaseUrl();
1518
+ const response = await fetch(`${baseUrl}/api/chat/history?channelId=${this.currentChannelId}`);
1519
+ const data = await response.json();
1520
+ if (data.messages) {
1521
+ const twentyFourHoursAgo = Date.now() - 24 * 60 * 60 * 1e3;
1522
+ const recentMsgs = data.messages.filter(
1523
+ (m) => this.parseServerTime(m.createdAt) > twentyFourHoursAgo
1524
+ );
1525
+ const sortedMsgs = [...recentMsgs].sort(
1526
+ (a, b) => this.parseServerTime(a.createdAt) - this.parseServerTime(b.createdAt)
1527
+ );
1528
+ const lastOldMsg = this.realMessages[this.realMessages.length - 1];
1529
+ if (!this._isVisible && sortedMsgs.length > 0) {
1530
+ if (lastOldMsg) {
1531
+ const lastOldTime = this.parseServerTime(lastOldMsg.createdAt);
1532
+ const newMsgsCount = sortedMsgs.filter(
1533
+ (m) => this.parseServerTime(m.createdAt) > lastOldTime
1534
+ ).length;
1535
+ this._unreadCount += newMsgsCount;
1536
+ } else {
1537
+ this._unreadCount = sortedMsgs.length;
1538
+ }
1539
+ }
1540
+ const lastNewMsg = sortedMsgs[sortedMsgs.length - 1];
1541
+ if (lastNewMsg && lastOldMsg && lastNewMsg.id !== lastOldMsg.id) {
1542
+ this.chatScrollOffset = 0;
1543
+ }
1544
+ this.realMessages = sortedMsgs;
1545
+ }
1546
+ } catch (e) {
1547
+ }
1548
+ }
1549
+ async handleSend() {
1550
+ if (!this.inputText.trim()) return;
1551
+ if (!this.idToken) {
1552
+ await this.performAuth();
1553
+ return;
1554
+ }
1555
+ if (!this.isRegisteredUser) return;
1556
+ const message = this.inputText.trim();
1557
+ this.inputText = "";
1558
+ try {
1559
+ const baseUrl = this.getBaseUrl();
1560
+ const response = await signedFetch({
1561
+ url: `${baseUrl}/api/chat/send`,
1562
+ init: {
1563
+ method: "POST",
1564
+ headers: {
1565
+ "Content-Type": "application/json",
1566
+ Authorization: `Bearer ${this.idToken}`
1567
+ },
1568
+ body: JSON.stringify({ channelId: this.currentChannelId, text: message })
1569
+ }
1570
+ });
1571
+ if (response.ok) {
1572
+ await this.fetchMessages();
1573
+ this.resetChatTimer();
1574
+ } else if (response.status === 401) {
1575
+ this.idToken = null;
1576
+ this.performAuth();
1577
+ }
1578
+ } catch (e) {
1579
+ this.client.log(`Failed to send message: ${e}`);
1580
+ }
1581
+ }
1582
+ generateBootSequence() {
1583
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1584
+ this.systemMessages = [
1585
+ { id: "sys-1", username: "SYSTEM", text: "ESTABLISHING SECURE CONNECTION...", uid: "system", createdAt: now, isSystem: true },
1586
+ { id: "sys-2", username: "SYSTEM", text: "ENCRYPTION KEYS EXCHANGED.", uid: "system", createdAt: now, isSystem: true },
1587
+ { id: "sys-3", username: "SYSTEM", text: `CONNECTED TO: ${this.currentChannelId.toUpperCase()}`, uid: "system", createdAt: now, isSystem: true }
1588
+ ];
1589
+ }
1590
+ async fetchPlayerInfo() {
1591
+ if (this.currentUserId) return;
1592
+ try {
1593
+ const player = await getPlayer();
1594
+ if (player && player.userId) {
1595
+ this.currentUserId = player.userId;
1596
+ if (this.currentUsername === "Guest") {
1597
+ this.currentUsername = player.name;
1598
+ }
1599
+ }
1600
+ } catch (e) {
1601
+ }
1602
+ }
1603
+ switchChannel(channelId) {
1604
+ this.currentChannelId = channelId;
1605
+ this.realMessages = [];
1606
+ this.showChannelDropdown = false;
1607
+ this.chatScrollOffset = 0;
1608
+ this.channelPage = 0;
1609
+ this.generateBootSequence();
1610
+ this.resetChatTimer();
1611
+ }
1612
+ startChatLoop() {
1613
+ this.chatLoop();
1614
+ }
1615
+ chatLoop() {
1616
+ const startTime = Date.now();
1617
+ this.fetchMessages().finally(() => {
1618
+ const duration = Date.now() - startTime;
1619
+ const targetInterval = this._isVisible ? POLL_INTERVAL_ACTIVE : POLL_INTERVAL_PASSIVE;
1620
+ const nextDelay = Math.max(0, targetInterval - duration);
1621
+ if (this.chatTimerId !== null) {
1622
+ dclClearTimeout(this.chatTimerId);
1623
+ }
1624
+ this.chatTimerId = dclSetTimeout(() => this.chatLoop(), nextDelay);
1625
+ });
1626
+ }
1627
+ resetChatTimer() {
1628
+ if (this.chatTimerId !== null) {
1629
+ dclClearTimeout(this.chatTimerId);
1630
+ }
1631
+ this.chatLoop();
1632
+ }
1633
+ // =============================================================================
1634
+ // --- UTILITIES ---
1635
+ // =============================================================================
1636
+ parseServerTime(input) {
1637
+ if (!input) return 0;
1638
+ if (typeof input === "number") return input;
1639
+ if (typeof input === "string") {
1640
+ const d = new Date(input);
1641
+ if (!isNaN(d.getTime())) return d.getTime();
1642
+ }
1643
+ return 0;
1644
+ }
1645
+ formatTime(isoString) {
1646
+ const ms = this.parseServerTime(isoString);
1647
+ if (ms === 0) return "";
1648
+ try {
1649
+ const date = new Date(ms);
1650
+ return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
1651
+ } catch (e) {
1652
+ return "";
1653
+ }
1654
+ }
1655
+ getPositionStyle() {
1656
+ const base = { bottom: UI_DIMENSIONS.chat.bottom };
1657
+ switch (this.position) {
1658
+ case "left":
1659
+ return { ...base, left: 20 };
1660
+ case "center":
1661
+ return { ...base, left: 400 };
1662
+ // Approximate center
1663
+ case "right":
1664
+ default:
1665
+ return { ...base, right: UI_DIMENSIONS.chat.right };
1666
+ }
1667
+ }
1668
+ renderHeader() {
1669
+ return PanelHeader({
1670
+ title: "LIVE CHAT",
1671
+ fontSize: 14,
1672
+ fontScale: this.fontScale,
1673
+ showPositionControls: true,
1674
+ position: this.position,
1675
+ onPositionChange: (pos) => {
1676
+ this.position = pos;
1677
+ },
1678
+ showFontControls: true,
1679
+ onFontScaleUp: () => {
1680
+ this.fontScale = Math.min(1.4, this.fontScale + 0.1);
1681
+ },
1682
+ onFontScaleDown: () => {
1683
+ this.fontScale = Math.max(0.7, this.fontScale - 0.1);
1684
+ },
1685
+ onClose: () => this.hide()
1686
+ });
1687
+ }
1688
+ renderChannelButton(theme) {
1689
+ const currentChannel = this.channels.find((c) => c.id === this.currentChannelId);
1690
+ const displayName = currentChannel?.name || this.currentChannelId;
1691
+ return ReactEcs3.createElement(UiEntity3, {
1692
+ uiTransform: {
1693
+ height: 40,
1694
+ width: "100%",
1695
+ padding: { left: 15, right: 15 },
1696
+ flexDirection: "row",
1697
+ alignItems: "center",
1698
+ justifyContent: "space-between",
1699
+ flexShrink: 0
1700
+ },
1701
+ uiBackground: { color: THEME.colors.inputBackground },
1702
+ onMouseDown: () => {
1703
+ this.showChannelDropdown = !this.showChannelDropdown;
1704
+ },
1705
+ children: [
1706
+ ReactEcs3.createElement(Label3, {
1707
+ value: `// ${truncateText(displayName, 24)}`,
1708
+ fontSize: theme.channelButton,
1709
+ color: THEME.colors.white
1710
+ }),
1711
+ ReactEcs3.createElement(Label3, {
1712
+ value: this.showChannelDropdown ? "\u25B2" : "\u25BC",
1713
+ fontSize: 10,
1714
+ color: THEME.colors.cyan
1715
+ })
1716
+ ]
1717
+ });
1718
+ }
1719
+ renderMessagesArea(theme) {
1720
+ const allMessages = [...this.systemMessages, ...this.realMessages];
1721
+ const totalMessages = allMessages.length;
1722
+ const maxOffset = Math.max(0, totalMessages - UI_DIMENSIONS.chat.messagesPerPage);
1723
+ if (this.chatScrollOffset > maxOffset) this.chatScrollOffset = maxOffset;
1724
+ if (this.chatScrollOffset < 0) this.chatScrollOffset = 0;
1725
+ const startIdx = Math.max(0, totalMessages - UI_DIMENSIONS.chat.messagesPerPage - this.chatScrollOffset);
1726
+ const endIdx = totalMessages - this.chatScrollOffset;
1727
+ const visibleMessages = allMessages.slice(startIdx, endIdx);
1728
+ const canScrollUp = this.chatScrollOffset < maxOffset;
1729
+ const canScrollDown = this.chatScrollOffset > 0;
1730
+ return ReactEcs3.createElement(UiEntity3, {
1731
+ uiTransform: { flexGrow: 1, width: "100%", flexDirection: "row", overflow: "hidden" },
1732
+ children: [
1733
+ // Messages
1734
+ ReactEcs3.createElement(UiEntity3, {
1735
+ uiTransform: {
1736
+ flexGrow: 1,
1737
+ flexDirection: "column",
1738
+ padding: { left: 12, right: 8, top: 8, bottom: 8 },
1739
+ overflow: "hidden"
1740
+ },
1741
+ children: visibleMessages.map(
1742
+ (msg) => msg.isSystem ? this.renderSystemMessage(msg, theme) : this.renderChatMessage(msg, theme)
1743
+ )
1744
+ }),
1745
+ // Scroll controls
1746
+ ReactEcs3.createElement(UiEntity3, {
1747
+ uiTransform: {
1748
+ width: 28,
1749
+ height: "100%",
1750
+ flexDirection: "column",
1751
+ justifyContent: "center",
1752
+ alignItems: "center",
1753
+ gap: 8
1754
+ },
1755
+ children: [
1756
+ ReactEcs3.createElement(UiEntity3, {
1757
+ uiTransform: { width: 26, height: 36, justifyContent: "center", alignItems: "center" },
1758
+ uiBackground: { color: canScrollUp ? THEME.colors.buttonBackground : Color44.create(0.1, 0.1, 0.1, 0.3) },
1759
+ onMouseDown: () => {
1760
+ if (canScrollUp) this.chatScrollOffset += 1;
1761
+ },
1762
+ children: [
1763
+ ReactEcs3.createElement(UiEntity3, {
1764
+ uiText: { value: "\u25B2", fontSize: 14, color: canScrollUp ? THEME.colors.cyan : THEME.colors.gray }
1765
+ })
1766
+ ]
1767
+ }),
1768
+ ReactEcs3.createElement(UiEntity3, {
1769
+ uiTransform: { width: 26, height: 24, justifyContent: "center", alignItems: "center" },
1770
+ children: [
1771
+ ReactEcs3.createElement(UiEntity3, {
1772
+ uiText: {
1773
+ value: totalMessages > UI_DIMENSIONS.chat.messagesPerPage ? `${this.chatScrollOffset}` : "-",
1774
+ fontSize: 10,
1775
+ color: THEME.colors.gray
1776
+ }
1777
+ })
1778
+ ]
1779
+ }),
1780
+ ReactEcs3.createElement(UiEntity3, {
1781
+ uiTransform: { width: 26, height: 36, justifyContent: "center", alignItems: "center" },
1782
+ uiBackground: { color: canScrollDown ? THEME.colors.buttonBackground : Color44.create(0.1, 0.1, 0.1, 0.3) },
1783
+ onMouseDown: () => {
1784
+ if (canScrollDown) this.chatScrollOffset -= 1;
1785
+ },
1786
+ children: [
1787
+ ReactEcs3.createElement(UiEntity3, {
1788
+ uiText: { value: "\u25BC", fontSize: 14, color: canScrollDown ? THEME.colors.cyan : THEME.colors.gray }
1789
+ })
1790
+ ]
1791
+ })
1792
+ ]
1793
+ })
1794
+ ]
1795
+ });
1796
+ }
1797
+ renderSystemMessage(msg, theme) {
1798
+ return ReactEcs3.createElement(UiEntity3, {
1799
+ key: msg.id,
1800
+ uiTransform: { width: "100%", height: 24, marginBottom: 4 },
1801
+ uiText: { value: msg.text, fontSize: theme.systemMessage, color: THEME.colors.green, textAlign: "middle-left" }
1802
+ });
1803
+ }
1804
+ renderChatMessage(msg, theme) {
1805
+ const time = this.formatTime(msg.createdAt);
1806
+ let displayName = msg.username || "Anonymous";
1807
+ const isCurrentUser = this.currentUserId && msg.uid === this.currentUserId || msg.username === this.currentUserId;
1808
+ if (isCurrentUser) displayName = this.currentUsername;
1809
+ let accentColor = THEME.colors.cyan;
1810
+ if (displayName.toLowerCase().includes("admin")) accentColor = THEME.colors.magenta;
1811
+ else if (isCurrentUser) accentColor = THEME.colors.green;
1812
+ return ReactEcs3.createElement(UiEntity3, {
1813
+ key: msg.id,
1814
+ uiTransform: { width: "100%", flexDirection: "column", marginBottom: 16 },
1815
+ children: [
1816
+ // Header row
1817
+ ReactEcs3.createElement(UiEntity3, {
1818
+ uiTransform: {
1819
+ width: "100%",
1820
+ height: 24,
1821
+ flexDirection: "row",
1822
+ justifyContent: "space-between",
1823
+ alignItems: "center",
1824
+ marginBottom: 4
1825
+ },
1826
+ children: [
1827
+ ReactEcs3.createElement(UiEntity3, {
1828
+ uiText: { value: displayName, fontSize: theme.chatUsername, color: accentColor, textAlign: "middle-left" }
1829
+ }),
1830
+ ReactEcs3.createElement(UiEntity3, {
1831
+ uiText: { value: time, fontSize: theme.chatTimestamp, color: THEME.colors.gray, textAlign: "middle-right" }
1832
+ })
1833
+ ]
1834
+ }),
1835
+ // Message content
1836
+ ReactEcs3.createElement(UiEntity3, {
1837
+ uiTransform: { width: "100%", flexDirection: "row", alignItems: "stretch", paddingLeft: 8 },
1838
+ children: [
1839
+ ReactEcs3.createElement(UiEntity3, {
1840
+ uiTransform: { width: 3, minHeight: 28 },
1841
+ uiBackground: { color: accentColor }
1842
+ }),
1843
+ ReactEcs3.createElement(UiEntity3, {
1844
+ uiTransform: { flexGrow: 1, paddingLeft: 12, paddingTop: 4, paddingBottom: 4 },
1845
+ uiText: {
1846
+ value: msg.text,
1847
+ fontSize: theme.chatMessage,
1848
+ color: THEME.colors.white,
1849
+ textWrap: "wrap",
1850
+ textAlign: "middle-left"
1851
+ }
1852
+ })
1853
+ ]
1854
+ })
1855
+ ]
1856
+ });
1857
+ }
1858
+ renderUserInfoBar(theme) {
1859
+ return ReactEcs3.createElement(UiEntity3, {
1860
+ uiTransform: {
1861
+ width: "100%",
1862
+ height: 34,
1863
+ flexShrink: 0,
1864
+ padding: { left: 15, right: 15 },
1865
+ flexDirection: "row",
1866
+ justifyContent: "space-between",
1867
+ alignItems: "center"
1868
+ },
1869
+ uiBackground: { color: Color44.create(0.03, 0.03, 0.05, 1) },
1870
+ children: [
1871
+ ReactEcs3.createElement(Label3, {
1872
+ value: `// ${this.currentUsername || "Guest"}`,
1873
+ fontSize: theme.userInfo,
1874
+ color: THEME.colors.yellow
1875
+ }),
1876
+ ReactEcs3.createElement(Label3, {
1877
+ value: this.isRegisteredUser ? "\u25CF CITIZEN" : "\u25CB GUEST",
1878
+ fontSize: theme.authStatus,
1879
+ color: this.isRegisteredUser ? THEME.colors.green : THEME.colors.gray
1880
+ })
1881
+ ]
1882
+ });
1883
+ }
1884
+ renderInputArea(theme) {
1885
+ const baseUrl = this.getBaseUrl();
1886
+ if (!this.isRegisteredUser) {
1887
+ return ReactEcs3.createElement(UiEntity3, {
1888
+ uiTransform: {
1889
+ height: 75,
1890
+ minHeight: 75,
1891
+ flexShrink: 0,
1892
+ flexDirection: "column",
1893
+ justifyContent: "center",
1894
+ alignItems: "center",
1895
+ padding: 5,
1896
+ borderColor: THEME.colors.panelBorder,
1897
+ border: { top: 1 }
1898
+ },
1899
+ uiBackground: { color: Color44.create(0.08, 0.08, 0.1, 1) },
1900
+ children: [
1901
+ ReactEcs3.createElement(Label3, {
1902
+ value: "ACCESS RESTRICTED: CITIZENS ONLY",
1903
+ fontSize: 12,
1904
+ color: THEME.colors.red,
1905
+ textAlign: "middle-center",
1906
+ uiTransform: { marginBottom: 5 }
1907
+ }),
1908
+ ReactEcs3.createElement(Button2, {
1909
+ value: "REGISTER IDENTITY",
1910
+ fontSize: 14,
1911
+ uiTransform: { width: 200, height: 30 },
1912
+ uiBackground: { color: THEME.colors.cyan },
1913
+ color: Color44.Black(),
1914
+ onMouseDown: () => {
1915
+ openExternalUrl2({ url: `${baseUrl}/apply` });
1916
+ }
1917
+ })
1918
+ ]
1919
+ });
1920
+ }
1921
+ return ReactEcs3.createElement(UiEntity3, {
1922
+ uiTransform: {
1923
+ height: 75,
1924
+ minHeight: 75,
1925
+ flexShrink: 0,
1926
+ flexDirection: "row",
1927
+ padding: 15,
1928
+ borderColor: THEME.colors.panelBorder,
1929
+ border: { top: 1 }
1930
+ },
1931
+ uiBackground: { color: Color44.create(0.08, 0.08, 0.1, 1) },
1932
+ children: [
1933
+ ReactEcs3.createElement(Input3, {
1934
+ onChange: (v) => {
1935
+ this.inputText = v;
1936
+ },
1937
+ onSubmit: () => this.handleSend(),
1938
+ value: this.inputText,
1939
+ placeholder: "Transmit message...",
1940
+ fontSize: theme.input,
1941
+ placeholderColor: THEME.colors.grayText,
1942
+ color: THEME.colors.white,
1943
+ uiBackground: { color: Color44.create(0.12, 0.12, 0.14, 1) },
1944
+ uiTransform: { flexGrow: 1, height: "100%", margin: { right: 10 } }
1945
+ }),
1946
+ ReactEcs3.createElement(Button2, {
1947
+ value: this.isSigningIn ? "..." : "SEND",
1948
+ onMouseDown: () => this.handleSend(),
1949
+ uiTransform: { width: 80, height: "100%" },
1950
+ fontSize: theme.sendButton,
1951
+ uiBackground: { color: THEME.colors.cyan },
1952
+ color: Color44.Black()
1953
+ })
1954
+ ]
1955
+ });
1956
+ }
1957
+ renderChannelDropdown(theme) {
1958
+ if (!this.showChannelDropdown) return null;
1959
+ const startIdx = this.channelPage * UI_DIMENSIONS.chat.channelsPerPage;
1960
+ const endIdx = startIdx + UI_DIMENSIONS.chat.channelsPerPage;
1961
+ const visibleChannels = this.channels.slice(startIdx, endIdx);
1962
+ const totalPages = Math.ceil(this.channels.length / UI_DIMENSIONS.chat.channelsPerPage);
1963
+ return ReactEcs3.createElement(UiEntity3, {
1964
+ uiTransform: {
1965
+ width: "100%",
1966
+ height: 260,
1967
+ flexDirection: "column",
1968
+ positionType: "absolute",
1969
+ position: { top: 95, left: 0 }
1970
+ },
1971
+ uiBackground: { color: Color44.create(0.08, 0.1, 0.14, 1) },
1972
+ children: [
1973
+ // Top border
1974
+ ReactEcs3.createElement(UiEntity3, {
1975
+ uiTransform: { width: "100%", height: 2, flexShrink: 0 },
1976
+ uiBackground: { color: THEME.colors.cyan }
1977
+ }),
1978
+ // Channel list
1979
+ ...visibleChannels.map(
1980
+ (channel) => ReactEcs3.createElement(UiEntity3, {
1981
+ key: channel.id,
1982
+ uiTransform: {
1983
+ height: 38,
1984
+ width: "100%",
1985
+ padding: { left: 15, right: 15 },
1986
+ alignItems: "center"
1987
+ },
1988
+ uiBackground: {
1989
+ color: channel.id === this.currentChannelId ? Color44.create(0, 0.8, 0.8, 0.25) : Color44.create(0.08, 0.1, 0.14, 1)
1990
+ },
1991
+ onMouseDown: () => this.switchChannel(channel.id),
1992
+ children: [
1993
+ ReactEcs3.createElement(Label3, {
1994
+ value: channel.id === this.currentChannelId ? `\u25B8 ${channel.name}` : channel.name,
1995
+ fontSize: theme.channelDropdown,
1996
+ color: channel.id === this.currentChannelId ? THEME.colors.cyan : THEME.colors.gray
1997
+ })
1998
+ ]
1999
+ })
2000
+ ),
2001
+ // Pagination
2002
+ ...totalPages > 1 ? [
2003
+ ReactEcs3.createElement(UiEntity3, {
2004
+ key: "pagination",
2005
+ uiTransform: {
2006
+ width: "100%",
2007
+ height: 30,
2008
+ flexDirection: "row",
2009
+ justifyContent: "center",
2010
+ alignItems: "center",
2011
+ gap: 10,
2012
+ marginTop: 5
2013
+ },
2014
+ children: [
2015
+ ...this.channelPage > 0 ? [
2016
+ ReactEcs3.createElement(UiEntity3, {
2017
+ key: "prev",
2018
+ onMouseDown: () => this.channelPage--,
2019
+ children: [
2020
+ ReactEcs3.createElement(Label3, {
2021
+ value: "<< PREV",
2022
+ fontSize: 10,
2023
+ color: THEME.colors.cyan
2024
+ })
2025
+ ]
2026
+ })
2027
+ ] : [],
2028
+ ReactEcs3.createElement(Label3, {
2029
+ value: `${this.channelPage + 1}/${totalPages}`,
2030
+ fontSize: 10,
2031
+ color: THEME.colors.gray
2032
+ }),
2033
+ ...this.channelPage < totalPages - 1 ? [
2034
+ ReactEcs3.createElement(UiEntity3, {
2035
+ key: "next",
2036
+ onMouseDown: () => this.channelPage++,
2037
+ children: [
2038
+ ReactEcs3.createElement(Label3, {
2039
+ value: "NEXT >>",
2040
+ fontSize: 10,
2041
+ color: THEME.colors.cyan
2042
+ })
2043
+ ]
2044
+ })
2045
+ ] : []
2046
+ ]
2047
+ })
2048
+ ] : [],
2049
+ // Bottom border
2050
+ ReactEcs3.createElement(UiEntity3, {
2051
+ uiTransform: { width: "100%", height: 1, flexShrink: 0 },
2052
+ uiBackground: { color: THEME.colors.panelBorder }
2053
+ })
2054
+ ]
2055
+ });
2056
+ }
2057
+ };
2058
+
473
2059
  // src/StaticTVClient.ts
474
2060
  var DEFAULT_BASE_URL = "https://thestatic.tv/api/v1/dcl";
475
2061
  var KEY_TYPE_CHANNEL = "channel";
@@ -518,10 +2104,14 @@ var StaticTVClient = class {
518
2104
  this.guide = new GuideModule(this);
519
2105
  this.heartbeat = new HeartbeatModule(this);
520
2106
  this.interactions = new InteractionsModule(this);
2107
+ this.guideUI = new GuideUIModule(this, config.guideUI);
2108
+ this.chatUI = new ChatUIModule(this, config.chatUI);
521
2109
  } else {
522
2110
  this.guide = null;
523
2111
  this.heartbeat = null;
524
2112
  this.interactions = null;
2113
+ this.guideUI = null;
2114
+ this.chatUI = null;
525
2115
  }
526
2116
  if (this.config.autoStartSession) {
527
2117
  fetchUserData().then(() => {
@@ -587,12 +2177,17 @@ var StaticTVClient = class {
587
2177
  if (this.heartbeat) {
588
2178
  this.heartbeat.stopWatching();
589
2179
  }
2180
+ if (this.chatUI) {
2181
+ this.chatUI.destroy();
2182
+ }
590
2183
  await this.session.endSession();
591
2184
  this.log("StaticTVClient destroyed");
592
2185
  }
593
2186
  };
594
2187
  export {
2188
+ ChatUIModule,
595
2189
  GuideModule,
2190
+ GuideUIModule,
596
2191
  HeartbeatModule,
597
2192
  InteractionsModule,
598
2193
  KEY_TYPE_CHANNEL,