diff --git a/index.js b/index.js index 96c7dab..2436e52 100644 --- a/index.js +++ b/index.js @@ -21,6 +21,7 @@ import { $thoughtsContainer, $inventoryContainer, $questsContainer, + $musicPlayerContainer, setExtensionSettings, updateExtensionSettings, setLastGeneratedData, @@ -37,6 +38,7 @@ import { setThoughtsContainer, setInventoryContainer, setQuestsContainer, + setMusicPlayerContainer, clearSessionAvatarPrompts } from './src/core/state.js'; import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js'; @@ -66,6 +68,8 @@ import { } from './src/systems/rendering/thoughts.js'; import { renderInventory } from './src/systems/rendering/inventory.js'; import { renderQuests } from './src/systems/rendering/quests.js'; +import { renderMusicPlayer } from './src/systems/rendering/musicPlayer.js'; +import { toggleSnowflakes, initSnowflakes } from './src/systems/ui/snowflakes.js'; // Interaction modules import { initInventoryEventListeners } from './src/systems/interaction/inventoryActions.js'; @@ -76,6 +80,7 @@ import { applyCustomTheme, toggleCustomColors, toggleAnimations, + updateFeatureTogglesVisibility, updateSettingsPopupTheme, applyCustomThemeToSettingsPopup } from './src/systems/ui/theme.js'; @@ -130,6 +135,7 @@ import { setupClassicStatsButtons } from './src/systems/features/classicStats.js import { ensureHtmlCleaningRegex, detectConflictingRegexScripts } from './src/systems/features/htmlCleaning.js'; import { setupMemoryRecollectionButton, updateMemoryRecollectionButton } from './src/systems/features/memoryRecollection.js'; import { initLorebookLimiter } from './src/systems/features/lorebookLimiter.js'; +import { parseAndStoreSpotifyUrl } from './src/systems/features/musicPlayer.js'; import { DEFAULT_HTML_PROMPT } from './src/systems/generation/promptBuilder.js'; import { openEncounterModal } from './src/systems/ui/encounterUI.js'; @@ -277,6 +283,7 @@ async function initUI() { setThoughtsContainer($('#rpg-thoughts')); setInventoryContainer($('#rpg-inventory')); setQuestsContainer($('#rpg-quests')); + setMusicPlayerContainer($('#rpg-music-player')); // Re-apply translations to the entire body to catch all new elements from the template i18n.applyTranslations(document.body); @@ -377,6 +384,25 @@ async function initUI() { saveSettings(); }); + $('#rpg-toggle-spotify-music').on('change', function() { + extensionSettings.enableSpotifyMusic = $(this).prop('checked'); + saveSettings(); + updateSectionVisibility(); + renderMusicPlayer($musicPlayerContainer[0]); + }); + + $('#rpg-toggle-snowflakes').on('change', function() { + extensionSettings.enableSnowflakes = $(this).prop('checked'); + saveSettings(); + toggleSnowflakes(extensionSettings.enableSnowflakes); + }); + + $('#rpg-dismiss-promo').on('click', function() { + extensionSettings.dismissedHolidayPromo = true; + saveSettings(); + $('#rpg-holiday-promo').fadeOut(300); + }); + $('#rpg-skip-guided-mode').on('change', function() { extensionSettings.skipInjectionsForGuided = String($(this).val()); saveSettings(); @@ -516,6 +542,25 @@ async function initUI() { toggleAnimations(); }); + // Feature toggle visibility controls + $('#rpg-toggle-show-html-toggle').on('change', function() { + extensionSettings.showHtmlToggle = $(this).prop('checked'); + saveSettings(); + updateFeatureTogglesVisibility(); + }); + + $('#rpg-toggle-show-spotify-toggle').on('change', function() { + extensionSettings.showSpotifyToggle = $(this).prop('checked'); + saveSettings(); + updateFeatureTogglesVisibility(); + }); + + $('#rpg-toggle-show-snowflakes-toggle').on('change', function() { + extensionSettings.showSnowflakesToggle = $(this).prop('checked'); + saveSettings(); + updateFeatureTogglesVisibility(); + }); + // Auto avatar generation settings $('#rpg-toggle-auto-avatars').on('change', function() { extensionSettings.autoGenerateAvatars = $(this).prop('checked'); @@ -710,6 +755,18 @@ async function initUI() { $('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat); $('#rpg-toggle-always-show-bubble').prop('checked', extensionSettings.alwaysShowThoughtBubble); $('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt); + $('#rpg-toggle-spotify-music').prop('checked', extensionSettings.enableSpotifyMusic); + $('#rpg-toggle-snowflakes').prop('checked', extensionSettings.enableSnowflakes); + + // Feature toggle visibility settings + $('#rpg-toggle-show-html-toggle').prop('checked', extensionSettings.showHtmlToggle ?? true); + $('#rpg-toggle-show-spotify-toggle').prop('checked', extensionSettings.showSpotifyToggle ?? true); + $('#rpg-toggle-show-snowflakes-toggle').prop('checked', extensionSettings.showSnowflakesToggle ?? true); + + // Hide holiday promo if previously dismissed + if (extensionSettings.dismissedHolidayPromo) { + $('#rpg-holiday-promo').hide(); + } $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#rpg-toggle-encounters').prop('checked', extensionSettings.encounterSettings?.enabled ?? true); @@ -766,6 +823,7 @@ async function initUI() { applyPanelPosition(); toggleCustomColors(); toggleAnimations(); + updateFeatureTogglesVisibility(); // Setup mobile toggle button setupMobileToggle(); @@ -784,6 +842,7 @@ async function initUI() { renderThoughts(); renderInventory(); renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); updateDiceDisplay(); setupDiceRoller(); setupClassicStatsButtons(); @@ -983,6 +1042,14 @@ jQuery(async () => { // Restore checkpoint state if one exists await restoreCheckpointOnLoad(); + // Initialize snowflakes effect if enabled + try { + initSnowflakes(); + } catch (error) { + console.error('[RPG Companion] Snowflakes initialization failed:', error); + // Non-critical - continue without it + } + console.log('[RPG Companion] ✅ Extension loaded successfully'); } catch (error) { console.error('[RPG Companion] ❌ Critical initialization failure:', error); diff --git a/src/core/config.js b/src/core/config.js index da6d396..c5279c5 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -35,6 +35,8 @@ export const defaultSettings = { showThoughtsInChat: true, // Show thoughts overlay in chat alwaysShowThoughtBubble: false, // Auto-expand thought bubble without clicking icon enableHtmlPrompt: false, // Enable immersive HTML prompt injection + enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) + customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) // Controls when the extension skips injecting tracker instructions/examples/HTML // into generations that appear to be user-injected instructions. Valid values: // - 'none' -> never skip (legacy behavior: always inject) diff --git a/src/core/state.js b/src/core/state.js index 0941ea7..d57b174 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -24,6 +24,13 @@ export let extensionSettings = { narratorMode: false, // Use character card as narrator instead of fixed character references enableHtmlPrompt: false, // Enable immersive HTML prompt injection customHtmlPrompt: '', // Custom HTML prompt text (empty = use default) + enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) + customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) + enableSnowflakes: false, // Enable festive snowflakes effect + dismissedHolidayPromo: false, // User dismissed the holiday promotion banner + showHtmlToggle: true, // Show Immersive HTML toggle in main panel + showSpotifyToggle: true, // Show Spotify Music toggle in main panel + showSnowflakesToggle: true, // Show Snowflakes Effect toggle in main panel skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility) enablePlotButtons: true, // Show plot progression buttons above chat input saveTrackerHistory: false, // Save tracker data in chat history for each message @@ -283,6 +290,7 @@ export let $infoBoxContainer = null; export let $thoughtsContainer = null; export let $inventoryContainer = null; export let $questsContainer = null; +export let $musicPlayerContainer = null; /** * State setters - provide controlled mutation of state variables @@ -354,3 +362,7 @@ export function setInventoryContainer($element) { export function setQuestsContainer($element) { $questsContainer = $element; } + +export function setMusicPlayerContainer($element) { + $musicPlayerContainer = $element; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 3e26bcd..cce66ce 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -39,6 +39,9 @@ "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first", "template.settingsModal.display.enableAnimations": "Enable Animations", "template.settingsModal.display.enableAnimationsNote": "Smooth transitions for stats, content updates, and dice rolls", + "template.settingsModal.display.showImmersiveHtmlToggle": "Show Immersive HTML", + "template.settingsModal.display.showSpotifyMusicToggle": "Show Spotify Music", + "template.settingsModal.display.showSnowflakesToggle": "Show Snowflakes Effect", "template.settingsModal.display.showPlotProgressionButtons": "Show Plot Progression Buttons", "template.settingsModal.display.showPlotProgressionButtonsNote": "Display buttons above chat input for plot progression prompts", "template.settingsModal.display.showDiceDisplay": "Show Dice Roll Display", @@ -126,7 +129,9 @@ "template.mainPanel.title": "RPG Companion", "template.mainPanel.lastRoll": "Last Roll:", "template.mainPanel.clearLastRoll": "Clear last roll", - "template.mainPanel.enableImmersiveHtml": "Enable Immersive HTML", + "template.mainPanel.immersiveHtml": "Immersive HTML", + "template.mainPanel.spotifyMusic": "Spotify Music", + "template.mainPanel.snowflakesEffect": "Snowflakes Effect", "template.mainPanel.refreshRpgInfo": "Refresh RPG Info", "template.mainPanel.updating": "Updating...", "template.mainPanel.editTrackersButton": "Edit Trackers", @@ -183,5 +188,7 @@ "checkpoint.setChapterStart": "Set Chapter Start", "checkpoint.clearChapterStart": "Clear Chapter Start", "checkpoint.indicator": "Chapter Start", - "checkpoint.tooltip": "Messages before this point are excluded from context" + "checkpoint.tooltip": "Messages before this point are excluded from context", + "musicPlayer.title": "Scene Music", + "musicPlayer.noMusic": "AI will suggest music when appropriate for the scene" } diff --git a/src/i18n/zh-tw.json b/src/i18n/zh-tw.json index f95025f..205c105 100644 --- a/src/i18n/zh-tw.json +++ b/src/i18n/zh-tw.json @@ -1,49 +1,52 @@ -{ - "settings.language.label": "語言", - "settings.language.option.en": "English", - "settings.language.option.zh-tw": "繁體中文", - "settings.extensionEnabled": "啟用 RPG Companion", - "settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。", - "template.settingsTitle": "RPG Companion 設定", - "template.settingsModal.themeTitle": "主題", - "template.settingsModal.themeLabel": "可選主題:", - "template.settingsModal.themeOptions.default": "預設", - "template.settingsModal.themeOptions.sciFi": "科幻 (合成波)", - "template.settingsModal.themeOptions.fantasy": "奇幻 (古樸羊皮紙)", - "template.settingsModal.themeOptions.cyberpunk": "賽博朋克 (霓虹網格)", - "template.settingsModal.themeOptions.custom": "自訂", - "template.settingsModal.themeOptions.custom.background": "背景:", - "template.settingsModal.themeOptions.custom.accent": "強調色:", - "template.settingsModal.themeOptions.custom.text": "文字:", - "template.settingsModal.themeOptions.custom.highlight": "高亮:", - "template.settingsModal.theme.statBarLow": "屬性條顏色 (低):", - "template.settingsModal.theme.statBarLowNote": "屬性在 0% 時的顏色", - "template.settingsModal.theme.statBarHigh": "屬性條顏色 (高):", - "template.settingsModal.theme.statBarHighNote": "屬性在 100% 時的顏色", - "template.settingsModal.displayTitle": "顯示設定", - "template.settingsModal.displayNote": "使用擴充功能標籤來啟用/停用 RPG Companion 擴充功能。", - "template.settingsModal.display.panelPosition": "面板位置:", - "template.settingsModal.display.panelPositionOptions.right": "右側邊欄", - "template.settingsModal.display.panelPositionOptions.left": "左側邊欄", - "template.settingsModal.display.toggleAutoUpdate": "訊息後自動更新", - "template.settingsModal.display.showUserStats": "顯示 user 屬性", - "template.settingsModal.display.showInfoBox": "顯示資訊框", - "template.settingsModal.display.showPresentCharacters": "顯示在場角色", - "template.settingsModal.display.showInventory": "顯示物品欄", - "template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法", - "template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡", - "template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡", - "template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡", - "template.settingsModal.display.enableAnimations": "啟用動畫", - "template.settingsModal.display.enableAnimationsNote": "屬性、內容更新和擲骰的動畫效果", - "template.settingsModal.display.showPlotProgressionButtons": "顯示劇情推進按鈕(QR)", - "template.settingsModal.display.showPlotProgressionButtonsNote": "在聊天輸入框上方顯示劇情推進提示按鈕(QR)", - "template.settingsModal.display.enableDebugMode": "Debug Mode", - "template.settingsModal.display.enableDebugModeNote": "UI 面板中顯示日誌,對於故障排除很有用。", - "template.settingsModal.advancedTitle": "進階", - "template.settingsModal.advanced.generationMode": "生成模式:", - "template.settingsModal.advanced.generationModeOptions.together": "同時生成", - "template.settingsModal.advanced.generationModeOptions.separate": "單獨生成", +{ + "settings.language.label": "語言", + "settings.language.option.en": "English", + "settings.language.option.zh-tw": "繁體中文", + "settings.extensionEnabled": "啟用 RPG Companion", + "settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。", + "template.settingsTitle": "RPG Companion 設定", + "template.settingsModal.themeTitle": "主題", + "template.settingsModal.themeLabel": "可選主題:", + "template.settingsModal.themeOptions.default": "預設", + "template.settingsModal.themeOptions.sciFi": "科幻 (合成波)", + "template.settingsModal.themeOptions.fantasy": "奇幻 (古樸羊皮紙)", + "template.settingsModal.themeOptions.cyberpunk": "賽博朋克 (霓虹網格)", + "template.settingsModal.themeOptions.custom": "自訂", + "template.settingsModal.themeOptions.custom.background": "背景:", + "template.settingsModal.themeOptions.custom.accent": "強調色:", + "template.settingsModal.themeOptions.custom.text": "文字:", + "template.settingsModal.themeOptions.custom.highlight": "高亮:", + "template.settingsModal.theme.statBarLow": "屬性條顏色 (低):", + "template.settingsModal.theme.statBarLowNote": "屬性在 0% 時的顏色", + "template.settingsModal.theme.statBarHigh": "屬性條顏色 (高):", + "template.settingsModal.theme.statBarHighNote": "屬性在 100% 時的顏色", + "template.settingsModal.displayTitle": "顯示設定", + "template.settingsModal.displayNote": "使用擴充功能標籤來啟用/停用 RPG Companion 擴充功能。", + "template.settingsModal.display.panelPosition": "面板位置:", + "template.settingsModal.display.panelPositionOptions.right": "右側邊欄", + "template.settingsModal.display.panelPositionOptions.left": "左側邊欄", + "template.settingsModal.display.toggleAutoUpdate": "訊息後自動更新", + "template.settingsModal.display.showUserStats": "顯示 user 屬性", + "template.settingsModal.display.showInfoBox": "顯示資訊框", + "template.settingsModal.display.showPresentCharacters": "顯示在場角色", + "template.settingsModal.display.showInventory": "顯示物品欄", + "template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法", + "template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡", + "template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡", + "template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡", + "template.settingsModal.display.enableAnimations": "啟用動畫", + "template.settingsModal.display.enableAnimationsNote": "屬性、內容更新和擲骰的動畫效果", + "template.settingsModal.display.showImmersiveHtmlToggle": "顯示沉浸式 HTML", + "template.settingsModal.display.showSpotifyMusicToggle": "顯示 Spotify 音樂", + "template.settingsModal.display.showSnowflakesToggle": "顯示雪花效果", + "template.settingsModal.display.showPlotProgressionButtons": "顯示劇情推進按鈕(QR)", + "template.settingsModal.display.showPlotProgressionButtonsNote": "在聊天輸入框上方顯示劇情推進提示按鈕(QR)", + "template.settingsModal.display.enableDebugMode": "Debug Mode", + "template.settingsModal.display.enableDebugModeNote": "UI 面板中顯示日誌,對於故障排除很有用。", + "template.settingsModal.advancedTitle": "進階", + "template.settingsModal.advanced.generationMode": "生成模式:", + "template.settingsModal.advanced.generationModeOptions.together": "同時生成", + "template.settingsModal.advanced.generationModeOptions.separate": "單獨生成", "template.settingsModal.advanced.generationModeNote": "同時生成:將 RPG 追蹤添加到主要提示詞中一同生成。單獨生成:分開生成 RPG 數據。(就是手動或自動的差別)。外部 API:直接連接 OpenAI 兼容端點生成數據。", "template.settingsModal.advanced.generationModeOptions.external": "外部 API", "template.settingsModal.advanced.externalApi.title": "外部 API 設定", @@ -57,120 +60,124 @@ "template.settingsModal.advanced.externalApi.temperature": "溫度 (Temperature)", "template.settingsModal.advanced.externalApi.testConnection": "測試連接", "template.settingsModal.advanced.contextMessages": "上下文訊息:", - "template.settingsModal.advanced.contextMessagesNote": "包含的最近訊息數量(僅限單獨生成模式)", - "template.settingsModal.advanced.memoryBatchSize": "記憶批次大小:", - "template.settingsModal.advanced.memoryBatchSizeNote": "在記憶回憶中每批處理的訊息數量", - "template.settingsModal.advanced.useSeparatePreset": "使用 RPG Companion 追蹤預設模型(設置次要模型)", - "template.settingsModal.advanced.useSeparatePresetNote": "僅限單獨生成模式。啟用後將使用“RPG Companion Trackers”預設中綁定的模型,而不是您的主要 API 模型。生成期間會自動切換預設,之後會恢復原使用預設。請在“RPG Companion Trackers”預設中選擇次要模型,並確保“將預設綁定到 API 連接”切換已開啟(在導入/導出預設按鈕旁邊)。", - "template.settingsModal.advanced.skipInjections": "在引導生成期間跳過注入:", - "template.settingsModal.advanced.skipInjectionsOptions.none": "從不跳過", - "template.settingsModal.advanced.skipInjectionsOptions.impersonation": "僅在模擬請求時跳過", - "template.settingsModal.advanced.skipInjectionsOptions.guided": "始終跳過引導", - "template.settingsModal.advanced.skipInjectionsNote": "當設置後,擴充功能在檢測到引導生成(通過 `instruct` 或 `quiet_prompt`)時,將根據所選模式不注入追蹤提示詞、範例或 HTML 指令。當與 GuidedGenerations 或類似擴充功能一起使用時非常有用。", - "template.settingsModal.advanced.customHtmlPromptTitle": "自訂 HTML 提示詞:", - "template.settingsModal.advanced.restoreDefaultHtmlPrompt": "恢復預設", - "template.settingsModal.advanced.customHtmlPromptNote": "自訂啟用“啟用沉浸式 HTML”時注入的 HTML 提示詞。上方顯示預設提示詞 - 您可以直接編輯或完全替換它。點擊“恢復預設”以重置。這會影響所有生成模式(同時、單獨和劇情推進)。", - "template.settingsModal.advanced.clearCache": "清除擴充功能快取", - "template.settingsModal.advanced.resetFabPositions": "重置按鈕位置", - "template.settingsModal.advanced.resetFabPositionsNote": "將所有浮動操作按鈕(切換、刷新、調試)重置為預設的左上位置。如果按鈕在螢幕外,這會很有用。", - "template.trackerEditorModal.title": "追蹤器編輯", - "template.trackerEditorModal.tabs.userStats": "User 屬性", - "template.trackerEditorModal.tabs.infoBox": "資訊框", - "template.trackerEditorModal.tabs.presentCharacters": "在場角色", - "template.trackerEditorModal.buttons.reset": "重置為預設值", - "template.trackerEditorModal.buttons.cancel": "取消", - "template.trackerEditorModal.buttons.save": "保存並應用", - "template.trackerEditorModal.userStatsTab.customStatsTitle": "自訂屬性", - "template.trackerEditorModal.userStatsTab.addCustomStatButton": "添加自訂屬性", - "template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG 屬性", - "template.trackerEditorModal.userStatsTab.enableRpgAttributes": "啟用 RPG 屬性", - "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始終發送屬性(prompt)", - "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "將 RPG 屬性始終包含在提示詞中,即使它們未顯示在面板上也一樣。", - "template.trackerEditorModal.userStatsTab.addAttributeButton": "添加屬性", - "template.trackerEditorModal.userStatsTab.statusSectionTitle": "狀態欄", - "template.trackerEditorModal.userStatsTab.enableStatusSection": "啟用狀態欄", - "template.trackerEditorModal.userStatsTab.showMoodEmoji": "顯示心情emoji", - "template.trackerEditorModal.userStatsTab.statusFieldsLabel": "狀態欄欄位(以逗號分隔):", - "template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能欄", - "template.trackerEditorModal.userStatsTab.enableSkillsSection": "啟用技能欄", - "template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能欄標籤:", - "template.trackerEditorModal.userStatsTab.skillsListLabel": " 技能列表(以逗號分隔):", - "template.trackerEditorModal.infoBoxTab.widgetsTitle": "小工具", - "template.trackerEditorModal.infoBoxTab.dateWidget": "日期", - "template.trackerEditorModal.infoBoxTab.weatherWidget": "天氣", - "template.trackerEditorModal.infoBoxTab.temperatureWidget": "溫度", - "template.trackerEditorModal.infoBoxTab.timeWidget": "時間", - "template.trackerEditorModal.infoBoxTab.locationWidget": "位置", - "template.trackerEditorModal.infoBoxTab.recentEventsWidget": "近期事件", - "template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "關係狀態", - "template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "定義關係類型,並在角色頭像上顯示對應的表情符號", - "template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "新增關係類型", - "template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "外觀與當前行為舉止", - "template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "角色名稱下方顯示的字段,以 | 分隔。", - "template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "添加自訂字段", - "template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "內心話配置", - "template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "啟用角色內心話", - "template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "內心話標籤:", - "template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "內心話提示詞:", - "template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "角色屬性", - "template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "啟用角色屬性", - "template.trackerEditorModal.presentCharactersTab.characterStatsHint": "建立統計資料以追蹤每個角色(以彩色長條圖顯示)", - "template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "添加角色屬性", - "template.mainPanel.title": "RPG Companion", - "template.mainPanel.lastRoll": "上次擲骰:", - "template.mainPanel.clearLastRoll": "清除上次擲骰", - "template.mainPanel.enableImmersiveHtml": "啟用沉浸式 HTML", - "template.mainPanel.refreshRpgInfo": "刷新資訊", - "template.mainPanel.updating": "更新中...", - "template.mainPanel.editTrackersButton": "追蹤器編輯", - "template.mainPanel.settingsButton": "設定", - "global.none": "None", - "global.add": "添加", - "global.cancel": "取消", - "global.save": "保存", - "global.listView": "清單檢視", - "global.gridView": "格子檢視", - "global.status": "狀態欄", - "global.inventory": "物品欄", - "global.quests": "任務", - "global.info":"資訊", - "infobox.noData.title": "無資訊可顯示", - "infobox.noData.instruction": "在RP中產生新的回复,或在設定中切換到“單獨生成”,然後點擊“刷新資訊”按鈕。", - "infobox.recentEvents.title": "近期事件", - "infobox.recentEvents.addEventPlaceholder": "添加事件...", - "inventory.section.onPerson": "隨身物品", - "inventory.section.stored": "倉庫物品", - "inventory.section.assets": "資產", - "inventory.onPerson.empty": "這裡什麼都沒有 (⚲□⚲)", - "inventory.onPerson.title": "攜帶的物品", - "inventory.onPerson.addItemButton": "添加物品", - "inventory.onPerson.addItemPlaceholder": "輸入物品名稱...", - "inventory.stored.title": "倉庫位置", - "inventory.stored.addLocationButton": "添加倉庫", - "inventory.stored.addLocationPlaceholder": "輸入倉庫名稱...", - "inventory.stored.saveButton": "保存", - "inventory.stored.empty": "沒有倉庫 (⚲□⚲), 點擊\"添加倉庫\"來新增一個倉庫", - "inventory.stored.noItems": "這個倉庫是空的 (⚲□⚲)", - "inventory.stored.addItemToLocationPlaceholder": "輸入物品名稱...", - "inventory.stored.addItemButton": "添加物品", - "inventory.stored.confirmRemoveLocationMessage": "確定要刪除這個倉庫嗎?這將移除所有其中的物品。", - "inventory.stored.confirmRemoveLocationConfirmButton": "刪除", - "inventory.assets.empty": "沒有資產 (⚲□⚲) 好窮", - "inventory.assets.title": "車輛、房產及主要財產", - "inventory.assets.addAssetModalTitle": "添加資產", - "inventory.assets.addAssetButton": "添加資產", - "inventory.assets.addAssetPlaceholder": "輸入資產名稱...", - "inventory.assets.description": "資產包括車輛(汽車、摩托車)、房產(房屋、公寓)和主要設備(車間工具、特殊物品)。", - "quests.section.main": "主線任務", - "quests.section.optional": "支線任務", - "quests.main.title": "主線任務", - "quests.main.addQuestButton": "添加主要任務", - "quests.main.addQuestPlaceholder": "輸入主線任務名稱...", - "quests.main.empty": "當前無主要任務 (ฅ˙Ⱉ˙ฅ)", - "quests.main.hint": "主線任務代表你在故事中的主要目標。", - "quests.optional.title": "支線任務", - "quests.optional.addQuestButton": "添加支線任務", - "quests.optional.addQuestPlaceholder": "輸入支線任務名稱...", - "quests.optional.empty": "當前無支線任務 (ʘ̆ʚʘ̆)", - "quests.optional.hint": "支線任務是補充主線劇情的支線目標。" -} \ No newline at end of file + "template.settingsModal.advanced.contextMessagesNote": "包含的最近訊息數量(僅限單獨生成模式)", + "template.settingsModal.advanced.memoryBatchSize": "記憶批次大小:", + "template.settingsModal.advanced.memoryBatchSizeNote": "在記憶回憶中每批處理的訊息數量", + "template.settingsModal.advanced.useSeparatePreset": "使用 RPG Companion 追蹤預設模型(設置次要模型)", + "template.settingsModal.advanced.useSeparatePresetNote": "僅限單獨生成模式。啟用後將使用“RPG Companion Trackers”預設中綁定的模型,而不是您的主要 API 模型。生成期間會自動切換預設,之後會恢復原使用預設。請在“RPG Companion Trackers”預設中選擇次要模型,並確保“將預設綁定到 API 連接”切換已開啟(在導入/導出預設按鈕旁邊)。", + "template.settingsModal.advanced.skipInjections": "在引導生成期間跳過注入:", + "template.settingsModal.advanced.skipInjectionsOptions.none": "從不跳過", + "template.settingsModal.advanced.skipInjectionsOptions.impersonation": "僅在模擬請求時跳過", + "template.settingsModal.advanced.skipInjectionsOptions.guided": "始終跳過引導", + "template.settingsModal.advanced.skipInjectionsNote": "當設置後,擴充功能在檢測到引導生成(通過 `instruct` 或 `quiet_prompt`)時,將根據所選模式不注入追蹤提示詞、範例或 HTML 指令。當與 GuidedGenerations 或類似擴充功能一起使用時非常有用。", + "template.settingsModal.advanced.customHtmlPromptTitle": "自訂 HTML 提示詞:", + "template.settingsModal.advanced.restoreDefaultHtmlPrompt": "恢復預設", + "template.settingsModal.advanced.customHtmlPromptNote": "自訂啟用“啟用沉浸式 HTML”時注入的 HTML 提示詞。上方顯示預設提示詞 - 您可以直接編輯或完全替換它。點擊“恢復預設”以重置。這會影響所有生成模式(同時、單獨和劇情推進)。", + "template.settingsModal.advanced.clearCache": "清除擴充功能快取", + "template.settingsModal.advanced.resetFabPositions": "重置按鈕位置", + "template.settingsModal.advanced.resetFabPositionsNote": "將所有浮動操作按鈕(切換、刷新、調試)重置為預設的左上位置。如果按鈕在螢幕外,這會很有用。", + "template.trackerEditorModal.title": "追蹤器編輯", + "template.trackerEditorModal.tabs.userStats": "User 屬性", + "template.trackerEditorModal.tabs.infoBox": "資訊框", + "template.trackerEditorModal.tabs.presentCharacters": "在場角色", + "template.trackerEditorModal.buttons.reset": "重置為預設值", + "template.trackerEditorModal.buttons.cancel": "取消", + "template.trackerEditorModal.buttons.save": "保存並應用", + "template.trackerEditorModal.userStatsTab.customStatsTitle": "自訂屬性", + "template.trackerEditorModal.userStatsTab.addCustomStatButton": "添加自訂屬性", + "template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG 屬性", + "template.trackerEditorModal.userStatsTab.enableRpgAttributes": "啟用 RPG 屬性", + "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始終發送屬性(prompt)", + "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "將 RPG 屬性始終包含在提示詞中,即使它們未顯示在面板上也一樣。", + "template.trackerEditorModal.userStatsTab.addAttributeButton": "添加屬性", + "template.trackerEditorModal.userStatsTab.statusSectionTitle": "狀態欄", + "template.trackerEditorModal.userStatsTab.enableStatusSection": "啟用狀態欄", + "template.trackerEditorModal.userStatsTab.showMoodEmoji": "顯示心情emoji", + "template.trackerEditorModal.userStatsTab.statusFieldsLabel": "狀態欄欄位(以逗號分隔):", + "template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能欄", + "template.trackerEditorModal.userStatsTab.enableSkillsSection": "啟用技能欄", + "template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能欄標籤:", + "template.trackerEditorModal.userStatsTab.skillsListLabel": " 技能列表(以逗號分隔):", + "template.trackerEditorModal.infoBoxTab.widgetsTitle": "小工具", + "template.trackerEditorModal.infoBoxTab.dateWidget": "日期", + "template.trackerEditorModal.infoBoxTab.weatherWidget": "天氣", + "template.trackerEditorModal.infoBoxTab.temperatureWidget": "溫度", + "template.trackerEditorModal.infoBoxTab.timeWidget": "時間", + "template.trackerEditorModal.infoBoxTab.locationWidget": "位置", + "template.trackerEditorModal.infoBoxTab.recentEventsWidget": "近期事件", + "template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "關係狀態", + "template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "定義關係類型,並在角色頭像上顯示對應的表情符號", + "template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "新增關係類型", + "template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "外觀與當前行為舉止", + "template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "角色名稱下方顯示的字段,以 | 分隔。", + "template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "添加自訂字段", + "template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "內心話配置", + "template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "啟用角色內心話", + "template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "內心話標籤:", + "template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "內心話提示詞:", + "template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "角色屬性", + "template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "啟用角色屬性", + "template.trackerEditorModal.presentCharactersTab.characterStatsHint": "建立統計資料以追蹤每個角色(以彩色長條圖顯示)", + "template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "添加角色屬性", + "template.mainPanel.title": "RPG Companion", + "template.mainPanel.lastRoll": "上次擲骰:", + "template.mainPanel.clearLastRoll": "清除上次擲骰", + "template.mainPanel.immersiveHtml": "沉浸式 HTML", + "template.mainPanel.spotifyMusic": "Spotify 音樂", + "template.mainPanel.snowflakesEffect": "雪花效果", + "template.mainPanel.refreshRpgInfo": "刷新資訊", + "template.mainPanel.updating": "更新中...", + "template.mainPanel.editTrackersButton": "追蹤器編輯", + "template.mainPanel.settingsButton": "設定", + "global.none": "None", + "global.add": "添加", + "global.cancel": "取消", + "global.save": "保存", + "global.listView": "清單檢視", + "global.gridView": "格子檢視", + "global.status": "狀態欄", + "global.inventory": "物品欄", + "global.quests": "任務", + "global.info":"資訊", + "infobox.noData.title": "無資訊可顯示", + "infobox.noData.instruction": "在RP中產生新的回复,或在設定中切換到“單獨生成”,然後點擊“刷新資訊”按鈕。", + "infobox.recentEvents.title": "近期事件", + "infobox.recentEvents.addEventPlaceholder": "添加事件...", + "inventory.section.onPerson": "隨身物品", + "inventory.section.stored": "倉庫物品", + "inventory.section.assets": "資產", + "inventory.onPerson.empty": "這裡什麼都沒有 (⚲□⚲)", + "inventory.onPerson.title": "攜帶的物品", + "inventory.onPerson.addItemButton": "添加物品", + "inventory.onPerson.addItemPlaceholder": "輸入物品名稱...", + "inventory.stored.title": "倉庫位置", + "inventory.stored.addLocationButton": "添加倉庫", + "inventory.stored.addLocationPlaceholder": "輸入倉庫名稱...", + "inventory.stored.saveButton": "保存", + "inventory.stored.empty": "沒有倉庫 (⚲□⚲), 點擊\"添加倉庫\"來新增一個倉庫", + "inventory.stored.noItems": "這個倉庫是空的 (⚲□⚲)", + "inventory.stored.addItemToLocationPlaceholder": "輸入物品名稱...", + "inventory.stored.addItemButton": "添加物品", + "inventory.stored.confirmRemoveLocationMessage": "確定要刪除這個倉庫嗎?這將移除所有其中的物品。", + "inventory.stored.confirmRemoveLocationConfirmButton": "刪除", + "inventory.assets.empty": "沒有資產 (⚲□⚲) 好窮", + "inventory.assets.title": "車輛、房產及主要財產", + "inventory.assets.addAssetModalTitle": "添加資產", + "inventory.assets.addAssetButton": "添加資產", + "inventory.assets.addAssetPlaceholder": "輸入資產名稱...", + "inventory.assets.description": "資產包括車輛(汽車、摩托車)、房產(房屋、公寓)和主要設備(車間工具、特殊物品)。", + "quests.section.main": "主線任務", + "quests.section.optional": "支線任務", + "quests.main.title": "主線任務", + "quests.main.addQuestButton": "添加主要任務", + "quests.main.addQuestPlaceholder": "輸入主線任務名稱...", + "quests.main.empty": "當前無主要任務 (ฅ˙Ⱉ˙ฅ)", + "quests.main.hint": "主線任務代表你在故事中的主要目標。", + "quests.optional.title": "支線任務", + "quests.optional.addQuestButton": "添加支線任務", + "quests.optional.addQuestPlaceholder": "輸入支線任務名稱...", + "quests.optional.empty": "當前無支線任務 (ʘ̆ʚʘ̆)", + "quests.optional.hint": "支線任務是補充主線劇情的支線目標。", + "musicPlayer.title": "場景音樂", + "musicPlayer.noMusic": "AI 會在適當時為場景建議音樂" +} diff --git a/src/systems/features/musicPlayer.js b/src/systems/features/musicPlayer.js new file mode 100644 index 0000000..3eac421 --- /dev/null +++ b/src/systems/features/musicPlayer.js @@ -0,0 +1,79 @@ +/** + * Music Player Module + * Handles parsing and storing Spotify URLs from AI responses + */ + +import { extensionSettings, committedTrackerData } from '../../core/state.js'; + +/** + * Extracts song suggestion from AI response in format + * @param {string} responseText - The raw AI response text + * @returns {Object|null} Object with {song, artist, searchQuery} or null if not found + */ +export function extractSpotifyUrl(responseText) { + if (!responseText || !extensionSettings.enableSpotifyMusic) return null; + + // Match format + const songMatch = responseText.match(/-]+)\s*-\s*([^<>\/]+)\/>/i); + if (songMatch) { + const song = songMatch[1].trim(); + const artist = songMatch[2].trim(); + const searchQuery = `${song} ${artist}`; + return { + song, + artist, + searchQuery, + displayText: `${song} - ${artist}` + }; + } + + return null; +} + +/** + * Converts song data to Spotify app protocol URL + * @param {Object} songData - Object with {song, artist, searchQuery} + * @returns {string} Spotify app protocol URL + */ +export function convertToEmbedUrl(songData) { + if (!songData || !songData.searchQuery) return ''; + + // Use Spotify app protocol for direct app opening + const encodedQuery = encodeURIComponent(songData.searchQuery); + return `spotify:search:${encodedQuery}`; +} + +/** + * Parses AI response for song suggestion and stores it + * @param {string} responseText - The raw AI response text + * @returns {boolean} True if song was found and stored + */ +export function parseAndStoreSpotifyUrl(responseText) { + if (!extensionSettings.enableSpotifyMusic) return false; + + const songData = extractSpotifyUrl(responseText); + console.log('[RPG Companion] Spotify Parser: Found song:', songData); + if (songData) { + // Store in committed tracker data + committedTrackerData.spotifyUrl = songData; + console.log('[RPG Companion] Spotify Parser: Stored song in committedTrackerData:', committedTrackerData.spotifyUrl); + return true; + } + + return false; +} + +/** + * Gets the current song data from committed tracker data + * @returns {Object|null} Current song data or null + */ +export function getCurrentSpotifyUrl() { + return committedTrackerData.spotifyUrl || null; +} + +/** + * Clears the current song data + */ +export function clearSpotifyUrl() { + committedTrackerData.spotifyUrl = null; +} diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 34254ac..9c32f0f 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -12,18 +12,21 @@ import { isGenerating, lastActionWasSwipe, setIsGenerating, - setLastActionWasSwipe + setLastActionWasSwipe, + $musicPlayerContainer } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; import { parseResponse, parseUserStats } from './parser.js'; +import { parseAndStoreSpotifyUrl } from '../features/musicPlayer.js'; import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; import { renderQuests } from '../rendering/quests.js'; +import { renderMusicPlayer } from '../rendering/musicPlayer.js'; import { i18n } from '../../core/i18n.js'; import { generateAvatarsForCharacters } from '../features/avatarGenerator.js'; @@ -121,7 +124,7 @@ export async function testExternalAPIConnection() { if (!baseUrl || !apiKey || !model) { return { success: false, - message: !apiKey + message: !apiKey ? 'API Key not found. Please re-enter it in settings (keys are stored locally per-browser).' : 'Please fill in all required fields (Base URL, API Key, and Model)' }; @@ -267,6 +270,8 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough if (response) { // console.log('[RPG Companion] Raw AI response:', response); const parsedData = parseResponse(response); + // Parse and store Spotify URL if feature is enabled + parseAndStoreSpotifyUrl(response); // console.log('[RPG Companion] Parsed data:', parsedData); // console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null'); @@ -346,6 +351,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough renderThoughts(); renderInventory(); renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); // Save to chat metadata saveChatData(); @@ -356,7 +362,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough const charactersNeedingAvatars = parseCharactersFromThoughts(parsedData.characterThoughts); if (charactersNeedingAvatars.length > 0) { console.log('[RPG Companion] Generating avatars for:', charactersNeedingAvatars); - + // Generate avatars - this awaits completion await generateAvatarsForCharacters(charactersNeedingAvatars, (names) => { // Callback when generation starts - re-render to show loading spinners diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js index 637d0e7..3cbebf7 100644 --- a/src/systems/generation/injector.js +++ b/src/systems/generation/injector.js @@ -19,7 +19,9 @@ import { generateTrackerExample, generateTrackerInstructions, generateContextualSummary, - DEFAULT_HTML_PROMPT + DEFAULT_HTML_PROMPT, + DEFAULT_SPOTIFY_PROMPT, + SPOTIFY_FORMAT_INSTRUCTION } from './promptBuilder.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; @@ -67,6 +69,7 @@ export async function onGenerationStarted(type, data) { setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); + setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); } @@ -230,6 +233,19 @@ export async function onGenerationStarted(type, data) { // Clear HTML prompt if disabled setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); } + + // Inject Spotify prompt separately at depth 0 if enabled + if (extensionSettings.enableSpotifyMusic && !shouldSuppress) { + // Use custom Spotify prompt if set, otherwise use default + const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT; + const spotifyPrompt = `\n${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}`; + + setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false); + // console.log('[RPG Companion] Injected Spotify prompt at depth 0 for together mode'); + } else { + // Clear Spotify prompt if disabled + setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false); + } } else if (extensionSettings.generationMode === 'separate') { // In SEPARATE mode, inject the contextual summary for main roleplay generation const contextSummary = generateContextualSummary(); @@ -266,6 +282,19 @@ Ensure these details naturally reflect and influence the narrative. Character be setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); } + // Inject Spotify prompt separately at depth 0 if enabled + if (extensionSettings.enableSpotifyMusic && !shouldSuppress) { + // Use custom Spotify prompt if set, otherwise use default + const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT; + const spotifyPrompt = `\n${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}`; + + setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false); + // console.log('[RPG Companion] Injected Spotify prompt at depth 0 for separate mode'); + } else { + // Clear Spotify prompt if disabled + setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false); + } + // Clear together mode injections setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); @@ -274,5 +303,7 @@ Ensure these details naturally reflect and influence the narrative. Character be setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); + setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); + setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false); } } diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index 26f4671..6fbe468 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -16,6 +16,16 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co */ export const DEFAULT_HTML_PROMPT = `If appropriate, include inline HTML, CSS, and JS segments whenever they enhance visual storytelling (e.g., for in-world screens, posters, books, letters, signs, crests, labels, etc.). Style them to match the setting's theme (e.g., fantasy, sci-fi), keep the text readable, and embed all assets directly (using inline SVGs only with no external scripts, libraries, or fonts). Use these elements freely and naturally within the narrative as characters would encounter them, including animations, 3D effects, pop-ups, dropdowns, websites, and so on. Do not wrap the HTML/CSS/JS in code fences!`; +/** + * Default Spotify music prompt text (customizable by users) + */ +export const DEFAULT_SPOTIFY_PROMPT = `If appropriate for the current scene's mood and atmosphere, suggest a song that fits the ambiance. Choose music that enhances the emotional tone, setting, or action of the scene.`; + +/** + * Spotify format instruction (constant, not editable by users) + */ +export const SPOTIFY_FORMAT_INSTRUCTION = `Include it in this exact format: .`; + /** * Gets character card information for current chat (handles both single and group chats) * @returns {string} Formatted character information @@ -445,6 +455,20 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon instructions += htmlPrompt; } + // Append Spotify music prompt if enabled AND includeHtmlPrompt is true + if (extensionSettings.enableSpotifyMusic && includeHtmlPrompt) { + // Add separator + if (hasAnyTrackers || extensionSettings.enableHtmlPrompt) { + instructions += `\n\n`; + } else { + instructions += `\n`; + } + + // Use custom Spotify prompt if set, otherwise use default + const spotifyPrompt = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT; + instructions += spotifyPrompt + ' ' + SPOTIFY_FORMAT_INSTRUCTION; + } + return instructions; } diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 29d56ba..b4171c2 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -16,12 +16,14 @@ import { setLastActionWasSwipe, setIsPlotProgression, updateLastGeneratedData, - updateCommittedTrackerData + updateCommittedTrackerData, + $musicPlayerContainer } from '../../core/state.js'; import { saveChatData, loadChatData } from '../../core/persistence.js'; // Generation & Parsing import { parseResponse, parseUserStats } from '../generation/parser.js'; +import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js'; import { updateRPGData } from '../generation/apiClient.js'; // Rendering @@ -30,6 +32,7 @@ import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; import { renderQuests } from '../rendering/quests.js'; +import { renderMusicPlayer } from '../rendering/musicPlayer.js'; // Utils import { getSafeThumbnailUrl } from '../../utils/avatars.js'; @@ -119,6 +122,8 @@ export async function onMessageReceived(data) { // console.log('[RPG Companion] Parsing together mode response:', responseText); const parsedData = parseResponse(responseText); + // Parse and store Spotify URL if feature is enabled + parseAndStoreSpotifyUrl(responseText); // console.log('[RPG Companion] Parsed data:', parsedData); // Update stored data @@ -176,6 +181,7 @@ export async function onMessageReceived(data) { cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n'); } // Note: XML tags are automatically hidden by SillyTavern + // Note: tags are also automatically hidden by SillyTavern // Update the message in chat history lastMessage.mes = cleanedMessage.trim(); @@ -191,6 +197,7 @@ export async function onMessageReceived(data) { renderThoughts(); renderInventory(); renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); // Then update the DOM to reflect the cleaned message // Using updateMessageBlock to perform macro substitutions + regex formatting @@ -202,11 +209,28 @@ export async function onMessageReceived(data) { // Save to chat metadata saveChatData(); } - } else if (extensionSettings.generationMode === 'separate' && extensionSettings.autoUpdate) { - // In separate mode with auto-update, trigger update after message - setTimeout(async () => { - await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); - }, 500); + } else if (extensionSettings.generationMode === 'separate') { + // In separate mode, also parse Spotify URLs from the main roleplay response + const lastMessage = chat[chat.length - 1]; + if (lastMessage && !lastMessage.is_user) { + const responseText = lastMessage.mes; + + // Parse and store Spotify URL + const foundSpotifyUrl = parseAndStoreSpotifyUrl(responseText); + + // No need to clean message - SillyTavern auto-hides tags + if (foundSpotifyUrl && extensionSettings.enableSpotifyMusic) { + // Just render the music player + renderMusicPlayer($musicPlayerContainer[0]); + } + } + + // Trigger auto-update if enabled + if (extensionSettings.autoUpdate) { + setTimeout(async () => { + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + }, 500); + } } // Reset the swipe flag after generation completes @@ -253,6 +277,7 @@ export function onCharacterChanged() { renderThoughts(); renderInventory(); renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); // Update chat thought overlays updateChatThoughts(); @@ -328,6 +353,7 @@ export function onMessageSwiped(messageIndex) { renderThoughts(); renderInventory(); renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); // Update chat thought overlays updateChatThoughts(); diff --git a/src/systems/rendering/musicPlayer.js b/src/systems/rendering/musicPlayer.js new file mode 100644 index 0000000..49b2546 --- /dev/null +++ b/src/systems/rendering/musicPlayer.js @@ -0,0 +1,150 @@ +/** + * Music Player Rendering Module + * Handles UI rendering for Spotify music player widget + */ + +import { extensionSettings, committedTrackerData } from '../../core/state.js'; +import { i18n } from '../../core/i18n.js'; + +/** + * Creates a Spotify deep link URL that opens the Spotify app + * Uses spotify:search: protocol for app, falls back to web URL + * @param {Object} songData - Object with {song, artist, searchQuery} + * @returns {Object} Object with appUrl and webUrl + */ +function createSpotifyUrls(songData) { + if (!songData || !songData.searchQuery) { + return { appUrl: '', webUrl: '' }; + } + + const encodedQuery = encodeURIComponent(songData.searchQuery); + + return { + // Spotify app protocol - opens directly in Spotify app on desktop/mobile + appUrl: `spotify:search:${encodedQuery}`, + // Web fallback - opens Spotify web player search + webUrl: `https://open.spotify.com/search/${encodedQuery}` + }; +} + +/** + * Opens Spotify with the given song + * Tries app protocol first, falls back to web + * @param {Object} songData - Song data object + */ +function openInSpotify(songData) { + const urls = createSpotifyUrls(songData); + + // Try to open in Spotify app first + // On mobile, this will open the Spotify app if installed + // On desktop, this will open Spotify desktop app if installed + window.location.href = urls.appUrl; + + // Fallback: If app doesn't open within 2 seconds, open web version + // This handles cases where Spotify app isn't installed + setTimeout(() => { + // Check if we're still on the same page (app didn't open) + // Note: This is a best-effort fallback + if (document.hasFocus()) { + window.open(urls.webUrl, '_blank'); + } + }, 1500); +} + +/** + * Renders the Spotify music player as a mini player widget above chat input + * @param {HTMLElement} container - Container element to render into + */ +export function renderMusicPlayer(container) { + console.log('[RPG Companion] Music Player: renderMusicPlayer called'); + + // Remove old chat-attached player if it exists + $('#rpg-chat-music-player').remove(); + + console.log('[RPG Companion] Music Player: enableSpotifyMusic =', extensionSettings.enableSpotifyMusic); + + if (!extensionSettings.enableSpotifyMusic) { + console.warn('[RPG Companion] Music Player: Spotify music is disabled'); + return; + } + + const songData = committedTrackerData.spotifyUrl; + console.log('[RPG Companion] Music Player: Rendering with song:', songData); + + if (!songData || !songData.displayText) { + // No song - don't show anything + return; + } + + // Create the mini music player widget + const musicPlayerHtml = ` +
+
+
+ +
+
+
${songData.song}
+
${songData.artist}
+
+ + +
+
+ `; + + // Find the chat form container and insert widget before (above) it + const $chatForm = $('#send_form'); + + console.log('[RPG Companion] Music Player: Found #send_form:', $chatForm.length > 0); + + if ($chatForm.length === 0) { + console.error('[RPG Companion] Music Player: Could not find #send_form - cannot render widget!'); + return; + } + + // Insert widget inside (at top of) the chat form + console.log('[RPG Companion] Music Player: Prepending widget to #send_form'); + $chatForm.prepend(musicPlayerHtml); + + console.log('[RPG Companion] Music Player: Widget inserted, checking if visible...'); + const $widget = $('#rpg-chat-music-player'); + console.log('[RPG Companion] Music Player: Widget exists:', $widget.length > 0); + if ($widget.length > 0) { + console.log('[RPG Companion] Music Player: Widget position:', $widget.offset()); + console.log('[RPG Companion] Music Player: Widget dimensions:', { width: $widget.width(), height: $widget.height() }); + console.log('[RPG Companion] Music Player: Widget CSS display:', $widget.css('display')); + console.log('[RPG Companion] Music Player: Widget CSS visibility:', $widget.css('visibility')); + } + + // Bind play button click + $('#rpg-chat-music-player .rpg-music-widget-play').on('click', function(e) { + e.stopPropagation(); + openInSpotify(songData); + }); + + // Bind close button click + $('#rpg-chat-music-player .rpg-music-widget-close').on('click', function(e) { + e.stopPropagation(); + $('#rpg-chat-music-player').fadeOut(200, function() { + $(this).remove(); + }); + }); + + // Clicking anywhere else on the widget also opens Spotify + $('#rpg-chat-music-player .rpg-music-widget-content').on('click', function() { + openInSpotify(songData); + }); +} + +/** + * Updates the music player display + * @param {HTMLElement} container - Container element + */ +export function updateMusicPlayer(container) { + renderMusicPlayer(container); +} diff --git a/src/systems/ui/layout.js b/src/systems/ui/layout.js index f3130a2..07ec973 100644 --- a/src/systems/ui/layout.js +++ b/src/systems/ui/layout.js @@ -10,7 +10,8 @@ import { $infoBoxContainer, $thoughtsContainer, $inventoryContainer, - $questsContainer + $questsContainer, + $musicPlayerContainer } from '../../core/state.js'; import { i18n } from '../../core/i18n.js'; @@ -283,10 +284,18 @@ export function updateSectionVisibility() { } } + if ($musicPlayerContainer) { + if (extensionSettings.enableSpotifyMusic) { + $musicPlayerContainer.show(); + } else { + $musicPlayerContainer.hide(); + } + } + // Show/hide dividers intelligently // Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible const showDividerAfterStats = extensionSettings.showUserStats && - (extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests); + (extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic); if (showDividerAfterStats) { $('#rpg-divider-stats').show(); } else { @@ -304,20 +313,28 @@ export function updateSectionVisibility() { // Divider after Thoughts: shown if Thoughts is visible AND at least one section after it is visible const showDividerAfterThoughts = extensionSettings.showCharacterThoughts && - (extensionSettings.showInventory || extensionSettings.showQuests); + (extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic); if (showDividerAfterThoughts) { $('#rpg-divider-thoughts').show(); } else { $('#rpg-divider-thoughts').hide(); } - // Divider after Inventory: shown if Inventory is visible AND Quests is visible - const showDividerAfterInventory = extensionSettings.showInventory && extensionSettings.showQuests; + // Divider after Inventory: shown if Inventory is visible AND (Quests or Music) is visible + const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic); if (showDividerAfterInventory) { $('#rpg-divider-inventory').show(); } else { $('#rpg-divider-inventory').hide(); } + + // Divider after Quests: shown if Quests is visible AND Music is visible + const showDividerAfterQuests = extensionSettings.showQuests && extensionSettings.enableSpotifyMusic; + if (showDividerAfterQuests) { + $('#rpg-divider-quests').show(); + } else { + $('#rpg-divider-quests').hide(); + } } /** diff --git a/src/systems/ui/promptsEditor.js b/src/systems/ui/promptsEditor.js index ea45063..e3a2956 100644 --- a/src/systems/ui/promptsEditor.js +++ b/src/systems/ui/promptsEditor.js @@ -4,7 +4,7 @@ */ import { extensionSettings } from '../../core/state.js'; import { saveSettings } from '../../core/persistence.js'; -import { DEFAULT_HTML_PROMPT } from '../generation/promptBuilder.js'; +import { DEFAULT_HTML_PROMPT, DEFAULT_SPOTIFY_PROMPT } from '../generation/promptBuilder.js'; let $editorModal = null; let tempPrompts = null; // Temporary prompts for cancel functionality @@ -12,6 +12,7 @@ let tempPrompts = null; // Temporary prompts for cancel functionality // Default prompts const DEFAULT_PROMPTS = { html: DEFAULT_HTML_PROMPT, + spotify: DEFAULT_SPOTIFY_PROMPT, plotRandom: 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.', plotNatural: 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.', avatar: `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons, but your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable. @@ -97,6 +98,7 @@ function openPromptsEditor() { // Create temporary copy for cancel functionality tempPrompts = { html: extensionSettings.customHtmlPrompt || '', + spotify: extensionSettings.customSpotifyPrompt || '', plotRandom: extensionSettings.customPlotRandomPrompt || '', plotNatural: extensionSettings.customPlotNaturalPrompt || '', avatar: extensionSettings.avatarLLMCustomInstruction || '', @@ -107,6 +109,7 @@ function openPromptsEditor() { // Load current values or defaults $('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html); + $('#rpg-prompt-spotify').val(extensionSettings.customSpotifyPrompt || DEFAULT_PROMPTS.spotify); $('#rpg-prompt-plot-random').val(extensionSettings.customPlotRandomPrompt || DEFAULT_PROMPTS.plotRandom); $('#rpg-prompt-plot-natural').val(extensionSettings.customPlotNaturalPrompt || DEFAULT_PROMPTS.plotNatural); $('#rpg-prompt-avatar').val(extensionSettings.avatarLLMCustomInstruction || DEFAULT_PROMPTS.avatar); @@ -141,6 +144,7 @@ function closePromptsEditor() { */ function savePrompts() { extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim(); + extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim(); extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim(); extensionSettings.customPlotNaturalPrompt = $('#rpg-prompt-plot-natural').val().trim(); extensionSettings.avatarLLMCustomInstruction = $('#rpg-prompt-avatar').val().trim(); @@ -164,6 +168,9 @@ function restorePromptToDefault(promptType) { case 'html': extensionSettings.customHtmlPrompt = ''; break; + case 'spotify': + extensionSettings.customSpotifyPrompt = ''; + break; case 'plotRandom': extensionSettings.customPlotRandomPrompt = ''; break; @@ -192,6 +199,7 @@ function restorePromptToDefault(promptType) { */ function restoreAllToDefaults() { $('#rpg-prompt-html').val(DEFAULT_PROMPTS.html); + $('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify); $('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom); $('#rpg-prompt-plot-natural').val(DEFAULT_PROMPTS.plotNatural); $('#rpg-prompt-avatar').val(DEFAULT_PROMPTS.avatar); @@ -201,6 +209,7 @@ function restoreAllToDefaults() { // Clear all custom prompts extensionSettings.customHtmlPrompt = ''; + extensionSettings.customSpotifyPrompt = ''; extensionSettings.customPlotRandomPrompt = ''; extensionSettings.customPlotNaturalPrompt = ''; extensionSettings.avatarLLMCustomInstruction = ''; diff --git a/src/systems/ui/snowflakes.js b/src/systems/ui/snowflakes.js new file mode 100644 index 0000000..774e1dc --- /dev/null +++ b/src/systems/ui/snowflakes.js @@ -0,0 +1,78 @@ +/** + * Snowflakes Effect Module + * Creates and manages animated snowflakes overlay + */ + +import { extensionSettings } from '../../core/state.js'; + +let snowflakesContainer = null; + +/** + * Create snowflakes container and snowflakes + */ +function createSnowflakes() { + if (snowflakesContainer) return; // Already created + + // Create container + snowflakesContainer = document.createElement('div'); + snowflakesContainer.className = 'rpg-snowflakes-container'; + + // Create 50 snowflakes with random positions + for (let i = 0; i < 50; i++) { + const snowflake = document.createElement('div'); + snowflake.className = 'rpg-snowflake'; + snowflake.textContent = '❄'; + + // Random horizontal position + snowflake.style.left = `${Math.random() * 100}%`; + + // Random animation delay for staggered effect + snowflake.style.animationDelay = `${Math.random() * 10}s`; + + // Random animation duration (between 10-20s) + snowflake.style.animationDuration = `${10 + Math.random() * 10}s`; + + snowflakesContainer.appendChild(snowflake); + } + + document.body.appendChild(snowflakesContainer); +} + +/** + * Remove snowflakes container + */ +function removeSnowflakes() { + if (snowflakesContainer) { + snowflakesContainer.remove(); + snowflakesContainer = null; + } +} + +/** + * Toggle snowflakes effect + */ +export function toggleSnowflakes(enabled) { + if (enabled) { + createSnowflakes(); + } else { + removeSnowflakes(); + } +} + +/** + * Initialize snowflakes based on saved state + */ +export function initSnowflakes() { + const enabled = extensionSettings.enableSnowflakes || false; + + if (enabled) { + createSnowflakes(); + } +} + +/** + * Clean up snowflakes + */ +export function cleanupSnowflakes() { + removeSnowflakes(); +} diff --git a/src/systems/ui/theme.js b/src/systems/ui/theme.js index 2f2b061..dcb0e6e 100644 --- a/src/systems/ui/theme.js +++ b/src/systems/ui/theme.js @@ -76,6 +76,27 @@ export function toggleAnimations() { } } +/** + * Updates visibility of feature toggles in main panel based on settings + */ +export function updateFeatureTogglesVisibility() { + const $featuresRow = $('#rpg-features-row'); + const $htmlToggle = $('#rpg-html-toggle-wrapper'); + const $spotifyToggle = $('#rpg-spotify-toggle-wrapper'); + const $snowflakesToggle = $('#rpg-snowflakes-toggle-wrapper'); + + // Show/hide individual toggles + $htmlToggle.toggle(extensionSettings.showHtmlToggle); + $spotifyToggle.toggle(extensionSettings.showSpotifyToggle); + $snowflakesToggle.toggle(extensionSettings.showSnowflakesToggle); + + // Hide entire row if all toggles are hidden + const anyVisible = extensionSettings.showHtmlToggle || + extensionSettings.showSpotifyToggle || + extensionSettings.showSnowflakesToggle; + $featuresRow.toggle(anyVisible); +} + /** * Updates the settings popup theme in real-time. * Backwards compatible wrapper for SettingsModal class. diff --git a/style.css b/style.css index ce9e5fa..cb9da62 100644 --- a/style.css +++ b/style.css @@ -1251,7 +1251,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Weather Widget Icon */ .rpg-weather-icon { - font-size: clamp(14px, 2.5vw, 20px); + font-size: clamp(0.875rem, 2.5vw, 1.25rem); filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); flex-shrink: 0; line-height: 1; @@ -1267,12 +1267,9 @@ body:has(.rpg-panel.rpg-position-left) #sheld { opacity: 0.85; line-height: 1.2; word-wrap: break-word; + overflow-wrap: break-word; max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + white-space: normal; flex-shrink: 1; } @@ -5120,19 +5117,19 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-weather-forecast { - font-size: clamp(9px, 2.2vw, 11px) !important; + font-size: clamp(0.5625rem, 2.2vw, 0.6875rem) !important; } .rpg-temp-value { - font-size: clamp(10px, 2.5vw, 13px) !important; + font-size: clamp(0.625rem, 2.5vw, 0.8125rem) !important; } .rpg-time-value { - font-size: clamp(10px, 2.5vw, 13px) !important; + font-size: clamp(0.625rem, 2.5vw, 0.8125rem) !important; } .rpg-location-text { - font-size: clamp(11px, 2.8vw, 14px) !important; + font-size: clamp(0.6875rem, 2.8vw, 0.875rem) !important; } .rpg-map-marker { @@ -8303,3 +8300,374 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 14px; } } + +/* ============================================ + SPOTIFY MUSIC WIDGET STYLES + ============================================ */ + +/* Music Widget Container - Positioned above chat input */ +.rpg-music-widget { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, calc(-100% - 8px)); + width: calc(100% - 40px); + max-width: 650px; + z-index: 1000; + animation: rpg-music-slide-in 0.3s ease-out; +} + +@keyframes rpg-music-slide-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Widget Content - The actual player UI */ +.rpg-music-widget-content { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--rpg-accent); + border-radius: 12px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + border: 2px solid var(--rpg-border); +} + +.rpg-music-widget-content:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3), 0 4px 8px var(--rpg-highlight); +} + +/* Spotify Icon */ +.rpg-music-widget-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + border-radius: 50%; + color: #1DB954; + font-size: 20px; +} + +/* Song Info Container */ +.rpg-music-widget-info { + flex: 1; + min-width: 0; /* Allows text truncation */ + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Song Title */ +.rpg-music-widget-title { + color: var(--rpg-text); + font-size: 14px; + font-weight: 700; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Artist Name */ +.rpg-music-widget-artist { + color: var(--rpg-text); + opacity: 0.7; + font-size: 12px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Play Button */ +.rpg-music-widget-play { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--rpg-highlight); + border: none; + border-radius: 50%; + color: var(--rpg-text); + font-size: 14px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.rpg-music-widget-play:hover { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.rpg-music-widget-play:active { + transform: scale(0.95); +} + +.rpg-music-widget-play i { + margin-left: 2px; /* Visual centering for play icon */ +} + +/* Close/Dismiss Button */ +.rpg-music-widget-close { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + border: none; + border-radius: 50%; + color: var(--rpg-text); + opacity: 0.6; + font-size: 12px; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.rpg-music-widget-close:hover { + background: rgba(0, 0, 0, 0.5); + opacity: 1; +} + +/* Mobile Responsive Styles */ +@media (max-width: 600px) { + .rpg-music-widget-content { + padding: 8px 12px; + gap: 10px; + } + + .rpg-music-widget-icon { + width: 36px; + height: 36px; + font-size: 18px; + } + + .rpg-music-widget-title { + font-size: 13px; + } + + .rpg-music-widget-artist { + font-size: 11px; + } + + .rpg-music-widget-play { + width: 32px; + height: 32px; + font-size: 12px; + } + + .rpg-music-widget-close { + width: 22px; + height: 22px; + font-size: 11px; + } +} + +/* Theme Support - Apply theme colors */ +.rpg-panel[data-theme="sci-fi"] ~ .rpg-music-widget .rpg-music-widget-content, +body[data-theme="sci-fi"] .rpg-music-widget-content { + background: linear-gradient(135deg, #8b00ff 0%, #6a00cc 50%, #5000aa 100%); +} + +.rpg-panel[data-theme="sci-fi"] ~ .rpg-music-widget .rpg-music-widget-play, +body[data-theme="sci-fi"] .rpg-music-widget-play { + color: #8b00ff; +} + +.rpg-panel[data-theme="fantasy"] ~ .rpg-music-widget .rpg-music-widget-content, +body[data-theme="fantasy"] .rpg-music-widget-content { + background: linear-gradient(135deg, #d4af37 0%, #b8962e 50%, #9c7d25 100%); +} + +.rpg-panel[data-theme="fantasy"] ~ .rpg-music-widget .rpg-music-widget-play, +body[data-theme="fantasy"] .rpg-music-widget-play { + color: #d4af37; +} + +.rpg-panel[data-theme="cyberpunk"] ~ .rpg-music-widget .rpg-music-widget-content, +body[data-theme="cyberpunk"] .rpg-music-widget-content { + background: linear-gradient(135deg, #ff2a6d 0%, #d91e58 50%, #b31848 100%); +} + +.rpg-panel[data-theme="cyberpunk"] ~ .rpg-music-widget .rpg-music-widget-play, +body[data-theme="cyberpunk"] .rpg-music-widget-play { + color: #ff2a6d; +} +/* ============================================ + SNOWFLAKES EFFECT + ============================================ */ + +/* Snowflakes container - covers entire viewport */ +.rpg-snowflakes-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + overflow: hidden; +} + +/* Individual snowflake */ +.rpg-snowflake { + position: absolute; + top: -10px; + color: white; + font-size: 1em; + font-family: Arial, sans-serif; + text-shadow: 0 0 5px rgba(255, 255, 255, 0.8); + animation: rpg-snowfall linear infinite; + opacity: 0.8; +} + +/* Snowfall animation */ +@keyframes rpg-snowfall { + 0% { + transform: translateY(0vh) rotate(0deg); + opacity: 0.8; + } + 100% { + transform: translateY(100vh) rotate(360deg); + opacity: 0.2; + } +} + +/* Create variations for different snowflakes */ +.rpg-snowflake:nth-child(2n) { + animation-duration: 12s; + font-size: 0.8em; +} + +.rpg-snowflake:nth-child(3n) { + animation-duration: 15s; + font-size: 1.2em; +} + +.rpg-snowflake:nth-child(4n) { + animation-duration: 18s; + font-size: 0.6em; +} + +.rpg-snowflake:nth-child(5n) { + animation-duration: 10s; + font-size: 1.4em; +} + +/* Slower mobile animation for performance */ +@media (max-width: 768px) { + .rpg-snowflake { + animation-duration: 20s; + } +} + +/* ============================================ + HOLIDAY PROMOTION BANNER + ============================================ */ + +/* Mobile-friendly holiday promo */ +.rpg-holiday-promo { + max-width: 100%; + word-wrap: break-word; +} + +.rpg-holiday-promo a { + display: inline-block; + max-width: 100%; +} + +/* Dismiss button hover effect */ +#rpg-dismiss-promo:hover { + opacity: 1 !important; + transform: scale(1.1); +} + +/* Mobile responsiveness */ +@media (max-width: 600px) { + .rpg-holiday-promo { + font-size: 10px !important; + padding: 10px 8px !important; + } + + #rpg-dismiss-promo { + font-size: 12px !important; + top: 2px !important; + right: 2px !important; + } +} + +/* ============================================ + THREE-COLUMN FEATURE TOGGLES LAYOUT + ============================================ */ + +/* Features row container */ +.rpg-features-row { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +/* Each feature column */ +.rpg-feature-col { + flex: 1; + min-width: 0; +} + +.rpg-feature-col .rpg-toggle-label { + justify-content: center; +} + +/* Always hide text, show only checkbox + icon */ +.rpg-feature-col .rpg-toggle-text { + display: none; +} + +.rpg-feature-col .rpg-toggle-label i { + font-size: 1.125rem; + flex-shrink: 0; +} + +/* Tablet and below: Hide text, show only checkbox + icon */ +@media (max-width: 768px) { + .rpg-feature-col .rpg-toggle-text { + display: none; + } + + .rpg-feature-col .rpg-toggle-label i { + font-size: 1.cally with icons only */ +@media (max-width: 400px) { + .rpg-features-row { + flex-direction: column; + gap: 0.5rem; + } + + .rpg-feature-col .rpg-toggle-label i { + font-size: 1.25rem; + } +} + + + +/* Ensure send_form has relative positioning for music widget placement */ +#send_form { + position: relative !important; +} diff --git a/template.html b/template.html index 7d6fc2a..8ff1c08 100644 --- a/template.html +++ b/template.html @@ -10,7 +10,7 @@

- RPG Companion + RPG Companion

@@ -60,15 +60,44 @@
+ + +
+ + +
+ +
- -
- + +
+ +
+ +
+ + +
+ +
+ + +
+ +
@@ -88,6 +117,15 @@ data-i18n-key="template.mainPanel.settingsButton">Settings
+ + + @@ -240,6 +278,21 @@ Smooth transitions for stats, content updates, and dice rolls + + + + + +