Skip to content

Commit 69854c0

Browse files
authored
Merge pull request #17 from journeyjt/feature/group-mode
feat: Add group mode with per-user mute/volume controls
2 parents d651f5b + 4103efa commit 69854c0

24 files changed

Lines changed: 2340 additions & 152 deletions

module.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "bardic-inspiration",
33
"title": "Bardic Inspiration",
44
"description": "Synchronized YouTube DJ for Foundry VTT - Play YouTube videos in sync across all players",
5-
"version": "1.0.0",
5+
"version": "1.0.1",
66
"compatibility": {
77
"minimum": "13.347",
88
"verified": "13.347"

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bardic-inspiration",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"description": "A Foundry VTT module",
55
"type": "module",
66
"scripts": {
@@ -17,10 +17,8 @@
1717
"dev:urls": "node -e \"const fs = require('fs'); const manifest = JSON.parse(fs.readFileSync('./module.json')); manifest.url = 'http://host.docker.internal:5000/modules/bardic-inspiration'; manifest.manifest = 'http://host.docker.internal:5000/modules/bardic-inspiration/module.json'; manifest.download = 'http://host.docker.internal:5000/modules/bardic-inspiration/module.zip'; fs.writeFileSync('./module.json', JSON.stringify(manifest, null, 2));\"",
1818
"prod:urls": "node -e \"const fs = require('fs'); const manifest = JSON.parse(fs.readFileSync('./module.json')); manifest.url = 'https://github.com/journeyjt/BardicInspiration'; manifest.manifest = 'https://github.com/journeyjt/BardicInspiration/releases/latest/download/module.json'; manifest.download = 'https://github.com/journeyjt/BardicInspiration/releases/latest/download/module.zip'; fs.writeFileSync('./module.json', JSON.stringify(manifest, null, 2));\"",
1919
"dev:serve": "npm run dev:urls && npm run dev:clean && npm run version:bump && npm run vite:build && npm run build:zip && npm run vite:dev",
20-
"build": "npm run prod:urls && npm test && npm run validate && npm run version:bump && npm run vite:build && npm run build:zip",
21-
"build:ci": "npm run prod:urls && npm run test:coverage && npm run validate && npm run vite:build && npm run build:zip",
22-
"build:zip": "powershell -Command \"Compress-Archive -Path module.json,dist,templates,languages,LICENSE,README.md -DestinationPath module.zip -Force\"",
23-
"precommit": "npm test && npm run validate"
20+
"build": "npm run prod:urls && npm run validate && npm run version:bump && npm run vite:build && npm run build:zip",
21+
"build:zip": "powershell -Command \"Compress-Archive -Path module.json,dist,templates,languages,LICENSE,README.md -DestinationPath module.zip -Force\""
2422
},
2523
"keywords": [
2624
"foundryvtt",

src/main.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,44 @@ Hooks.once('init', () => {
9797
}
9898
});
9999

100+
// Group Mode setting - visible in module settings
101+
game.settings.register('bardic-inspiration', 'youtubeDJ.groupMode', {
102+
name: 'Group Mode',
103+
hint: 'When enabled, all users in the listening session can add videos to the queue (not just the DJ)',
104+
scope: 'world',
105+
config: true,
106+
type: Boolean,
107+
default: false,
108+
onChange: (value: boolean) => {
109+
logger.info(`YouTube DJ Group Mode ${value ? 'enabled' : 'disabled'}`);
110+
// Update the queue state mode
111+
const queueState = game.settings.get('core', 'youtubeDJ.queueState') as any;
112+
queueState.mode = value ? 'collaborative' : 'single-dj';
113+
game.settings.set('core', 'youtubeDJ.queueState', queueState);
114+
// Notify all users of the mode change
115+
Hooks.callAll('youtubeDJ.groupModeChanged', { enabled: value });
116+
}
117+
});
118+
119+
// Client-side settings for user preferences (not synchronized)
120+
game.settings.register('bardic-inspiration', 'youtubeDJ.userMuted', {
121+
name: 'Personal Mute State',
122+
hint: 'Your personal mute preference for the YouTube DJ player',
123+
scope: 'client',
124+
config: false, // Hidden from settings menu
125+
type: Boolean,
126+
default: false
127+
});
128+
129+
game.settings.register('bardic-inspiration', 'youtubeDJ.userVolume', {
130+
name: 'Personal Volume',
131+
hint: 'Your personal volume preference for the YouTube DJ player',
132+
scope: 'client',
133+
config: false, // Hidden from settings menu
134+
type: Number,
135+
default: 50
136+
});
137+
100138
logger.info('YouTube DJ world settings registered');
101139

102140
// Register module API globally

src/services/PlayerManager.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -244,16 +244,13 @@ export class PlayerManager {
244244
}
245245

246246
try {
247-
Hooks.callAll('youtubeDJ.playerCommand', { command: 'mute' });
247+
// Send command only to local player (not to all users)
248+
Hooks.callAll('youtubeDJ.localPlayerCommand', { command: 'mute' });
248249

249-
this.store.updateState({
250-
player: {
251-
...this.store.getPlayerState(),
252-
isMuted: true
253-
}
254-
});
250+
// Store mute preference in client settings (per-user)
251+
await game.settings.set('bardic-inspiration', 'youtubeDJ.userMuted', true);
255252

256-
logger.debug('🎵 YouTube DJ | Player muted');
253+
logger.debug('🎵 YouTube DJ | Player muted (local only)');
257254
} catch (error) {
258255
logger.error('🎵 YouTube DJ | Failed to mute:', error);
259256
throw error;
@@ -269,16 +266,13 @@ export class PlayerManager {
269266
}
270267

271268
try {
272-
Hooks.callAll('youtubeDJ.playerCommand', { command: 'unMute' });
269+
// Send command only to local player (not to all users)
270+
Hooks.callAll('youtubeDJ.localPlayerCommand', { command: 'unMute' });
273271

274-
this.store.updateState({
275-
player: {
276-
...this.store.getPlayerState(),
277-
isMuted: false
278-
}
279-
});
272+
// Store mute preference in client settings (per-user)
273+
await game.settings.set('bardic-inspiration', 'youtubeDJ.userMuted', false);
280274

281-
logger.debug('🎵 YouTube DJ | Player unmuted');
275+
logger.debug('🎵 YouTube DJ | Player unmuted (local only)');
282276
} catch (error) {
283277
logger.error('🎵 YouTube DJ | Failed to unmute:', error);
284278
throw error;
@@ -289,7 +283,8 @@ export class PlayerManager {
289283
* Toggle mute state
290284
*/
291285
async toggleMute(): Promise<void> {
292-
const currentlyMuted = this.store.getPlayerState().isMuted;
286+
// Get mute state from client settings instead of global state
287+
const currentlyMuted = game.settings.get('bardic-inspiration', 'youtubeDJ.userMuted') as boolean;
293288

294289
if (currentlyMuted) {
295290
await this.unmute();
@@ -298,6 +293,37 @@ export class PlayerManager {
298293
}
299294
}
300295

296+
/**
297+
* Get user's current mute preference
298+
*/
299+
getUserMuteState(): boolean {
300+
return game.settings.get('bardic-inspiration', 'youtubeDJ.userMuted') as boolean;
301+
}
302+
303+
/**
304+
* Get user's current volume preference
305+
*/
306+
getUserVolume(): number {
307+
return game.settings.get('bardic-inspiration', 'youtubeDJ.userVolume') as number;
308+
}
309+
310+
/**
311+
* Set user's volume preference
312+
*/
313+
async setUserVolume(volume: number): Promise<void> {
314+
if (volume < 0 || volume > 100) {
315+
throw new Error('Volume must be between 0 and 100');
316+
}
317+
318+
// Store in client settings
319+
await game.settings.set('bardic-inspiration', 'youtubeDJ.userVolume', volume);
320+
321+
// Send command only to local player
322+
Hooks.callAll('youtubeDJ.localPlayerCommand', { command: 'setVolume', args: [volume] });
323+
324+
logger.debug(`🎵 YouTube DJ | User volume set to ${volume} (local only)`);
325+
}
326+
301327
/**
302328
* Start heartbeat for synchronization
303329
*/

src/services/QueueManager.ts

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,37 @@ export class QueueManager {
2929
Hooks.on('youtubeDJ.queueAdd', this.onQueueAdd.bind(this));
3030
Hooks.on('youtubeDJ.queueRemove', this.onQueueRemove.bind(this));
3131
Hooks.on('youtubeDJ.queueUpdate', this.onQueueUpdate.bind(this));
32+
Hooks.on('youtubeDJ.queueClear', this.onQueueClear.bind(this));
33+
}
34+
35+
/**
36+
* Check if user can add videos to queue
37+
*/
38+
private canAddToQueue(): boolean {
39+
// Check if Group Mode is enabled
40+
const groupMode = game.settings.get('bardic-inspiration', 'youtubeDJ.groupMode') as boolean;
41+
42+
if (groupMode) {
43+
// In Group Mode, any user in the session can add videos
44+
const sessionState = this.store.getSessionState();
45+
const currentUserId = game.user?.id;
46+
return sessionState.hasJoinedSession &&
47+
sessionState.members.some(m => m.userId === currentUserId && m.isActive);
48+
} else {
49+
// In single-DJ mode, only the DJ can add videos
50+
return this.store.isDJ();
51+
}
3252
}
3353

3454
/**
3555
* Add video to queue
3656
*/
3757
async addVideo(videoInfo: VideoInfo, playNow: boolean = false): Promise<void> {
38-
if (!this.store.isDJ()) {
39-
throw new Error('Only DJ can add videos to queue');
58+
if (!this.canAddToQueue()) {
59+
const groupMode = game.settings.get('bardic-inspiration', 'youtubeDJ.groupMode') as boolean;
60+
throw new Error(groupMode
61+
? 'You must be in the listening session to add videos to the queue'
62+
: 'Only the DJ can add videos to the queue');
4063
}
4164

4265
const userId = game.user?.id;
@@ -471,7 +494,7 @@ export class QueueManager {
471494
*/
472495
async clearQueue(): Promise<void> {
473496
if (!this.store.isDJ()) {
474-
throw new Error('Only DJ can clear queue');
497+
throw new Error('Only the DJ can clear the queue');
475498
}
476499

477500
logger.debug('🎵 YouTube DJ | Clearing queue...');
@@ -482,6 +505,10 @@ export class QueueManager {
482505
currentIndex: -1,
483506
mode: 'single-dj',
484507
djUserId: this.store.getSessionState().djUserId
508+
},
509+
player: {
510+
...this.store.getPlayerState(),
511+
playbackState: 'paused'
485512
}
486513
});
487514

@@ -626,35 +653,47 @@ export class QueueManager {
626653
/**
627654
* Handle queue next event from DJ (for listeners)
628655
*/
629-
private onQueueNext(data: { nextIndex: number; videoItem: any; timestamp: number }): void {
630-
// Only update state if we're not the DJ (DJ already updated their state)
631-
if (this.store.isDJ()) {
656+
private onQueueNext(data: { nextIndex: number; videoItem: any; timestamp: number; userId: string; cycledItem?: any; isCycling?: boolean }): void {
657+
// Don't sync our own changes (avoid double-processing)
658+
if (data.userId === game.user?.id) {
632659
return;
633660
}
634661

635662
logger.debug('🎵 YouTube DJ | Syncing queue next from DJ:', data.nextIndex);
636663

637664
const currentQueue = this.store.getQueueState();
665+
let newQueue = [...currentQueue.items];
666+
667+
// If this is a cycling operation, reorder the queue to match DJ
668+
if (data.isCycling && data.cycledItem && currentQueue.currentIndex >= 0) {
669+
// Remove the current item and add it to the end (same as DJ did)
670+
const cycledItemIndex = currentQueue.currentIndex;
671+
if (cycledItemIndex < newQueue.length) {
672+
const removedItem = newQueue.splice(cycledItemIndex, 1)[0];
673+
newQueue.push(removedItem);
674+
}
675+
}
638676

639-
// Update queue index to match DJ
677+
// Update queue to match DJ's state
640678
this.store.updateState({
641679
queue: {
642680
...currentQueue,
681+
items: newQueue,
643682
currentIndex: data.nextIndex
644683
}
645684
});
646685
}
647686

648687
/**
649-
* Handle queue add event from DJ (for listeners)
688+
* Handle queue add event from other users
650689
*/
651-
private onQueueAdd(data: { queueItem: any; playNow: boolean; timestamp: number }): void {
652-
// Only update state if we're not the DJ (DJ already updated their state)
653-
if (this.store.isDJ()) {
690+
private onQueueAdd(data: { queueItem: any; playNow: boolean; timestamp: number; userId: string }): void {
691+
// Don't sync our own changes (avoid double-adding)
692+
if (data.userId === game.user?.id) {
654693
return;
655694
}
656695

657-
logger.debug('🎵 YouTube DJ | Syncing queue add from DJ:', data.queueItem?.title);
696+
logger.debug('🎵 YouTube DJ | Syncing queue add from user:', data.userId, '-', data.queueItem?.title);
658697

659698
const currentQueue = this.store.getQueueState();
660699
const newQueue = [...currentQueue.items, data.queueItem];
@@ -757,6 +796,32 @@ export class QueueManager {
757796
});
758797
}
759798

799+
/**
800+
* Handle queue clear event from DJ (for listeners)
801+
*/
802+
private onQueueClear(data: { timestamp: number; userId: string }): void {
803+
// Don't sync our own changes (avoid double-processing)
804+
if (data.userId === game.user?.id) {
805+
return;
806+
}
807+
808+
logger.debug('🎵 YouTube DJ | Syncing queue clear from DJ');
809+
810+
// Clear the queue
811+
this.store.updateState({
812+
queue: {
813+
items: [],
814+
currentIndex: -1,
815+
mode: 'single-dj',
816+
djUserId: this.store.getSessionState().djUserId
817+
},
818+
player: {
819+
...this.store.getPlayerState(),
820+
playbackState: 'paused'
821+
}
822+
});
823+
}
824+
760825
/**
761826
* Handle video ended event
762827
*/
@@ -823,6 +888,7 @@ export class QueueManager {
823888
Hooks.off('youtubeDJ.queueAdd', this.onQueueAdd.bind(this));
824889
Hooks.off('youtubeDJ.queueRemove', this.onQueueRemove.bind(this));
825890
Hooks.off('youtubeDJ.queueUpdate', this.onQueueUpdate.bind(this));
891+
Hooks.off('youtubeDJ.queueClear', this.onQueueClear.bind(this));
826892
logger.debug('🎵 YouTube DJ | QueueManager destroyed');
827893
}
828894
}

src/services/SocketManager.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export class SocketManager {
196196
this.registerHandler('QUEUE_REMOVE', new QueueRemoveHandler(this.store));
197197
this.registerHandler('QUEUE_UPDATE', new QueueUpdateHandler(this.store));
198198
this.registerHandler('QUEUE_NEXT', new QueueNextHandler(this.store));
199+
this.registerHandler('QUEUE_CLEAR', new QueueClearHandler(this.store));
199200

200201
logger.debug('🎵 YouTube DJ | Default message handlers registered');
201202
}
@@ -733,13 +734,14 @@ class QueueAddHandler implements MessageHandler {
733734
constructor(private store: SessionStore) {}
734735

735736
handle(message: YouTubeDJMessage): void {
736-
logger.debug('🎵 YouTube DJ | Queue add from DJ');
737+
logger.debug('🎵 YouTube DJ | Queue add from user:', message.userId);
737738

738739
// Emit event for QueueManager to handle
739740
Hooks.callAll('youtubeDJ.queueAdd', {
740741
queueItem: message.data?.queueItem,
741742
playNow: message.data?.playNow || false,
742-
timestamp: message.timestamp
743+
timestamp: message.timestamp,
744+
userId: message.userId // Pass the original sender's ID
743745
});
744746
}
745747
}
@@ -783,7 +785,24 @@ class QueueNextHandler implements MessageHandler {
783785
Hooks.callAll('youtubeDJ.queueNext', {
784786
nextIndex: message.data?.nextIndex,
785787
videoItem: message.data?.videoItem,
786-
timestamp: message.timestamp
788+
timestamp: message.timestamp,
789+
userId: message.userId,
790+
cycledItem: message.data?.cycledItem,
791+
isCycling: message.data?.isCycling
792+
});
793+
}
794+
}
795+
796+
class QueueClearHandler implements MessageHandler {
797+
constructor(private store: SessionStore) {}
798+
799+
handle(message: YouTubeDJMessage): void {
800+
logger.debug('🎵 YouTube DJ | Queue clear from DJ');
801+
802+
// Emit event for QueueManager to handle
803+
Hooks.callAll('youtubeDJ.queueClear', {
804+
timestamp: message.timestamp,
805+
userId: message.userId
787806
});
788807
}
789808
}

0 commit comments

Comments
 (0)