Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ A synchronized YouTube music player module for FoundryVTT that lets Game Masters
- Remove videos from queue
- Automatic loop - restarts from beginning when queue ends
- Queue persists across sessions
- **Save & Load Queues**: DJs can save the current queue with a custom name and load saved queues later
- **Export/Import**: Share queue configurations between games or GMs

## Installation

Expand Down Expand Up @@ -93,6 +95,28 @@ A synchronized YouTube music player module for FoundryVTT that lets Game Masters
- **Remove Videos**:
- Click the **✕** button next to any video to remove it from the queue

- **Save Current Queue**:
- Click **"Save Queue"** button
- Enter a unique name for your queue
- The queue will be saved and can be loaded later

- **Load Saved Queue**:
- Click **"Load Queue"** button
- Select from your saved queues
- Choose to replace the current queue or append to it
- Note: Loading a queue does not automatically start playback - you must press play

- **Manage Saved Queues**:
- View all saved queues with creation date and song count
- Rename saved queues to better organize them
- Delete queues you no longer need
- Export queues to share with other GMs
- Import queues from other games

- **Clear Queue**:
- Click **"Clear All"** to remove all videos
- You'll be prompted to save the current queue before clearing

#### Playback Controls
- **Play/Pause**: Toggle playback for all connected users
- **Next**: Skip to the next video in the queue
Expand Down Expand Up @@ -122,9 +146,11 @@ A synchronized YouTube music player module for FoundryVTT that lets Game Masters

- **Stable Connection**: Ensure all players have a stable internet connection for synchronized playback
- **Prepare Playlists**: Add multiple videos to the queue before starting for uninterrupted music
- **Save Your Playlists**: Save frequently-used queues for quick loading in future sessions
- **Volume Balance**: Start with lower volume and adjust up to avoid startling players
- **Session Persistence**: The queue and current playback position persist between sessions
- **Late Joiners**: Players who join mid-session automatically sync to current playback
- **Audio Settings Preserved**: Loading a saved queue won't change users' volume or mute settings

### Troubleshooting

Expand Down
5 changes: 3 additions & 2 deletions module.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"description": "Synchronized YouTube DJ for Foundry VTT - Play YouTube videos in sync across all players",
"version": "1.0.1",
"compatibility": {
"minimum": "13.347",
"verified": "13.347"
"minimum": "12",
"verified": "13",
"maximum": "13"
},
"authors": [
{
Expand Down
2 changes: 2 additions & 0 deletions src/apps/YouTubeDJApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
this.addEventDelegation('.move-down-btn', 'click', (e) => this.queueSectionComponent.onMoveDownClick(e));
this.addEventDelegation('.skip-to-btn', 'click', (e) => this.queueSectionComponent.onSkipToClick(e));
this.addEventDelegation('.clear-queue-btn', 'click', () => this.queueSectionComponent.onClearQueueClick());
this.addEventDelegation('.save-queue-btn', 'click', () => this.queueSectionComponent.onSaveQueueClick());
this.addEventDelegation('.load-queue-btn', 'click', () => this.queueSectionComponent.onLoadQueueClick());
this.addEventDelegation('.youtube-url-input', 'keypress', (e) => this.queueSectionComponent.onUrlInputKeypress(e as KeyboardEvent));

// Integrated playbook controls in queue section
Expand Down
19 changes: 18 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SocketManager } from './services/SocketManager.js';
import { SessionManager } from './services/SessionManager.js';
import { PlayerManager } from './services/PlayerManager.js';
import { QueueManager } from './services/QueueManager.js';
import { SavedQueuesManager } from './services/SavedQueuesManager.js';
import { UIHelper } from './ui/UIHelper.js';
import { logger } from './lib/logger.js';
import './styles/main.css';
Expand Down Expand Up @@ -93,10 +94,21 @@ Hooks.once('init', () => {
items: [],
currentIndex: -1,
mode: 'single-dj',
djUserId: null
djUserId: null,
savedQueues: []
}
});

// Saved queues setting
game.settings.register('core', 'youtubeDJ.savedQueues', {
name: 'YouTube DJ Saved Queues',
hint: 'Saved queue templates that can be loaded',
scope: 'world',
config: false,
type: Array,
default: []
});

