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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Bardic Inspiration

![FoundryVTT Version](https://img.shields.io/badge/FoundryVTT-v13-green)
![Module Version](https://img.shields.io/badge/version-0.8.33-blue)
![Module Version](https://img.shields.io/badge/version-1.0.0-blue)
![License](https://img.shields.io/badge/license-MIT-yellow)

A synchronized YouTube music player module for FoundryVTT that lets Game Masters and players share a musical experience during tabletop sessions. Features DJ controls, queue management, and real-time synchronization across all connected players.
Expand Down
4 changes: 2 additions & 2 deletions module.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"id": "bardic-inspiration",
"title": "Bardic Inspiration",
"description": "Synchronized YouTube DJ for Foundry VTT - Play YouTube videos in sync across all players",
"version": "0.9.5",
"version": "1.0.0",
"compatibility": {
"minimum": "12",
"minimum": "13.347",
"verified": "13.347"
},
"authors": [
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bardic-inspiration",
"version": "0.9.20",
"version": "1.0.0",
"description": "A Foundry VTT module",
"type": "module",
"scripts": {
Expand Down
47 changes: 16 additions & 31 deletions src/apps/YouTubeDJApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,12 @@ import { SocketManager } from '../services/SocketManager.js';
// UI Components
import { SessionControlsComponent } from '../ui/components/SessionControlsComponent.js';
import { QueueSectionComponent } from '../ui/components/QueueSectionComponent.js';
import { PlayerControlsComponent } from '../ui/components/PlayerControlsComponent.js';
// PlayerControlsComponent now integrated into QueueSectionComponent
import { UIHelper } from '../ui/UIHelper.js';

interface YouTubeDJData {
// Only main template context data needed - components handle their own data
hasJoinedSession: boolean;
sessionMembers: Array<{userId: string, name: string, isDJ: boolean, isActive: boolean, lastActivity: number}>;
queueLength: number;
isDJ: boolean;
}

Expand All @@ -39,7 +37,7 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
// UI Components
private sessionControlsComponent!: SessionControlsComponent;
private queueSectionComponent!: QueueSectionComponent;
private playerControlsComponent!: PlayerControlsComponent;
// PlayerControlsComponent integrated into QueueSectionComponent

// Event cleanup tracking - separated by type
private stateListenerCleanup: (() => void)[] = [];
Expand Down Expand Up @@ -109,22 +107,17 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
*/
async _prepareContext(): Promise<YouTubeDJData> {
const sessionState = this.store.getSessionState();
const queueState = this.store.getQueueState();
const isDJ = this.store.isDJ();

const context = {
// Main template only needs structural information
hasJoinedSession: sessionState.hasJoinedSession,
sessionMembers: sessionState.members,
queueLength: queueState.items.length,
isDJ
};

logger.debug('🎵 YouTube DJ | Main template context:', {
hasJoinedSession: context.hasJoinedSession,
isDJ: context.isDJ,
sessionMembers: context.sessionMembers.length,
queueLength: context.queueLength
isDJ: context.isDJ
});

return context;
Expand Down Expand Up @@ -176,13 +169,12 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
// Initialize UI components with their specific containers
this.sessionControlsComponent = new SessionControlsComponent(this.store, contentElement, this.sessionManager);
this.queueSectionComponent = new QueueSectionComponent(this.store, contentElement, this.queueManager, this.playerManager);
this.playerControlsComponent = new PlayerControlsComponent(this.store, contentElement, this.playerManager, this.queueManager);
// PlayerControls integrated into QueueSectionComponent

// Initialize all components
await Promise.all([
this.sessionControlsComponent.initialize(),
this.queueSectionComponent.initialize(),
this.playerControlsComponent.initialize()
this.queueSectionComponent.initialize()
]);

logger.debug('🎵 YouTube DJ | UI components initialized');
Expand Down Expand Up @@ -221,18 +213,16 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
this.addEventDelegation('.skip-to-btn', 'click', (e) => this.queueSectionComponent.onSkipToClick(e));
this.addEventDelegation('.clear-queue-btn', 'click', () => this.queueSectionComponent.onClearQueueClick());
this.addEventDelegation('.youtube-url-input', 'keypress', (e) => this.queueSectionComponent.onUrlInputKeypress(e as KeyboardEvent));

// Integrated playbook controls in queue section
this.addEventDelegation('.play-btn', 'click', () => this.queueSectionComponent.onPlayClick());
this.addEventDelegation('.pause-btn', 'click', () => this.queueSectionComponent.onPauseClick());
this.addEventDelegation('.skip-btn', 'click', () => this.queueSectionComponent.onSkipClick());
this.addEventDelegation('.start-queue-btn', 'click', () => this.queueSectionComponent.onStartQueueClick());

// Player controls event delegation
this.addEventDelegation('.play-btn', 'click', () => this.playerControlsComponent.onPlayClick());
this.addEventDelegation('.pause-btn', 'click', () => this.playerControlsComponent.onPauseClick());
this.addEventDelegation('.next-btn', 'click', () => this.playerControlsComponent.onNextTrackClick());
this.addEventDelegation('.prev-btn', 'click', () => this.playerControlsComponent.onPreviousTrackClick());
this.addEventDelegation('.play-next-btn', 'click', () => this.queueSectionComponent.onPlayNextClick());
this.addEventDelegation('.load-video-btn', 'click', () => this.queueSectionComponent.onLoadVideoClick());
// Removed: play-next-btn and load-video-btn - users can use queue reordering instead

// Seek bar controls (handled by SeekBarComponent)
this.addEventDelegation('.seek-bar', 'input', (e) => this.playerControlsComponent.seekBarComponent?.onSeekBarInput(e));
this.addEventDelegation('.seek-bar', 'change', (e) => this.playerControlsComponent.seekBarComponent?.onSeekBarChange(e));
// Seek bar controls removed (seeking now handled by widget)

// Widget volume control
this.addEventDelegation('.volume-slider', 'input', (e) => (window as any).youtubeDJWidget?.onVolumeChange(e));
Expand Down Expand Up @@ -261,7 +251,7 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
const matchedElement = target.closest(selector);

// Debug logging for queue buttons
if (selector.includes('move-') || selector.includes('remove-queue')) {
if (selector.includes('move-') || selector.includes('remove-queue') || selector.includes('clear-queue')) {
logger.debug('🎵 YouTube DJ | Event delegation check', {
selector,
targetTag: target.tagName,
Expand Down Expand Up @@ -403,10 +393,7 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
this.queueSectionComponent.destroy();
this.queueSectionComponent = null as any;
}
if (this.playerControlsComponent) {
this.playerControlsComponent.destroy();
this.playerControlsComponent = null as any;
}
// PlayerControls integrated into QueueSectionComponent

// Initialize UI components (will check for hasJoinedSession internally)
await this.initializeUIComponents();
Expand Down Expand Up @@ -489,9 +476,7 @@ export class YouTubeDJApp extends foundry.applications.api.HandlebarsApplication
if (this.queueSectionComponent) {
this.queueSectionComponent.destroy();
}
if (this.playerControlsComponent) {
this.playerControlsComponent.destroy();
}
// PlayerControls integrated into QueueSectionComponent

// Note: Don't destroy global services - they persist across app instances

Expand Down
28 changes: 7 additions & 21 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,24 @@ 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 { UIHelper } from './ui/UIHelper.js';
import { logger } from './lib/logger.js';
import './styles/main.css';

const MODULE_ID = 'bardic-inspiration';

interface ModuleAPI {
ID: string;
openYoutubeDJ(): void;
somePublicMethod(): void;
}

class BardicInspirationAPI implements ModuleAPI {
static readonly ID = MODULE_ID;
readonly ID = MODULE_ID;

static openYoutubeDJ(): void {
openYoutubeDJ(): void {
YouTubeDJApp.open();
}

static openYoutubeDJWidget(): void {
openYoutubeDJWidget(): void {
YouTubePlayerWidget.getInstance().initialize();
}

static somePublicMethod(): void {
logger.api('Public API method called');
}

static getLibWrapperUtils(): typeof LibWrapperUtils {
getLibWrapperUtils(): any {
return LibWrapperUtils;
}
}
Expand Down Expand Up @@ -111,13 +102,8 @@ Hooks.once('init', () => {
// Register module API globally
const module = game.modules.get(MODULE_ID);
if (module) {
(module as any).api = {
ID: BardicInspirationAPI.ID,
openYoutubeDJ: BardicInspirationAPI.openYoutubeDJ,
openYoutubeDJWidget: BardicInspirationAPI.openYoutubeDJWidget,
somePublicMethod: BardicInspirationAPI.somePublicMethod,
getLibWrapperUtils: BardicInspirationAPI.getLibWrapperUtils
};
const apiInstance = new BardicInspirationAPI();
(module as any).api = apiInstance;
}

// Example of using libWrapper (when available)
Expand Down
57 changes: 37 additions & 20 deletions src/services/PlayerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,47 @@ export class PlayerManager {
throw new Error('Only DJ can control playback');
}

// Check if there's a valid video to play
const playerState = this.store.getPlayerState();
const hasValidVideo = playerState.currentVideo?.videoId &&
playerState.currentVideo.videoId.length === 11;
// PRIORITY 1: Check if there are queued videos to play
const queueState = this.store.getQueueState();
const hasQueuedVideo = queueState.items.length > 0 &&
queueState.currentIndex >= 0 &&
queueState.currentIndex < queueState.items.length;

if (!hasValidVideo) {
// No video loaded - try to play from queue if available
const queueState = this.store.getQueueState();
if (queueState.items.length > 0 && queueState.currentIndex >= 0) {
const currentItem = queueState.items[queueState.currentIndex];
if (currentItem?.videoId) {
logger.debug('🎵 YouTube DJ | Loading video from queue:', currentItem.title);
await this.loadVideo(currentItem.videoId);
return; // loadVideo will handle playing
}
if (hasQueuedVideo) {
const currentQueueItem = queueState.items[queueState.currentIndex];
const playerState = this.store.getPlayerState();

logger.debug('🎵 YouTube DJ | Queue has videos, checking if correct video is loaded:', {
queueVideoId: currentQueueItem.videoId,
playerVideoId: playerState.currentVideo?.videoId,
queueTitle: currentQueueItem.title
});

// Check if the player has the correct video loaded
if (playerState.currentVideo?.videoId !== currentQueueItem.videoId) {
logger.debug('🎵 YouTube DJ | Loading correct video from queue:', currentQueueItem.title);
await this.loadVideo(currentQueueItem.videoId);
return; // loadVideo will handle playing
}

// Correct video is already loaded, proceed to play it
logger.debug('🎵 YouTube DJ | Playing current queue video:', currentQueueItem.title);
} else {
// PRIORITY 2: No queue items - check if there's a fallback video loaded
const playerState = this.store.getPlayerState();
const hasValidVideo = playerState.currentVideo?.videoId &&
playerState.currentVideo.videoId.length === 11;

if (!hasValidVideo) {
throw new Error('No video loaded. Please add videos to the queue first.');
}
throw new Error('No video loaded. Please add videos to the queue first.');

logger.debug('🎵 YouTube DJ | No queue items, playing loaded video:', {
videoId: playerState.currentVideo.videoId,
title: playerState.currentVideo.title
});
}

logger.debug('🎵 YouTube DJ | Playing video...', {
videoId: playerState.currentVideo.videoId,
title: playerState.currentVideo.title
});

try {
// Send command to widget player
Hooks.callAll('youtubeDJ.playerCommand', { command: 'playVideo' });
Expand Down
94 changes: 93 additions & 1 deletion src/services/QueueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,105 @@ export class QueueManager {
}

/**
* Play next video in queue
* Play next video in queue (with auto-cycling)
*/
async nextVideo(): Promise<VideoItem | null> {
if (!this.store.isDJ()) {
throw new Error('Only DJ can control queue playback');
}

const currentQueue = this.store.getQueueState();

// If we have a currently playing item, move it to the end of the queue
if (currentQueue.currentIndex >= 0 && currentQueue.items[currentQueue.currentIndex]) {
const currentItem = currentQueue.items[currentQueue.currentIndex];
let newQueue = [...currentQueue.items];

// Remove the current item from its position
newQueue.splice(currentQueue.currentIndex, 1);

// Add it to the end of the queue
newQueue.push(currentItem);

// After cycling, the next item moves into the current index position
// Unless we were at the end, in which case we go to 0
let newIndex = currentQueue.currentIndex;
if (newQueue.length === 0) {
newIndex = -1; // Queue is empty after removing the only item
} else if (newIndex >= newQueue.length) {
newIndex = 0; // Loop back to beginning if we were at the last item
}

// Update the queue with the cycled item
this.store.updateState({
queue: {
...currentQueue,
items: newQueue,
currentIndex: newIndex
}
});

// Play the next item if there is one
if (newIndex >= 0 && newQueue.length > 0) {
const nextVideo = newQueue[newIndex];
this.playQueueItem(newIndex);

// Broadcast queue advance with cycling
this.broadcastMessage({
type: 'QUEUE_NEXT',
userId: game.user?.id || '',
timestamp: Date.now(),
data: {
nextIndex: newIndex,
videoItem: nextVideo,
cycledItem: currentItem,
isCycling: true
}
});

logger.info('🎵 YouTube DJ | Advanced to next video, cycled previous to end:', nextVideo.title);
return nextVideo;
} else {
logger.debug('🎵 YouTube DJ | Queue is now empty');
return null;
}
} else {
// No current item, try to play the first item if available
if (currentQueue.items.length > 0) {
this.store.updateState({
queue: {
...currentQueue,
currentIndex: 0
}
});

const nextVideo = currentQueue.items[0];
this.playQueueItem(0);

// Broadcast queue start
this.broadcastMessage({
type: 'QUEUE_NEXT',
userId: game.user?.id || '',
timestamp: Date.now(),
data: { nextIndex: 0, videoItem: nextVideo }
});

return nextVideo;
} else {
logger.debug('🎵 YouTube DJ | Queue is empty');
return null;
}
}
}

/**
* Play next video (for manual skip without cycling)
*/
async playNext(): Promise<VideoItem | null> {
if (!this.store.isDJ()) {
throw new Error('Only DJ can control queue playback');
}

const currentQueue = this.store.getQueueState();
const nextIndex = currentQueue.currentIndex + 1;

Expand Down
Loading
Loading