@mideind/netskrafl-react 1.6.1 → 1.7.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.
@@ -1898,7 +1898,7 @@ div.netskrafl-tile.dragging div.letterscore {
1898
1898
  opacity: 1;
1899
1899
  }
1900
1900
 
1901
- .netskrafl-container div.right-tab.alert > span.glyphicon {
1901
+ .netskrafl-container div.right-tab.chat-alert > span.glyphicon {
1902
1902
  color: var(--cancel-button);
1903
1903
  animation: redBlink 1s infinite;
1904
1904
  -webkit-animation: redBlink 1s infinite;
@@ -5325,7 +5325,7 @@ div.highlight1.netskrafl-blanktile {
5325
5325
  }
5326
5326
 
5327
5327
  .netskrafl-container div#user-unfriend {
5328
- left: 180px;
5328
+ left: 166px;
5329
5329
  width: 280px;
5330
5330
  /* Override */
5331
5331
  border-style: solid;
package/dist/cjs/index.js CHANGED
@@ -3,8 +3,63 @@
3
3
  var jsxRuntime = require('react/jsx-runtime');
4
4
  var React = require('react');
5
5
 
6
+ const DEFAULT_STATE = {
7
+ projectId: "netskrafl",
8
+ firebaseApiKey: "",
9
+ databaseUrl: "",
10
+ firebaseSenderId: "",
11
+ firebaseAppId: "",
12
+ measurementId: "",
13
+ account: "",
14
+ userEmail: "",
15
+ userId: "",
16
+ userNick: "",
17
+ userFullname: "",
18
+ locale: "is_IS",
19
+ isExplo: false,
20
+ serverUrl: "",
21
+ movesUrl: "",
22
+ movesAccessKey: "",
23
+ token: "",
24
+ loginMethod: "",
25
+ subscriptionUrl: "",
26
+ newUser: false,
27
+ beginner: true,
28
+ fairPlay: false,
29
+ plan: "", // Not a friend
30
+ hasPaid: false,
31
+ ready: true,
32
+ readyTimed: true,
33
+ uiFullscreen: true,
34
+ uiLandscape: false,
35
+ runningLocal: false,
36
+ };
37
+
6
38
  // Key for storing auth settings in sessionStorage
7
39
  const AUTH_SETTINGS_KEY = "netskrafl_auth_settings";
40
+ const makeServerUrls = (backendUrl, movesUrl) => {
41
+ // If the last character of the url is a slash, cut it off,
42
+ // since path URLs always start with a slash
43
+ const cleanupUrl = (url) => {
44
+ if (url.length > 0 && url[url.length - 1] === "/") {
45
+ url = url.slice(0, -1);
46
+ }
47
+ return url;
48
+ };
49
+ return {
50
+ serverUrl: cleanupUrl(backendUrl),
51
+ movesUrl: cleanupUrl(movesUrl),
52
+ };
53
+ };
54
+ const makeGlobalState = (overrides) => {
55
+ const state = {
56
+ ...DEFAULT_STATE,
57
+ ...overrides,
58
+ };
59
+ const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
60
+ // Apply any persisted authentication settings from sessionStorage
61
+ return applyPersistedSettings(stateWithUrls);
62
+ };
8
63
  // Save authentication settings to sessionStorage
9
64
  const saveAuthSettings = (settings) => {
10
65
  if (!settings) {
@@ -100,61 +155,6 @@ const applyPersistedSettings = (state) => {
100
155
  };
101
156
  };
102
157
 
103
- const DEFAULT_STATE = {
104
- projectId: "netskrafl",
105
- firebaseApiKey: "",
106
- databaseUrl: "",
107
- firebaseSenderId: "",
108
- firebaseAppId: "",
109
- measurementId: "",
110
- account: "",
111
- userEmail: "",
112
- userId: "",
113
- userNick: "",
114
- userFullname: "",
115
- locale: "is_IS",
116
- isExplo: false,
117
- serverUrl: "",
118
- movesUrl: "",
119
- movesAccessKey: "",
120
- token: "",
121
- loginMethod: "",
122
- subscriptionUrl: "",
123
- newUser: false,
124
- beginner: true,
125
- fairPlay: false,
126
- plan: "", // Not a friend
127
- hasPaid: false,
128
- ready: true,
129
- readyTimed: true,
130
- uiFullscreen: true,
131
- uiLandscape: false,
132
- runningLocal: false,
133
- };
134
- const makeServerUrls = (backendUrl, movesUrl) => {
135
- // If the last character of the url is a slash, cut it off,
136
- // since path URLs always start with a slash
137
- const cleanupUrl = (url) => {
138
- if (url.length > 0 && url[url.length - 1] === "/") {
139
- url = url.slice(0, -1);
140
- }
141
- return url;
142
- };
143
- return {
144
- serverUrl: cleanupUrl(backendUrl),
145
- movesUrl: cleanupUrl(movesUrl),
146
- };
147
- };
148
- const makeGlobalState = (overrides) => {
149
- const state = {
150
- ...DEFAULT_STATE,
151
- ...overrides,
152
- };
153
- const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
154
- // Apply any persisted authentication settings from sessionStorage
155
- return applyPersistedSettings(stateWithUrls);
156
- };
157
-
158
158
  function getDefaultExportFromCjs (x) {
159
159
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
160
160
  }
@@ -2535,6 +2535,133 @@ const ERROR_MESSAGES = {
2535
2535
  "server": "Netþjónn gat ekki tekið við leiknum - reyndu aftur"
2536
2536
  };
2537
2537
 
2538
+ /*
2539
+
2540
+ Audio.ts
2541
+
2542
+ Audio management service for Netskrafl/Explo
2543
+
2544
+ Copyright (C) 2025 Miðeind ehf.
2545
+ Author: Vilhjálmur Þorsteinsson
2546
+
2547
+ The Creative Commons Attribution-NonCommercial 4.0
2548
+ International Public License (CC-BY-NC 4.0) applies to this software.
2549
+ For further information, see https://github.com/mideind/Netskrafl
2550
+
2551
+ */
2552
+ /**
2553
+ * AudioManager handles preloading and playback of sound effects.
2554
+ * It creates HTMLAudioElement instances for each sound and manages their lifecycle.
2555
+ */
2556
+ class AudioManager {
2557
+ constructor(state, soundUrls) {
2558
+ this.sounds = new Map();
2559
+ this.initialized = false;
2560
+ // By default, sound URLs are based on /static on the backend server
2561
+ const DEFAULT_SOUND_BASE = serverUrl(state, "/static");
2562
+ const DEFAULT_SOUND_URLS = {
2563
+ "your-turn": `${DEFAULT_SOUND_BASE}/your-turn.mp3`,
2564
+ "you-win": `${DEFAULT_SOUND_BASE}/you-win.mp3`,
2565
+ "new-msg": `${DEFAULT_SOUND_BASE}/new-msg.mp3`,
2566
+ };
2567
+ // Merge provided URLs with defaults
2568
+ this.soundUrls = {
2569
+ ...DEFAULT_SOUND_URLS,
2570
+ ...(soundUrls || {}),
2571
+ };
2572
+ }
2573
+ /**
2574
+ * Initialize the audio manager by creating and preloading audio elements.
2575
+ * This should be called once when the application starts.
2576
+ */
2577
+ initialize() {
2578
+ if (this.initialized) {
2579
+ return;
2580
+ }
2581
+ // Create audio elements for each sound
2582
+ Object.entries(this.soundUrls).forEach(([soundId, url]) => {
2583
+ const audio = new Audio(url);
2584
+ audio.preload = "auto";
2585
+ // Handle load errors gracefully - don't let them crash the app
2586
+ audio.addEventListener("error", () => {
2587
+ console.warn(`Failed to load audio: ${soundId} from ${url}`);
2588
+ });
2589
+ this.sounds.set(soundId, audio);
2590
+ });
2591
+ this.initialized = true;
2592
+ }
2593
+ /**
2594
+ * Play a sound by its ID.
2595
+ * If the sound is not loaded or fails to play, the error is logged but doesn't throw.
2596
+ *
2597
+ * @param soundId The identifier of the sound to play
2598
+ */
2599
+ play(soundId) {
2600
+ if (!this.initialized) {
2601
+ this.initialize();
2602
+ }
2603
+ const audio = this.sounds.get(soundId);
2604
+ if (!audio) {
2605
+ console.warn(`Audio not found: ${soundId}`);
2606
+ return;
2607
+ }
2608
+ // Reset to start in case it's already playing
2609
+ audio.currentTime = 0;
2610
+ // Play the audio - catch any errors (e.g., user hasn't interacted with page yet)
2611
+ audio.play().catch((err) => {
2612
+ // This is expected in some cases (e.g., autoplay restrictions)
2613
+ // so we just log it at debug level
2614
+ if (err.name !== "NotAllowedError") {
2615
+ console.warn(`Failed to play audio ${soundId}:`, err);
2616
+ }
2617
+ });
2618
+ }
2619
+ /**
2620
+ * Update the URL for a specific sound.
2621
+ * This will recreate the audio element with the new URL.
2622
+ *
2623
+ * @param soundId The identifier of the sound to update
2624
+ * @param url The new URL for the sound
2625
+ */
2626
+ updateSoundUrl(soundId, url) {
2627
+ this.soundUrls[soundId] = url;
2628
+ // If already initialized, recreate this audio element
2629
+ if (this.initialized) {
2630
+ const audio = new Audio(url);
2631
+ audio.preload = "auto";
2632
+ audio.addEventListener("error", () => {
2633
+ console.warn(`Failed to load audio: ${soundId} from ${url}`);
2634
+ });
2635
+ this.sounds.set(soundId, audio);
2636
+ }
2637
+ }
2638
+ /**
2639
+ * Dispose of all audio elements and clean up resources.
2640
+ */
2641
+ dispose() {
2642
+ this.sounds.forEach((audio) => {
2643
+ audio.pause();
2644
+ audio.src = "";
2645
+ });
2646
+ this.sounds.clear();
2647
+ this.initialized = false;
2648
+ }
2649
+ }
2650
+ // Global singleton instance
2651
+ let audioManager = null;
2652
+ /**
2653
+ * Get or create the global AudioManager instance.
2654
+ *
2655
+ * @param soundUrls Optional custom sound URLs (only used on first call)
2656
+ * @returns The global AudioManager instance
2657
+ */
2658
+ function getAudioManager(state, soundUrls) {
2659
+ if (!audioManager) {
2660
+ audioManager = new AudioManager(state, soundUrls);
2661
+ }
2662
+ return audioManager;
2663
+ }
2664
+
2538
2665
  /*
2539
2666
 
2540
2667
  Util.ts
@@ -2691,11 +2818,10 @@ function setInput(id, val) {
2691
2818
  const elem = document.getElementById(id);
2692
2819
  elem.value = val;
2693
2820
  }
2694
- function playAudio(elemId) {
2695
- // Play an audio file
2696
- const sound = document.getElementById(elemId);
2697
- if (sound)
2698
- sound.play();
2821
+ function playAudio(state, soundId) {
2822
+ // Play an audio file using the AudioManager
2823
+ const audioManager = getAudioManager(state);
2824
+ audioManager.play(soundId);
2699
2825
  }
2700
2826
  function arrayEqual(a, b) {
2701
2827
  // Return true if arrays a and b are equal
@@ -27907,11 +28033,12 @@ const TogglerReadyTimed = (initialVnode) => {
27907
28033
  }
27908
28034
  };
27909
28035
  };
27910
- const TogglerAudio = () => {
28036
+ const TogglerAudio = (initialVnode) => {
27911
28037
  // Toggle for audio on/off
28038
+ const { model } = initialVnode.attrs.view;
27912
28039
  function toggleFunc(state) {
27913
- if (state)
27914
- playAudio("your-turn");
28040
+ if (state && model.state !== null)
28041
+ playAudio(model.state, "your-turn");
27915
28042
  }
27916
28043
  return {
27917
28044
  view: ({ attrs: { state, tabindex } }) => m(Toggler, {
@@ -27926,11 +28053,12 @@ const TogglerAudio = () => {
27926
28053
  })
27927
28054
  };
27928
28055
  };
27929
- const TogglerFanfare = () => {
28056
+ const TogglerFanfare = (initialVnode) => {
27930
28057
  // Toggle for fanfare on/off
28058
+ const { model } = initialVnode.attrs.view;
27931
28059
  function toggleFunc(state) {
27932
- if (state)
27933
- playAudio("you-win");
28060
+ if (state && model.state !== null)
28061
+ playAudio(model.state, "you-win");
27934
28062
  }
27935
28063
  return {
27936
28064
  view: ({ attrs: { state, tabindex } }) => m(Toggler, {
@@ -29077,8 +29205,7 @@ class Game extends BaseGame {
29077
29205
  // Ongoing timed game: start the clock
29078
29206
  this.startClock();
29079
29207
  // Kick off loading of chat messages, if this is not a robot game
29080
- const isHumanGame = !this.autoplayer[0] && !this.autoplayer[1];
29081
- if (isHumanGame)
29208
+ if (!this.isRobotGame())
29082
29209
  this.loadMessages();
29083
29210
  }
29084
29211
  init(srvGame) {
@@ -29236,6 +29363,8 @@ class Game extends BaseGame {
29236
29363
  // Update the srvGame state with data from the server,
29237
29364
  // either after submitting a move to the server or
29238
29365
  // after receiving a move notification via the Firebase listener
29366
+ // Remember if the game was already won before this update
29367
+ const wasWon = this.congratulate;
29239
29368
  // Stop highlighting the previous opponent move, if any
29240
29369
  for (let sq in this.tiles)
29241
29370
  if (this.tiles.hasOwnProperty(sq))
@@ -29256,6 +29385,10 @@ class Game extends BaseGame {
29256
29385
  // The call to resetClock() clears any outstanding interval timers
29257
29386
  // if the srvGame is now over
29258
29387
  this.resetClock();
29388
+ // Notify the move listener if the game just transitioned to won
29389
+ if (!wasWon && this.congratulate && this.moveListener) {
29390
+ this.moveListener.notifyGameWon();
29391
+ }
29259
29392
  }
29260
29393
  ;
29261
29394
  async refresh() {
@@ -29460,6 +29593,10 @@ class Game extends BaseGame {
29460
29593
  // actual game score minus accrued time penalty, if any, in a timed game
29461
29594
  return Math.max(this.scores[player] + (player === 0 ? this.penalty0 : this.penalty1), 0);
29462
29595
  }
29596
+ isRobotGame() {
29597
+ // Return true if any player in the game is a robot
29598
+ return this.autoplayer[0] || this.autoplayer[1];
29599
+ }
29463
29600
  async loadMessages() {
29464
29601
  // Load chat messages for this game
29465
29602
  if (this.chatLoading)
@@ -30995,9 +31132,17 @@ class Model {
30995
31132
  m.redraw();
30996
31133
  }
30997
31134
  handleMoveMessage(json, firstAttach) {
31135
+ var _a;
30998
31136
  // Handle an incoming Firebase move message
30999
31137
  if (!firstAttach && this.game) {
31000
31138
  this.game.update(json);
31139
+ // Play "your turn" audio notification if:
31140
+ // - User has audio enabled
31141
+ // - User is a participant in the game
31142
+ // - This is not a robot game (robots reply instantly anyway)
31143
+ if (((_a = this.user) === null || _a === void 0 ? void 0 : _a.audio) && this.game.player !== null && !this.game.isRobotGame()) {
31144
+ playAudio(this.state, "your-turn");
31145
+ }
31001
31146
  m.redraw();
31002
31147
  }
31003
31148
  }
@@ -31008,6 +31153,14 @@ class Model {
31008
31153
  this.gameList = null;
31009
31154
  }
31010
31155
  }
31156
+ notifyGameWon() {
31157
+ var _a;
31158
+ // The user just won a game:
31159
+ // play the "you-win" audio if fanfare is enabled
31160
+ if ((_a = this.user) === null || _a === void 0 ? void 0 : _a.fanfare) {
31161
+ playAudio(this.state, "you-win");
31162
+ }
31163
+ }
31011
31164
  moreGamesAllowed() {
31012
31165
  // Return true if the user is allowed to have more games ongoing
31013
31166
  if (!this.state)
@@ -32719,7 +32872,7 @@ const GamePromptDialogs = (initialVnode) => {
32719
32872
  // they can be invoked while the last_chall dialog is being
32720
32873
  // displayed. We therefore allow them to cover the last_chall
32721
32874
  // dialog. On mobile, both dialogs are displayed simultaneously.
32722
- if (game.last_chall) {
32875
+ if (game.last_chall && game.localturn) {
32723
32876
  r.push(m(".chall-info", [
32724
32877
  glyph("info-sign"), nbsp(),
32725
32878
  // "Your opponent emptied the rack - you can challenge or pass"
@@ -34336,7 +34489,7 @@ const Tab = {
34336
34489
  const game = view.model.game;
34337
34490
  return m(".right-tab" + (sel === tabid ? ".selected" : ""), {
34338
34491
  id: "tab-" + tabid,
34339
- className: alert ? "alert" : "",
34492
+ className: alert ? "chat-alert" : "",
34340
34493
  title: title,
34341
34494
  onclick: (ev) => {
34342
34495
  // Select this tab
@@ -34358,7 +34511,7 @@ const TabGroup = {
34358
34511
  // A group of clickable tabs for the right-side area content
34359
34512
  const { view } = vnode.attrs;
34360
34513
  const { game } = view.model;
34361
- const showChat = game && !(game.autoplayer[0] || game.autoplayer[1]);
34514
+ const showChat = game && !game.isRobotGame();
34362
34515
  const r = [
34363
34516
  m(Tab, { view, tabid: "board", title: ts("Borðið"), icon: "grid" }),
34364
34517
  m(Tab, { view, tabid: "movelist", title: ts("Leikir"), icon: "show-lines" }),
@@ -34380,7 +34533,7 @@ const TabGroup = {
34380
34533
  },
34381
34534
  // Show chat icon in red if any chat messages have not been seen
34382
34535
  // and the chat tab is not already selected
34383
- alert: !game.chatSeen && view.selectedTab != "chat"
34536
+ alert: !game.chatSeen && view.selectedTab !== "chat"
34384
34537
  }));
34385
34538
  }
34386
34539
  return m.fragment({}, r);
@@ -35082,6 +35235,9 @@ class View {
35082
35235
  this.actions = actions;
35083
35236
  // Initialize media listeners now that we have the view reference
35084
35237
  this.actions.initMediaListener(this);
35238
+ // Load user preferences early so audio settings are available
35239
+ // Use false to not show spinner on initial load
35240
+ this.model.loadUser(false);
35085
35241
  }
35086
35242
  appView(routeName) {
35087
35243
  // Returns a view based on the current route.
@@ -35514,6 +35670,7 @@ class Actions {
35514
35670
  this.model.handleUserMessage(json, firstAttach);
35515
35671
  }
35516
35672
  onChatMessage(json, firstAttach, view) {
35673
+ var _a, _b, _c;
35517
35674
  // Handle an incoming chat message
35518
35675
  if (firstAttach)
35519
35676
  console.log("First attach of chat: " + JSON.stringify(json));
@@ -35522,6 +35679,13 @@ class Actions {
35522
35679
  if (this.model.addChatMessage(json.game, json.from_userid, json.msg, json.ts)) {
35523
35680
  // A chat message was successfully added
35524
35681
  view.notifyChatMessage();
35682
+ // Play audio notification if:
35683
+ // - User has audio enabled
35684
+ // - Message is from opponent (not from current user)
35685
+ const userId = (_b = (_a = this.model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
35686
+ if (((_c = this.model.user) === null || _c === void 0 ? void 0 : _c.audio) && json.from_userid !== userId) {
35687
+ playAudio(this.model.state, "new-msg");
35688
+ }
35525
35689
  }
35526
35690
  }
35527
35691
  }