// Group Mode setting - visible in module settings
game.settings.register('bardic-inspiration', 'youtubeDJ.groupMode', {
name: 'Group Mode',
Expand Down Expand Up @@ -177,11 +189,16 @@ Hooks.once('ready', async () => {
const queueManager = new QueueManager(store);
logger.info('YouTube DJ QueueManager initialized globally');

// Initialize global SavedQueuesManager for saved queue operations
const savedQueuesManager = new SavedQueuesManager(store, queueManager);
logger.info('YouTube DJ SavedQueuesManager initialized globally');

// Store global references for access across components
(globalThis as any).youtubeDJSocketManager = socketManager;
(globalThis as any).youtubeDJSessionManager = sessionManager;
(globalThis as any).youtubeDJPlayerManager = playerManager;
(globalThis as any).youtubeDJQueueManager = queueManager;
(globalThis as any).youtubeDJSavedQueuesManager = savedQueuesManager;

// Initialize YouTube player widget above player list
try {
Expand Down
42 changes: 37 additions & 5 deletions src/services/PlayerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export class PlayerManager {
// Listen for video load requests from QueueManager
Hooks.on('youtubeDJ.loadVideo', this.onLoadVideoRequest.bind(this));

// Listen for video cue requests (load without auto-play)
Hooks.on('youtubeDJ.cueVideo', this.onCueVideoRequest.bind(this));

// Listen for heartbeat synchronization
Hooks.on('youtubeDJ.heartbeat', this.onHeartbeatReceived.bind(this));

Expand Down Expand Up @@ -219,12 +222,12 @@ export class PlayerManager {
Hooks.callAll('youtubeDJ.playerCommand', { command: 'cueVideoById', args: [videoId, startTime] });
}

// Broadcast load command
// Broadcast load command with autoPlay flag
this.broadcastMessage({
type: 'LOAD',
userId: game.user?.id || '',
timestamp: Date.now(),
data: { videoId, startTime, videoInfo }
data: { videoId, startTime, videoInfo, autoPlay }
});

logger.info('🎵 YouTube DJ | Video loaded successfully:', videoInfo.title);
Expand Down Expand Up @@ -560,6 +563,27 @@ export class PlayerManager {
}
}

/**
* Handle cue video request (load without auto-play)
* This is used when loading saved queues to preserve user's playback/audio state
*/
private async onCueVideoRequest(data: { videoId: string; videoInfo: VideoInfo; autoPlay?: boolean }): Promise<void> {
if (!this.store.isDJ()) {
logger.debug('🎵 YouTube DJ | Ignoring cue request - not DJ');
return;
}

logger.debug('🎵 YouTube DJ | Cueing video from saved queue:', data.videoInfo.title);

try {
// Load video with autoPlay explicitly set to false
// This preserves the user's current mute/volume settings
await this.loadVideo(data.videoId, 0, false);
} catch (error) {
logger.error('🎵 YouTube DJ | Failed to cue video from saved queue:', error);
}
}

/**
* Handle heartbeat received from DJ
*/
Expand Down Expand Up @@ -747,7 +771,7 @@ export class PlayerManager {
/**
* Handle load command from DJ
*/
private async onLoadCommand(data: { videoId: string; startTime: number; videoInfo: any; timestamp: number }): Promise<void> {
private async onLoadCommand(data: { videoId: string; startTime: number; videoInfo: any; autoPlay?: boolean; timestamp: number }): Promise<void> {
if (this.store.isDJ()) {
// DJ doesn't need to respond to their own commands
return;
Expand All @@ -768,11 +792,18 @@ export class PlayerManager {
}
});

// Send command to widget player
// Send command to widget player - respect autoPlay flag
const command = data.autoPlay !== false ? 'loadVideoById' : 'cueVideoById';
Hooks.callAll('youtubeDJ.playerCommand', {
command: 'loadVideoById',
command: command,
args: [data.videoId, data.startTime || 0]
});

logger.debug('🎵 YouTube DJ | Synced video command:', {
command,
videoId: data.videoId,
autoPlay: data.autoPlay
});
} catch (error) {
logger.error('🎵 YouTube DJ | Failed to sync load command:', error);
}
Expand Down Expand Up @@ -802,6 +833,7 @@ export class PlayerManager {
this.stopHeartbeat();
Hooks.off('youtubeDJ.stateChanged', this.onStateChanged.bind(this));
Hooks.off('youtubeDJ.loadVideo', this.onLoadVideoRequest.bind(this));
Hooks.off('youtubeDJ.cueVideo', this.onCueVideoRequest.bind(this));
Hooks.off('youtubeDJ.heartbeat', this.onHeartbeatReceived.bind(this));
Hooks.off('youtubeDJ.playCommand', this.onPlayCommand.bind(this));
Hooks.off('youtubeDJ.pauseCommand', this.onPauseCommand.bind(this));
Expand Down
Loading
Loading