수정코드
<div class="ra0-content"><details class="fold">
<summary>bgm.css</summary>
<div class="fold-content">/* BGM Player Styles */
<br>.bgm-player {
<br> position: fixed;
<br> bottom: var(--spacing-lg);
<br> right: var(--spacing-lg);
<br> width: 280px;
<br> background: var(--bg-glass);
<br> backdrop-filter: blur(16px);
<br> border: 1px solid var(--bg-glass-dark);
<br> border-radius: var(--container-border-radius);
<br> padding: var(--spacing-lg);
<br> box-shadow: var(--shadow-glass);
<br> z-index: 9999;
<br> font-family: var(--f-pre);
<br> transition: all var(--transition-base);
<br> transform: translateY(100px);
<br> opacity: 0;
<br> pointer-events: auto; /* iframe 내부에서도 클릭 가능 */
<br>}
<br>
<br>.bgm-player.show {
<br> transform: translateY(0);
<br> opacity: 1;
<br>}
<br>
<br>/* 최소화 상태 */
<br>.bgm-player.minimized {
<br> width: 30px;
<br> height: 30px;
<br> padding: 0;
<br> border-radius: var(--spacing-sm);
<br> cursor: pointer;
<br> background: var(--bg-glass);
<br> border: 1px solid var(--bg-glass-dark);
<br> display: flex;
<br> align-items: center;
<br> justify-content: center;
<br> transition: all var(--transition-base);
<br>}
<br>
<br>.bgm-player.minimized:hover {
<br> transform: scale(1.05);
<br> box-shadow: var(--shadow-lg);
<br>}
<br>
<br>.bgm-player.minimized .player-content {
<br> display: none;
<br>}
<br>
<br>.bgm-player.minimized::before {
<br> content: '♪';
<br> font-size: 1.25rem;
<br> color: var(--content-font-color);
<br>}
<br>
<br>/* 최소화 상태에서 클릭 가능한 오버레이 */
<br>.minimized-overlay {
<br> position: absolute;
<br> top: 0;
<br> left: 0;
<br> right: 0;
<br> bottom: 0;
<br> border-radius: inherit;
<br> display: none;
<br>}
<br>
<br>.bgm-player.minimized .minimized-overlay {
<br> display: block;
<br>}
<br>
<br>/* 노래 제목 + 토글 버튼 헤더 */
<br>.track-header {
<br> display: flex;
<br> align-items: center;
<br> justify-content: space-between;
<br> gap: var(--spacing-sm);
<br>}
<br>
<br>.track-info {
<br> color: var(--content-font-color);
<br> font-size: 12px;
<br> font-weight: 400;
<br> font-family: var(--f-pre);
<br> overflow: hidden;
<br> text-overflow: ellipsis;
<br> white-space: nowrap;
<br> flex: 1;
<br> line-height: 1.4;
<br>}
<br>
<br>.list-toggle-btn {
<br> background: var(--bg-secondary);
<br> border: 1px solid var(--bg-glass-dark);
<br> color: var(--text-secondary);
<br> padding: var(--spacing-xs) var(--spacing-sm);
<br> border-radius: var(--btn-primary-radius);
<br> cursor: pointer;
<br> font-size: 11px;
<br> font-family: var(--f-pre);
<br> transition: all var(--transition-fast);
<br> display: flex;
<br> align-items: center;
<br> gap: var(--spacing-xxs);
<br> min-width: 20px;
<br> justify-content: center;
<br>}
<br>
<br>.list-toggle-btn:hover {
<br> background: var(--primary-light);
<br> color: var(--primary-color);
<br> border-color: var(--primary-color);
<br> transform: translateY(-1px);
<br>}
<br>
<br>.list-toggle-btn.active {
<br> background: var(--primary-color);
<br> color: var(--white);
<br> border-color: var(--primary-color);
<br>}
<br>
<br>.toggle-icon {
<br> transition: transform var(--transition-fast);
<br> font-size: 10px;
<br>}
<br>
<br>.list-toggle-btn.active .toggle-icon {
<br> transform: rotate(180deg);
<br>}
<br>
<br>/* 플레이리스트 컨테이너 */
<br>.playlist-container {
<br> max-height: 160px;
<br> overflow-y: auto;
<br> margin-top: var(--spacing-md);
<br> border-radius: var(--card-border-radius);
<br> background: var(--bg-glass-dark);
<br> border: 1px solid var(--bg-glass-dark);
<br>}
<br>
<br>.playlist {
<br> list-style: none;
<br> padding: 0;
<br> margin: 0;
<br>}
<br>
<br>.playlist li {
<br> padding: var(--spacing-sm) var(--spacing-md);
<br> color: var(--text-secondary);
<br> font-size: 12px;
<br> font-family: var(--f-pre);
<br> cursor: pointer;
<br> transition: all var(--transition-fast);
<br> overflow: hidden;
<br> text-overflow: ellipsis;
<br> white-space: nowrap;
<br>}
<br>
<br>.playlist li:last-child {
<br> border-bottom: none;
<br>}
<br>
<br>.playlist li:hover {
<br> background: var(--primary-light);
<br> color: var(--primary-color);
<br>}
<br>
<br>.playlist li.current {
<br> background: var(--primary-color);
<br> color: var(--white);
<br> font-weight: 500;
<br>}
<br>
<br>/* 플레이어 컨트롤 */
<br>.player-controls {
<br> display: flex;
<br> justify-content: center;
<br> align-items: center;
<br> gap: var(--spacing-lg);
<br>}
<br>
<br>.bgm-controls {
<br> display: flex;
<br> justify-content: space-between;
<br> align-items: center;
<br> margin-bottom: var(--spacing-md);
<br> padding-right: var(--spacing-sm);
<br>}
<br>
<br>.control-btn {
<br> background: transparent;
<br> border: none;
<br> color: rgb(from var(--primary-color) r g b / 60%);
<br> font-size: 14px;
<br> cursor: pointer;
<br> padding: 0;
<br> transition: all var(--transition-fast);
<br> display: flex;
<br> align-items: center;
<br> justify-content: center;
<br> gap: var(--spacing-sm);
<br>}
<br>
<br>.control-btn:hover:not(:disabled) {
<br> color: rgb(from var(--white) r g b / 30%);
<br> transform: translateY(-2px);
<br>}
<br>
<br>.control-btn.play-pause {
<br> background: transparent;
<br> color: var(--accent-color);
<br> border: none;
<br> font-size: 16px;
<br>}
<br>
<br>.control-btn.play-pause:hover {
<br> transform: translateY(-2px) scale(1.05);
<br>}
<br>
<br>.control-btn:disabled {
<br> opacity: 0.4;
<br> cursor: not-allowed;
<br> transform: none;
<br>}
<br>
<br>.control-btn:disabled:hover {
<br> transform: none;
<br> box-shadow: none;
<br>}
<br>
<br>/* 모드 컨트롤 (셔플/반복) */
<br>.mode-controls {
<br> display: flex;
<br> align-items: center;
<br> gap: var(--spacing-sm);
<br>}
<br>
<br>.control-btn.mode-btn {
<br> font-size: 12px;
<br> opacity: 0.5;
<br> position: relative;
<br>}
<br>
<br>.control-btn.mode-btn:hover {
<br> opacity: 0.8;
<br>}
<br>
<br>.control-btn.mode-btn.active {
<br> opacity: 1;
<br> color: var(--accent-color);
<br>}
<br>
<br>/* 1곡 반복 배지 */
<br>.repeat-one-badge {
<br> position: absolute;
<br> top: -4px;
<br> right: -6px;
<br> font-size: 9px;
<br> font-weight: 700;
<br> color: var(--accent-color);
<br> line-height: 1;
<br>}
<br>
<br>/* 볼륨 컨트롤 */
<br>.volume-control {
<br> position: relative;
<br> display: flex;
<br> align-items: center;
<br>}
<br>
<br>.volume-btn {
<br> font-size: 14px;
<br>}
<br>
<br>.volume-btn.muted i::before {
<br> content: "\f6a9"; /* fa-volume-xmark */
<br>}
<br>
<br>.volume-popup {
<br> position: absolute;
<br> bottom: 100%;
<br> left: 50%;
<br> transform: translateX(-50%);
<br> background: var(--bg-glass);
<br> backdrop-filter: blur(16px);
<br> border: 1px solid var(--bg-glass-dark);
<br> border-radius: var(--card-border-radius);
<br> padding: var(--spacing-md) var(--spacing-sm);
<br> box-shadow: var(--shadow-lg);
<br> display: none;
<br> margin-bottom: var(--spacing-sm);
<br>}
<br>
<br>.volume-popup.show {
<br> display: block;
<br>}
<br>
<br>.volume-slider {
<br> width: 4px;
<br> height: 80px;
<br> background: var(--border-medium);
<br> border-radius: 2px;
<br> outline: none;
<br> -webkit-appearance: slider-vertical;
<br> appearance: slider-vertical;
<br> cursor: pointer;
<br> transition: all var(--transition-fast);
<br> writing-mode: vertical-lr;
<br> direction: rtl;
<br>}
<br>
<br>.volume-slider:hover {
<br> background: var(--primary-light);
<br>}
<br>
<br>.volume-slider::-webkit-slider-thumb {
<br> -webkit-appearance: none;
<br> width: 14px;
<br> height: 14px;
<br> background: var(--primary-color);
<br> border-radius: 50%;
<br> cursor: pointer;
<br> border: 2px solid var(--white);
<br> box-shadow: var(--shadow-sm);
<br> transition: all var(--transition-fast);
<br>}
<br>
<br>.volume-slider::-webkit-slider-thumb:hover {
<br> transform: scale(1.2);
<br> box-shadow: var(--shadow-md);
<br>}
<br>
<br>.volume-slider::-moz-range-thumb {
<br> width: 14px;
<br> height: 14px;
<br> background: var(--primary-color);
<br> border-radius: 50%;
<br> cursor: pointer;
<br> border: 2px solid var(--white);
<br> box-shadow: var(--shadow-sm);
<br>}
<br>
<br>/* 최소화 버튼 */
<br>.minimize-btn {
<br> position: absolute;
<br> top: calc(var(--spacing-sm) * 0.8);
<br> right: calc(var(--spacing-sm) * 0.8);
<br> /* background: rgb(from var(--accent-color) r g b / 70%); */
<br> background: transparent;
<br> background: var(--accent-color);
<br> /* border: 1px solid var(--bg-glass-dark); */
<br> border: none;
<br> color: var(--white);
<br> font-size: 12px;
<br> cursor: pointer;
<br> padding: 0;
<br> border-radius: var(--btn-primary-radius);
<br> transition: all var(--transition-fast);
<br> display: flex;
<br> align-items: center;
<br> justify-content: center;
<br> font-family: var(--f-pre);
<br>}
<br>
<br>.minimize-btn:hover {
<br> background: var(--primary-color);
<br> transform: scale(1.1);
<br>}
<br>
<br>/* 스크롤바 스타일 */
<br>.playlist-container::-webkit-scrollbar {
<br> width: 4px;
<br>}
<br>
<br>.playlist-container::-webkit-scrollbar-track {
<br> background: var(--gray-100);
<br> border-radius: 2px;
<br>}
<br>
<br>.playlist-container::-webkit-scrollbar-thumb {
<br> background: var(--primary-color);
<br> border-radius: 2px;
<br> transition: all var(--transition-fast);
<br>}
<br>
<br>.playlist-container::-webkit-scrollbar-thumb:hover {
<br> background: var(--primary-dark);
<br>}
<br>
<br>/* 애니메이션 */
<br>@keyframes slideInUp {
<br> from {
<br> transform: translateY(100px);
<br> opacity: 0;
<br> }
<br> to {
<br> transform: translateY(0);
<br> opacity: 1;
<br> }
<br>}
<br>
<br>@keyframes pulse {
<br> 0% { transform: scale(1); }
<br> 50% { transform: scale(1.05); }
<br> 100% { transform: scale(1); }
<br>}
<br>
<br>.bgm-player.minimized:hover {
<br> animation: pulse 0.6s ease infinite;
<br>}
<br>
<br>/* 로딩 상태 */
<br>.track-info.loading::after {
<br> content: '';
<br> display: inline-block;
<br> width: 12px;
<br> height: 12px;
<br> margin-left: var(--spacing-xs);
<br> border: 2px solid var(--border-light);
<br> border-radius: 50%;
<br> border-top-color: var(--primary-color);
<br> animation: spin 1s linear infinite;
<br>}
<br>
<br>@keyframes spin {
<br> to {
<br> transform: rotate(360deg);
<br> }
<br>}
<br>
<br>/* 포커스 상태 */
<br>.control-btn:focus,
<br>.list-toggle-btn:focus,
<br>.minimize-btn:focus {
<br> outline: 2px solid var(--primary-color);
<br> outline-offset: 2px;
<br>}
<br>
<br>.volume-slider:focus {
<br> outline: 2px solid var(--primary-color);
<br> outline-offset: 2px;
<br>}
<br>
<br>/* 접근성 개선 */
<br>@media (prefers-reduced-motion: reduce) {
<br> .bgm-player,
<br> .control-btn,
<br> .list-toggle-btn,
<br> .minimize-btn,
<br> .volume-slider::-webkit-slider-thumb,
<br> .toggle-icon {
<br> transition: none;
<br> }
<br>
<br> .bgm-player.minimized:hover {
<br> animation: none;
<br> }
<br>}
<br>
<br>/* 고해상도 디스플레이 대응 */
<br>@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
<br> .bgm-player {
<br> backdrop-filter: blur(20px);
<br> }
<br>} <br></div>
</details><details class="fold">
<summary>bgm.js</summary>
<div class="fold-content">let player;
<br>let playerReady = false;
<br>let isPlaying = false;
<br>let currentVolume = 30;
<br>let isMinimized = false;
<br>let userInteracted = false;
<br>let readyToPlay = false;
<br>let isPlaylistOpen = false;
<br>let playlistData = ☐;
<br>let currentTrackIndex = 0;
<br>
<br>// 셔플/반복 관련 변수
<br>let isShuffleOn = false;
<br>let repeatMode = 'all'; // 'all', 'one', 'none'
<br>let shuffledOrder = ☐; // 셔플된 인덱스 순서
<br>let shuffledIndex = 0; // 셔플 순서에서의 현재 위치
<br>let isVolumeOpen = false;
<br>
<br>// localStorage 키
<br>const BGM_STORAGE_KEY = 'ra0_bgm_settings';
<br>
<br>// localStorage 저장
<br>function saveBGMSettings() {
<br> try {
<br> const settings = {
<br> volume: currentVolume,
<br> shuffle: isShuffleOn,
<br> repeat: repeatMode
<br> };
<br> localStorage.setItem(BGM_STORAGE_KEY, JSON.stringify(settings));
<br> } catch (e) {
<br> }
<br>}
<br>
<br>// localStorage 복원
<br>function loadBGMSettings() {
<br> try {
<br> const saved = localStorage.getItem(BGM_STORAGE_KEY);
<br> if (saved) {
<br> const settings = JSON.parse(saved);
<br> if (typeof settings.volume === 'number') {
<br> currentVolume = settings.volume;
<br> }
<br> if (typeof settings.shuffle === 'boolean') {
<br> isShuffleOn = settings.shuffle;
<br> }
<br> if (['all', 'one', 'none'].includes(settings.repeat)) {
<br> repeatMode = settings.repeat;
<br> }
<br> }
<br> } catch (e) {
<br> }
<br>}
<br>
<br>// 셔플 순서 생성
<br>function generateShuffledOrder() {
<br> if (!playlistData.length) return;
<br>
<br> shuffledOrder = [...Array(playlistData.length).keys()];
<br> // Fisher-Yates 셔플
<br> for (let i = shuffledOrder.length - 1; i > 0; i--) {
<br> const j = Math.floor(Math.random() * (i + 1));
<br> [shuffledOrder[i], shuffledOrder[j]] = [shuffledOrder[j], shuffledOrder[i]];
<br> }
<br>
<br> // 현재 곡을 첫 번째로 이동
<br> const currentPos = shuffledOrder.indexOf(currentTrackIndex);
<br> if (currentPos > 0) {
<br> shuffledOrder.splice(currentPos, 1);
<br> shuffledOrder.unshift(currentTrackIndex);
<br> }
<br> shuffledIndex = 0;
<br>}
<br>
<br>// 셔플 토글
<br>function toggleShuffle() {
<br> isShuffleOn = !isShuffleOn;
<br>
<br> if (isShuffleOn) {
<br> generateShuffledOrder();
<br> }
<br>
<br> updateShuffleUI();
<br> saveBGMSettings();
<br>}
<br>
<br>// 반복 모드 순환
<br>function cycleRepeatMode() {
<br> if (repeatMode === 'all') {
<br> repeatMode = 'one';
<br> } else if (repeatMode === 'one') {
<br> repeatMode = 'none';
<br> } else {
<br> repeatMode = 'all';
<br> }
<br>
<br> updateRepeatUI();
<br> saveBGMSettings();
<br>}
<br>
<br>// 셔플 UI 업데이트
<br>function updateShuffleUI() {
<br> const shuffleBtn = document.getElementById('shuffle-btn');
<br> if (shuffleBtn) {
<br> if (isShuffleOn) {
<br> shuffleBtn.classList.add('active');
<br> } else {
<br> shuffleBtn.classList.remove('active');
<br> }
<br> }
<br>}
<br>
<br>// 반복 UI 업데이트
<br>function updateRepeatUI() {
<br> const repeatBtn = document.getElementById('repeat-btn');
<br> if (!repeatBtn) return;
<br>
<br> repeatBtn.classList.remove('active', 'repeat-one');
<br>
<br> if (repeatMode === 'all') {
<br> repeatBtn.classList.add('active');
<br> repeatBtn.innerHTML = '<i class="fa-solid fa-repeat"></i>';
<br> } else if (repeatMode === 'one') {
<br> repeatBtn.classList.add('active', 'repeat-one');
<br> repeatBtn.innerHTML = '<i class="fa-solid fa-repeat"></i><span class="repeat-one-badge">1</span>';
<br> } else {
<br> repeatBtn.innerHTML = '<i class="fa-solid fa-repeat"></i>';
<br> }
<br>}
<br>
<br>// 볼륨 팝업 토글
<br>function toggleVolume() {
<br> isVolumeOpen = !isVolumeOpen;
<br> const popup = document.getElementById('volume-popup');
<br> if (popup) {
<br> if (isVolumeOpen) {
<br> popup.classList.add('show');
<br> } else {
<br> popup.classList.remove('show');
<br> }
<br> }
<br>}
<br>
<br>// 볼륨 아이콘 업데이트
<br>function updateVolumeIcon() {
<br> const volumeBtn = document.getElementById('volume-btn');
<br> if (!volumeBtn) return;
<br>
<br> let icon = 'fa-volume-high';
<br> if (currentVolume == 0) {
<br> icon = 'fa-volume-xmark';
<br> } else if (currentVolume < 30) {
<br> icon = 'fa-volume-off';
<br> } else if (currentVolume < 70) {
<br> icon = 'fa-volume-low';
<br> }
<br>
<br> volumeBtn.innerHTML = `<i class="fa-solid ${icon}"></i>`;
<br>}
<br>
<br>// Pixel 스킨 볼륨 바 업데이트 함수
<br>function updatePixelVolumeBar(volume) {
<br> const pixelVolBar = document.getElementById('pixel-vol-bar');
<br> if (!pixelVolBar) return;
<br>
<br> const segments = pixelVolBar.querySelectorAll('.vol-seg');
<br> const filledCount = Math.round(volume / 10); // 0-100 → 0-10
<br>
<br> segments.forEach((seg, index) => {
<br> if (index < filledCount) {
<br> seg.classList.add('filled');
<br> } else {
<br> seg.classList.remove('filled');
<br> }
<br> });
<br>}
<br>
<br>// 다음 트랙 인덱스 계산 (셔플/반복 고려)
<br>function getNextTrackIndex() {
<br> if (playlistData.length <= 1) return 0;
<br>
<br> if (repeatMode === 'one') {
<br> return currentTrackIndex;
<br> }
<br>
<br> if (isShuffleOn) {
<br> shuffledIndex++;
<br> if (shuffledIndex >= shuffledOrder.length) {
<br> if (repeatMode === 'none') {
<br> return -1; // 재생 중지
<br> }
<br> shuffledIndex = 0;
<br> generateShuffledOrder(); // 새로운 셔플 순서
<br> }
<br> return shuffledOrder[shuffledIndex];
<br> } else {
<br> const nextIndex = currentTrackIndex + 1;
<br> if (nextIndex >= playlistData.length) {
<br> if (repeatMode === 'none') {
<br> return -1; // 재생 중지
<br> }
<br> return 0;
<br> }
<br> return nextIndex;
<br> }
<br>}
<br>
<br>// 이전 트랙 인덱스 계산 (셔플/반복 고려)
<br>function getPrevTrackIndex() {
<br> if (playlistData.length <= 1) return 0;
<br>
<br> if (isShuffleOn) {
<br> shuffledIndex--;
<br> if (shuffledIndex < 0) {
<br> shuffledIndex = shuffledOrder.length - 1;
<br> }
<br> return shuffledOrder[shuffledIndex];
<br> } else {
<br> return currentTrackIndex > 0 ? currentTrackIndex - 1 : playlistData.length - 1;
<br> }
<br>}
<br>
<br>// 보안 검증 함수들
<br>function validateOrigin() {
<br> // 현재 실행 중인 도메인은 항상 허용 (자동 감지)
<br> const currentOrigin = window.location.hostname;
<br>
<br> // localhost 또는 실제 도메인(점이 포함된 hostname)이면 허용
<br> if (currentOrigin === 'localhost' || currentOrigin.includes('.')) {
<br> return true;
<br> }
<br>
<br> // 그 외에는 거부 (비정상적인 경우)
<br> return false;
<br>}
<br>
<br>// 보안 YouTube API 준비
<br>function onYouTubeIframeAPIReady() {
<br> if (!validateOrigin()) {
<br> return;
<br> }
<br>
<br> initBGMPlayer();
<br>}
<br>
<br>async function initBGMPlayer() {
<br> // 추가 보안 검증
<br> if (!validateOrigin()) {
<br> return;
<br> }
<br>
<br> if (!bgmConfig.youtube) {
<br> return;
<br> }
<br>
<br> // YouTube Player 생성 (보안 강화된 설정)
<br> let playerDiv = document.getElementById('youtube-player');
<br> if (!playerDiv) {
<br> playerDiv = document.createElement('div');
<br> playerDiv.id = 'youtube-player';
<br> playerDiv.style.display = 'none';
<br> document.body.appendChild(playerDiv);
<br> }
<br>
<br> // 보안 강화된 playerVars
<br> // iframe 내부에서 실행될 때 최상위 페이지의 origin 사용
<br> let pageOrigin;
<br> try {
<br> pageOrigin = window.top.location.origin;
<br> } catch(e) {
<br> // cross-origin인 경우 현재 origin 사용
<br> pageOrigin = window.location.origin;
<br> }
<br>
<br> const securePlayerVars = {
<br> autoplay: 0,
<br> controls: 0,
<br> showinfo: 0,
<br> rel: 0,
<br> iv_load_policy: 3,
<br> enablejsapi: 1,
<br> modestbranding: 1,
<br> fs: 0,
<br> cc_load_policy: 0,
<br> disablekb: 1,
<br> origin: pageOrigin
<br> };
<br>
<br> if (bgmConfig.youtube.type === 'playlist') {
<br> player = new YT.Player('youtube-player', {
<br> height: '0',
<br> width: '0',
<br> playerVars: {
<br> ...securePlayerVars,
<br> listType: 'playlist',
<br> list: bgmConfig.youtube.id
<br> },
<br> events: {
<br> 'onReady': onPlayerReady,
<br> 'onStateChange': onPlayerStateChange,
<br> 'onError': onPlayerError
<br> }
<br> });
<br> } else {
<br> player = new YT.Player('youtube-player', {
<br> height: '0',
<br> width: '0',
<br> videoId: bgmConfig.youtube.id,
<br> playerVars: securePlayerVars,
<br> events: {
<br> 'onReady': onPlayerReady,
<br> 'onStateChange': onPlayerStateChange,
<br> 'onError': onPlayerError
<br> }
<br> });
<br> }
<br>}
<br>
<br>function onPlayerReady(event) {
<br>
<br> playerReady = true;
<br> readyToPlay = true;
<br>
<br> // 저장된 설정 복원
<br> loadBGMSettings();
<br>
<br> player.setVolume(currentVolume);
<br>
<br> // 볼륨 슬라이더 동기화
<br> const volumeSlider = document.getElementById('volume-slider');
<br> if (volumeSlider) {
<br> volumeSlider.value = currentVolume;
<br> }
<br>
<br> // 플레이어 표시
<br> setTimeout(() => {
<br> const bgmPlayer = document.getElementById('bgm-player');
<br> if (bgmPlayer) {
<br> bgmPlayer.classList.add('show');
<br> }
<br> }, 500);
<br>
<br> updateTrackInfo();
<br> updateControls();
<br> loadPlaylist();
<br>
<br> // 셔플/반복/볼륨 UI 초기화
<br> updateShuffleUI();
<br> updateRepeatUI();
<br> updateVolumeIcon();
<br>
<br> // 사용자 상호작용 대기
<br> setupUserInteractionListeners();
<br> showBGMNotice();
<br>}
<br>
<br>// 보안 강화된 플레이리스트 로드
<br>async function loadPlaylist() {
<br> if (!playerReady) return;
<br>
<br> try {
<br> const playlist = player.getPlaylist();
<br> if (playlist && playlist.length > 1) {
<br> const toggleBtn = document.getElementById('list-toggle-btn');
<br> if (toggleBtn) {
<br> toggleBtn.style.display = 'flex';
<br> }
<br>
<br> playlistData = playlist;
<br>
<br> // 셔플이 켜져있으면 셔플 순서 생성
<br> if (isShuffleOn) {
<br> generateShuffledOrder();
<br> }
<br>
<br> await loadPlaylistTitles();
<br> updatePlaylistUI();
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 보안 강화된 제목 가져오기
<br>async function fetchVideoTitle(videoId) {
<br> // videoId 검증
<br> if (!videoId || typeof videoId !== 'string' || !/^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
<br> return null;
<br> }
<br>
<br> try {
<br> // 안전한 URL 구성
<br> const safeUrl = `URL
<br>
<br> const response = await fetch(safeUrl, {
<br> method: 'GET',
<br> headers: {
<br> 'Accept': 'application/json',
<br> },
<br> credentials: 'omit',
<br> referrerPolicy: 'no-referrer'
<br> });
<br>
<br> if (!response.ok) {
<br> throw new Error(`HTTP ${response.status}`);
<br> }
<br>
<br> const data = await response.json();
<br>
<br> // 응답 데이터 검증
<br> if (data && typeof data.title === 'string' && data.title.trim() !== '') {
<br> // XSS 방지를 위한 제목 정리
<br> return data.title.replace(/<[^>]*>/g, '').trim();
<br> }
<br>
<br> return null;
<br> } catch (error) {
<br> return null;
<br> }
<br>}
<br>
<br>// 보안 강화된 사용자 상호작용 리스너
<br>function setupUserInteractionListeners() {
<br> const events = ['click', 'touchstart', 'keydown', 'scroll', 'mousemove'];
<br>
<br> const handleUserInteraction = (e) => {
<br> // 이벤트 검증
<br> if (!e || !e.type) {
<br> return;
<br> }
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br>
<br> // 재생 전 최종 보안 검증
<br> if (readyToPlay && player && playerReady ) {
<br> startBGM();
<br> }
<br>
<br> hideBGMNotice();
<br>
<br> events.forEach(event => {
<br> document.removeEventListener(event, handleUserInteraction, true);
<br> });
<br> }
<br> };
<br>
<br> events.forEach(event => {
<br> document.addEventListener(event, handleUserInteraction, true);
<br> });
<br>}
<br>
<br>// 보안 강화된 BGM 시작
<br>function startBGM() {
<br> if (!player || !playerReady) {
<br> return;
<br> }
<br>
<br> try {
<br> player.playVideo();
<br>
<br> setTimeout(() => {
<br> try {
<br> const newState = player.getPlayerState();
<br>
<br> if (newState === YT.PlayerState.PLAYING) {
<br> // 재생 성공
<br> } else if (newState === YT.PlayerState.BUFFERING) {
<br> // 버퍼링 중
<br> } else {
<br> // 재생 실패 시 재시도
<br> setTimeout(() => {
<br> if (true) {
<br> player.playVideo();
<br> }
<br> }, 1000);
<br> }
<br> } catch (error) {
<br> }
<br> }, 500);
<br>
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 보안 강화된 플레이어 상태 변경
<br>function onPlayerStateChange(event) {
<br> const bgmPlayer = document.getElementById('bgm-player');
<br>
<br> if (event.data === YT.PlayerState.PLAYING) {
<br> isPlaying = true;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-pause"></i>';
<br> if (bgmPlayer) bgmPlayer.classList.add('playing');
<br>
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br>
<br> } else if (event.data === YT.PlayerState.PAUSED) {
<br> isPlaying = false;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
<br> if (bgmPlayer) bgmPlayer.classList.remove('playing');
<br> } else if (event.data === YT.PlayerState.ENDED) {
<br> // 셔플/반복 모드에 따른 재생 처리
<br> if (playlistData.length > 1) {
<br> const nextIndex = getNextTrackIndex();
<br>
<br> if (nextIndex === -1) {
<br> // repeatMode === 'none' 이고 마지막 곡인 경우
<br> isPlaying = false;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
<br> return;
<br> }
<br>
<br> currentTrackIndex = nextIndex;
<br>
<br> setTimeout(() => {
<br> try {
<br> player.playVideoAt(currentTrackIndex);
<br> } catch (error) {
<br> player.seekTo(0);
<br> player.playVideo();
<br> }
<br> }, 500);
<br>
<br> updatePlaylistUI();
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } else {
<br> // 단일 곡
<br> if (repeatMode === 'none') {
<br> isPlaying = false;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
<br> return;
<br> }
<br>
<br> // 1곡 반복 또는 전체 반복 (단일곡은 동일)
<br> setTimeout(() => {
<br> try {
<br> player.seekTo(0);
<br> player.playVideo();
<br> } catch (error) {
<br> }
<br> }, 500);
<br> }
<br> }
<br>
<br> updateControls();
<br>}
<br>
<br>// 보안 강화된 플레이 토글
<br>function togglePlay() {
<br> if (!playerReady || !true) return;
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> }
<br>
<br> try {
<br> if (isPlaying) {
<br> player.pauseVideo();
<br> } else {
<br> player.playVideo();
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function stopTrack() {
<br> if (!playerReady) return;
<br>
<br> try {
<br> player.stopVideo();
<br> isPlaying = false;
<br> updatePlayPauseButton();
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 트랙 이동 함수들 (셔플 지원)
<br>function previousTrack() {
<br> if (!playerReady || playlistData.length <= 1) return;
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> }
<br>
<br> try {
<br> currentTrackIndex = getPrevTrackIndex();
<br> player.playVideoAt(currentTrackIndex);
<br> updatePlaylistUI();
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function nextTrack() {
<br> if (!playerReady || playlistData.length <= 1) return;
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> }
<br>
<br> try {
<br> const nextIndex = getNextTrackIndex();
<br> if (nextIndex === -1) {
<br> // repeatMode === 'none' 마지막 곡
<br> return;
<br> }
<br> currentTrackIndex = nextIndex;
<br> player.playVideoAt(currentTrackIndex);
<br> updatePlaylistUI();
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } catch (error) {
<br> }
<br>}
<br>
<br>async function loadPlaylistTitles() {
<br> // 보안 체크 제거됨
<br>
<br> window.playlistTitles = new Array(playlistData.length).fill(null);
<br>
<br> const titlePromises = playlistData.map(async (videoId, index) => {
<br> try {
<br> const title = await fetchVideoTitle(videoId);
<br> window.playlistTitles[index] = title || `곡 ${index + 1}`;
<br>
<br> if (title) {
<br> updatePlaylistUI();
<br> }
<br> } catch (error) {
<br> window.playlistTitles[index] = `곡 ${index + 1}`;
<br> }
<br> });
<br>
<br> await Promise.all(titlePromises);
<br>}
<br>
<br>function updatePlaylistUI() {
<br> const playlistEl = document.getElementById('playlist');
<br> if (!playlistEl || !playlistData.length) return;
<br>
<br> playlistEl.innerHTML = '';
<br>
<br> playlistData.forEach((videoId, index) => {
<br> const li = document.createElement('li');
<br>
<br> let title;
<br> if (window.playlistTitles && window.playlistTitles[index]) {
<br> // XSS 방지
<br> title = window.playlistTitles[index].replace(/<[^>]*>/g, '');
<br> } else {
<br> title = `곡 ${index + 1} (로딩중...)`;
<br> }
<br>
<br> li.textContent = title;
<br> li.onclick = () => {
<br> // 클릭 시 보안 검증
<br> if (true) {
<br> playTrack(index);
<br> }
<br> };
<br>
<br> if (index === currentTrackIndex) {
<br> li.classList.add('current');
<br> }
<br>
<br> playlistEl.appendChild(li);
<br> });
<br>}
<br>
<br>function playTrack(index) {
<br> if (!playerReady || !playlistData.length || !true) return;
<br>
<br> try {
<br> player.playVideoAt(index);
<br> currentTrackIndex = index;
<br> updatePlaylistUI();
<br>
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } catch (error) {
<br> }
<br>}
<br>
<br>async function updateCurrentTrackTitle() {
<br> if (!playerReady || !true) return;
<br>
<br> try {
<br> const videoData = player.getVideoData();
<br>
<br> if (videoData && videoData.title && videoData.title.trim() !== '') {
<br> if (window.playlistTitles) {
<br> // XSS 방지
<br> window.playlistTitles[currentTrackIndex] = videoData.title.replace(/<[^>]*>/g, '');
<br> updatePlaylistUI();
<br> }
<br> return;
<br> }
<br>
<br> if (playlistData[currentTrackIndex]) {
<br> const title = await fetchVideoTitle(playlistData[currentTrackIndex]);
<br> if (title && window.playlistTitles) {
<br> window.playlistTitles[currentTrackIndex] = title;
<br> updatePlaylistUI();
<br> }
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function togglePlaylist() {
<br>
<br>
<br> const container = document.getElementById('playlist-container');
<br> const toggleBtn = document.getElementById('list-toggle-btn');
<br>
<br> if (!container || !toggleBtn) return;
<br>
<br> isPlaylistOpen = !isPlaylistOpen;
<br>
<br> if (isPlaylistOpen) {
<br> container.style.display = 'block';
<br> toggleBtn.classList.add('active');
<br> } else {
<br> container.style.display = 'none';
<br> toggleBtn.classList.remove('active');
<br> }
<br>}
<br>
<br>function togglePlayer() {
<br>
<br>
<br> const playerElement = document.getElementById('bgm-player');
<br> if (!playerElement) return;
<br>
<br> isMinimized = !isMinimized;
<br>
<br> if (isMinimized) {
<br> playerElement.classList.add('minimized');
<br> if (isPlaylistOpen) {
<br> togglePlaylist();
<br> }
<br> } else {
<br> playerElement.classList.remove('minimized');
<br> }
<br>}
<br>
<br>function onPlayerError(event) {
<br> const trackInfo = document.getElementById('track-info');
<br> if (trackInfo) {
<br> trackInfo.textContent = '재생 오류 발생';
<br> }
<br>}
<br>
<br>function showBGMNotice() {
<br> if (document.getElementById('bgm-notice')) return;
<br>
<br> const notice = document.createElement('div');
<br> notice.id = 'bgm-notice';
<br> notice.innerHTML = `
<br> <div style="
<br> position: fixed;
<br> bottom: 20px;
<br> left: 20px;
<br> background: rgba(0,0,0,0.9);
<br> color: white;
<br> padding: 12px 16px;
<br> border-radius: 8px;
<br> font-size: 13px;
<br> z-index: 9999;
<br> cursor: pointer;
<br> transition: all 0.3s ease;
<br> box-shadow: 0 4px 12px rgba(0,0,0,0.3);
<br> backdrop-filter: blur(10px);
<br> animation: slideIn 0.3s ease;
<br> " onclick="handleNoticeClick()">
<br> 페이지를 클릭하면 BGM이 재생됩니다
<br> <span style="margin-left: 10px; opacity: 0.7; font-size: 11px;">[클릭하여 닫기]</span>
<br> </div>
<br> <style>
<br> @keyframes slideIn {
<br> from { transform: translateY(100px); opacity: 0; }
<br> to { transform: translateY(0); opacity: 1; }
<br> }
<br> </style>
<br> `;
<br> document.body.appendChild(notice);
<br>
<br> setTimeout(() => {
<br> hideBGMNotice();
<br> }, 10000);
<br>}
<br>
<br>function handleNoticeClick() {
<br> if (!userInteracted) {
<br> userInteracted = true;
<br>
<br> if (readyToPlay && player && playerReady) {
<br> startBGM();
<br> }
<br> }
<br> hideBGMNotice();
<br>}
<br>
<br>function hideBGMNotice() {
<br> const notice = document.getElementById('bgm-notice');
<br> if (notice) {
<br> const noticeEl = notice.firstElementChild;
<br> noticeEl.style.opacity = '0';
<br> noticeEl.style.transform = 'translateY(20px)';
<br> setTimeout(() => {
<br> if (notice.parentNode) {
<br> notice.remove();
<br> }
<br> }, 300);
<br> }
<br>}
<br>
<br>function updateTrackInfo() {
<br> if (!playerReady || !true) return;
<br>
<br> const trackInfo = document.getElementById('track-info');
<br> if (!trackInfo) return;
<br>
<br> try {
<br> const videoData = player.getVideoData();
<br>
<br> if (videoData && videoData.title) {
<br> // XSS 방지를 위한 제목 정리
<br> const safeTitle = videoData.title.replace(/<[^>]*>/g, '');
<br> trackInfo.textContent = safeTitle;
<br> } else {
<br> if (playlistData.length > 1) {
<br> trackInfo.textContent = `곡 ${currentTrackIndex + 1}`;
<br> } else {
<br> trackInfo.textContent = '재생 중...';
<br> }
<br> }
<br>
<br> // 썸네일 업데이트
<br> updateThumbnail();
<br> } catch (error) {
<br> if (playlistData.length > 1) {
<br> trackInfo.textContent = `곡 ${currentTrackIndex + 1}`;
<br> } else {
<br> trackInfo.textContent = '재생 중...';
<br> }
<br> }
<br>}
<br>
<br>// 썸네일 업데이트 함수
<br>function updateThumbnail() {
<br> if (!playerReady) return;
<br>
<br> try {
<br> const videoData = player.getVideoData();
<br> let videoId = null;
<br>
<br> if (videoData && videoData.video_id) {
<br> videoId = videoData.video_id;
<br> } else if (playlistData.length > 0 && playlistData[currentTrackIndex]) {
<br> videoId = playlistData[currentTrackIndex];
<br> }
<br>
<br> if (videoId) {
<br> const thumbnailUrl = `URL
<br>
<br> // CD 스킨 썸네일
<br> const cdThumb = document.getElementById('cd-thumbnail');
<br> if (cdThumb) {
<br> cdThumb.src = thumbnailUrl;
<br> }
<br>
<br> // Vinyl 스킨 썸네일
<br> const vinylThumb = document.getElementById('vinyl-thumbnail');
<br> if (vinylThumb) {
<br> vinylThumb.src = thumbnailUrl;
<br> }
<br>
<br> // Neon 스킨 썸네일
<br> const neonThumb = document.getElementById('neon-thumbnail');
<br> if (neonThumb) {
<br> neonThumb.src = thumbnailUrl;
<br> }
<br>
<br> // Pixel 스킨 썸네일
<br> const pixelThumb = document.getElementById('pixel-thumbnail');
<br> if (pixelThumb) {
<br> pixelThumb.src = thumbnailUrl;
<br> }
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function updateControls() {
<br> if (!playerReady || !true) return;
<br>
<br> try {
<br> const playlist = player.getPlaylist();
<br> const prevBtn = document.getElementById('prev-btn');
<br> const nextBtn = document.getElementById('next-btn');
<br>
<br> if (prevBtn && nextBtn) {
<br> if (playlist && playlist.length > 1) {
<br> prevBtn.disabled = false;
<br> nextBtn.disabled = false;
<br> } else {
<br> prevBtn.disabled = true;
<br> nextBtn.disabled = true;
<br> }
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 보안 강화된 DOM 로드 이벤트
<br>document.addEventListener('DOMContentLoaded', function() {
<br> // 볼륨 컨트롤 이벤트 (보안 강화)
<br> const volumeSlider = document.getElementById('volume-slider');
<br> if (volumeSlider) {
<br> volumeSlider.addEventListener('input', function() {
<br> // 볼륨 조절 시 보안 검증
<br> // 보안 체크 제거됨
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> if (readyToPlay && player && playerReady) {
<br> startBGM();
<br> }
<br> }
<br>
<br> currentVolume = parseInt(this.value);
<br>
<br> if (playerReady && player) {
<br> player.setVolume(currentVolume);
<br> }
<br>
<br> updateVolumeIcon();
<br> saveBGMSettings();
<br>
<br> // Retro 스킨 볼륨 표시 업데이트
<br> const volDisplay = document.getElementById('vol-display');
<br> if (volDisplay) {
<br> volDisplay.textContent = currentVolume + '%';
<br> }
<br>
<br> // Pixel 스킨 볼륨 바 업데이트
<br> updatePixelVolumeBar(currentVolume);
<br> });
<br> }
<br>
<br> // 초기 픽셀 볼륨 바 설정
<br> updatePixelVolumeBar(currentVolume);
<br>
<br> // 외부 클릭 시 볼륨 팝업 닫기
<br> document.addEventListener('click', function(e) {
<br> const volumeControl = document.querySelector('.volume-control');
<br> if (volumeControl && !volumeControl.contains(e.target) && isVolumeOpen) {
<br> isVolumeOpen = false;
<br> const popup = document.getElementById('volume-popup');
<br> if (popup) popup.classList.remove('show');
<br> }
<br> });
<br>
<br> // 보안 YouTube API 로드
<br> if (typeof YT === 'undefined') {
<br> const script = document.createElement('script');
<br> script.src = 'URL';
<br> script.onload = function() {
<br> // YouTube API 로드 성공
<br> };
<br> script.onerror = function() {
<br> };
<br> document.head.appendChild(script);
<br> }
<br>});
<br>
<br>// 페이지 언로드 시 정리
<br>window.addEventListener('beforeunload', function() {
<br> if (player && typeof player.destroy === 'function') {
<br> try {
<br> player.destroy();
<br> } catch (error) {
<br> }
<br> }
<br>}); <br></div>
</details><details class="fold">
<summary>intro.php</summary>
<div class="fold-content"><?php<br>define('_INTRO_', true);<br>include_once('./_common.php');<br><br>// 로그인한 회원은 메인으로 이동<br>if ($is_member) {<br>$main_link = get_main_link();<br><br>// iframe 내부에서는 PostMessage로 메인 이동<br>if (isset($_GET['iframe_skip'])) {<br>echo "<script><br>if (window.parent && window.parent !== window) {<br>window.parent.postMessage({type: 'navigate', url: '".$main_link."'}, '*');<br>} else {<br>location.href = '".$main_link."';<br>}<br></script>";<br>exit;<br>}<br>}<br><br>include_once(G5_PATH.'/head.sub.php');<br>?><br><br><div class="intro-container"><br><div class="site-logo"><br><?php<br>$logo_url = $design['logo_image_url'];<br>$use_logo = $design['use_logo'] == '1';<br>$main_link = get_main_link();<br>?><br><br><a href="<?=$main_link?>" class="logo-link"><br><?php if ($use_logo): ?><br><img src="<?php echo htmlspecialchars($design['logo_image_url'] ?? G5_IMG_URL.'/logo.png'); ?>" alt="<?php echo $config['cf_title']; ?>"><br><?php else: ?><br><span class="logo-text"><?php echo $config['cf_title']; ?></span><br><?php endif; ?><br></a><br></div><br><br><div class="intro-content"><br><?php if ($config['cf_visit'] == '1') { ?><br><!-- 비회원: 로그인 폼 (비공개 설정일 때만 표시) --><br><div class="login_form"><br><form name="flogin" action="<?php echo $login_action_url ?>" onsubmit="return flogin_submit(this);" method="post"><br><fieldset><br><div class="login_input"><br><input type="text" name="mb_id" maxlength="20" placeholder="아이디" required><br><input type="password" name="mb_password" maxlength="20" placeholder="비밀번호" required><br></div><br><button type="submit" class="btn_login">LOGIN</button><br></fieldset><br></form><br><div class="login_links"><br><a href="<?php echo G5_BBS_URL ?>/register.php">회원가입</a><br><a href="<?php echo G5_BBS_URL ?>/password_lost.php">비밀번호 찾기</a><br></div><br></div><br><?php } ?><br></div><br></div><br><br><script><br>function flogin_submit(f) {<br>if (!f.mb_id.value) {<br>alert('아이디를 입력해 주세요.');<br>f.mb_id.focus();<br>return false;<br>}<br><br>if (!f.mb_password.value) {<br>alert('비밀번호를 입력해 주세요.');<br>f.mb_password.focus();<br>return false;<br>}<br><br>return true;<br>}<br></script><br><br><script><br>$(document).ready(function(){<br>// 메뉴 위젯 숨기기<br>parent.$('.tf-menu-widget').hide();<br>parent.$('.tf-menu-mobile-btn').hide();<br>});<br><br>// 페이지 벗어날 때 다시 보이기<br>$(window).on("beforeunload", function(){<br>parent.$('.tf-menu-widget').show();<br>parent.$('.tf-menu-mobile-btn').show();<br>});<br></script><br><br><?php include_once(G5_PATH.'/tail.sub.php'); ?></div>
</details><p><br></p></div>
<summary>bgm.css</summary>
<div class="fold-content">/* BGM Player Styles */
<br>.bgm-player {
<br> position: fixed;
<br> bottom: var(--spacing-lg);
<br> right: var(--spacing-lg);
<br> width: 280px;
<br> background: var(--bg-glass);
<br> backdrop-filter: blur(16px);
<br> border: 1px solid var(--bg-glass-dark);
<br> border-radius: var(--container-border-radius);
<br> padding: var(--spacing-lg);
<br> box-shadow: var(--shadow-glass);
<br> z-index: 9999;
<br> font-family: var(--f-pre);
<br> transition: all var(--transition-base);
<br> transform: translateY(100px);
<br> opacity: 0;
<br> pointer-events: auto; /* iframe 내부에서도 클릭 가능 */
<br>}
<br>
<br>.bgm-player.show {
<br> transform: translateY(0);
<br> opacity: 1;
<br>}
<br>
<br>/* 최소화 상태 */
<br>.bgm-player.minimized {
<br> width: 30px;
<br> height: 30px;
<br> padding: 0;
<br> border-radius: var(--spacing-sm);
<br> cursor: pointer;
<br> background: var(--bg-glass);
<br> border: 1px solid var(--bg-glass-dark);
<br> display: flex;
<br> align-items: center;
<br> justify-content: center;
<br> transition: all var(--transition-base);
<br>}
<br>
<br>.bgm-player.minimized:hover {
<br> transform: scale(1.05);
<br> box-shadow: var(--shadow-lg);
<br>}
<br>
<br>.bgm-player.minimized .player-content {
<br> display: none;
<br>}
<br>
<br>.bgm-player.minimized::before {
<br> content: '♪';
<br> font-size: 1.25rem;
<br> color: var(--content-font-color);
<br>}
<br>
<br>/* 최소화 상태에서 클릭 가능한 오버레이 */
<br>.minimized-overlay {
<br> position: absolute;
<br> top: 0;
<br> left: 0;
<br> right: 0;
<br> bottom: 0;
<br> border-radius: inherit;
<br> display: none;
<br>}
<br>
<br>.bgm-player.minimized .minimized-overlay {
<br> display: block;
<br>}
<br>
<br>/* 노래 제목 + 토글 버튼 헤더 */
<br>.track-header {
<br> display: flex;
<br> align-items: center;
<br> justify-content: space-between;
<br> gap: var(--spacing-sm);
<br>}
<br>
<br>.track-info {
<br> color: var(--content-font-color);
<br> font-size: 12px;
<br> font-weight: 400;
<br> font-family: var(--f-pre);
<br> overflow: hidden;
<br> text-overflow: ellipsis;
<br> white-space: nowrap;
<br> flex: 1;
<br> line-height: 1.4;
<br>}
<br>
<br>.list-toggle-btn {
<br> background: var(--bg-secondary);
<br> border: 1px solid var(--bg-glass-dark);
<br> color: var(--text-secondary);
<br> padding: var(--spacing-xs) var(--spacing-sm);
<br> border-radius: var(--btn-primary-radius);
<br> cursor: pointer;
<br> font-size: 11px;
<br> font-family: var(--f-pre);
<br> transition: all var(--transition-fast);
<br> display: flex;
<br> align-items: center;
<br> gap: var(--spacing-xxs);
<br> min-width: 20px;
<br> justify-content: center;
<br>}
<br>
<br>.list-toggle-btn:hover {
<br> background: var(--primary-light);
<br> color: var(--primary-color);
<br> border-color: var(--primary-color);
<br> transform: translateY(-1px);
<br>}
<br>
<br>.list-toggle-btn.active {
<br> background: var(--primary-color);
<br> color: var(--white);
<br> border-color: var(--primary-color);
<br>}
<br>
<br>.toggle-icon {
<br> transition: transform var(--transition-fast);
<br> font-size: 10px;
<br>}
<br>
<br>.list-toggle-btn.active .toggle-icon {
<br> transform: rotate(180deg);
<br>}
<br>
<br>/* 플레이리스트 컨테이너 */
<br>.playlist-container {
<br> max-height: 160px;
<br> overflow-y: auto;
<br> margin-top: var(--spacing-md);
<br> border-radius: var(--card-border-radius);
<br> background: var(--bg-glass-dark);
<br> border: 1px solid var(--bg-glass-dark);
<br>}
<br>
<br>.playlist {
<br> list-style: none;
<br> padding: 0;
<br> margin: 0;
<br>}
<br>
<br>.playlist li {
<br> padding: var(--spacing-sm) var(--spacing-md);
<br> color: var(--text-secondary);
<br> font-size: 12px;
<br> font-family: var(--f-pre);
<br> cursor: pointer;
<br> transition: all var(--transition-fast);
<br> overflow: hidden;
<br> text-overflow: ellipsis;
<br> white-space: nowrap;
<br>}
<br>
<br>.playlist li:last-child {
<br> border-bottom: none;
<br>}
<br>
<br>.playlist li:hover {
<br> background: var(--primary-light);
<br> color: var(--primary-color);
<br>}
<br>
<br>.playlist li.current {
<br> background: var(--primary-color);
<br> color: var(--white);
<br> font-weight: 500;
<br>}
<br>
<br>/* 플레이어 컨트롤 */
<br>.player-controls {
<br> display: flex;
<br> justify-content: center;
<br> align-items: center;
<br> gap: var(--spacing-lg);
<br>}
<br>
<br>.bgm-controls {
<br> display: flex;
<br> justify-content: space-between;
<br> align-items: center;
<br> margin-bottom: var(--spacing-md);
<br> padding-right: var(--spacing-sm);
<br>}
<br>
<br>.control-btn {
<br> background: transparent;
<br> border: none;
<br> color: rgb(from var(--primary-color) r g b / 60%);
<br> font-size: 14px;
<br> cursor: pointer;
<br> padding: 0;
<br> transition: all var(--transition-fast);
<br> display: flex;
<br> align-items: center;
<br> justify-content: center;
<br> gap: var(--spacing-sm);
<br>}
<br>
<br>.control-btn:hover:not(:disabled) {
<br> color: rgb(from var(--white) r g b / 30%);
<br> transform: translateY(-2px);
<br>}
<br>
<br>.control-btn.play-pause {
<br> background: transparent;
<br> color: var(--accent-color);
<br> border: none;
<br> font-size: 16px;
<br>}
<br>
<br>.control-btn.play-pause:hover {
<br> transform: translateY(-2px) scale(1.05);
<br>}
<br>
<br>.control-btn:disabled {
<br> opacity: 0.4;
<br> cursor: not-allowed;
<br> transform: none;
<br>}
<br>
<br>.control-btn:disabled:hover {
<br> transform: none;
<br> box-shadow: none;
<br>}
<br>
<br>/* 모드 컨트롤 (셔플/반복) */
<br>.mode-controls {
<br> display: flex;
<br> align-items: center;
<br> gap: var(--spacing-sm);
<br>}
<br>
<br>.control-btn.mode-btn {
<br> font-size: 12px;
<br> opacity: 0.5;
<br> position: relative;
<br>}
<br>
<br>.control-btn.mode-btn:hover {
<br> opacity: 0.8;
<br>}
<br>
<br>.control-btn.mode-btn.active {
<br> opacity: 1;
<br> color: var(--accent-color);
<br>}
<br>
<br>/* 1곡 반복 배지 */
<br>.repeat-one-badge {
<br> position: absolute;
<br> top: -4px;
<br> right: -6px;
<br> font-size: 9px;
<br> font-weight: 700;
<br> color: var(--accent-color);
<br> line-height: 1;
<br>}
<br>
<br>/* 볼륨 컨트롤 */
<br>.volume-control {
<br> position: relative;
<br> display: flex;
<br> align-items: center;
<br>}
<br>
<br>.volume-btn {
<br> font-size: 14px;
<br>}
<br>
<br>.volume-btn.muted i::before {
<br> content: "\f6a9"; /* fa-volume-xmark */
<br>}
<br>
<br>.volume-popup {
<br> position: absolute;
<br> bottom: 100%;
<br> left: 50%;
<br> transform: translateX(-50%);
<br> background: var(--bg-glass);
<br> backdrop-filter: blur(16px);
<br> border: 1px solid var(--bg-glass-dark);
<br> border-radius: var(--card-border-radius);
<br> padding: var(--spacing-md) var(--spacing-sm);
<br> box-shadow: var(--shadow-lg);
<br> display: none;
<br> margin-bottom: var(--spacing-sm);
<br>}
<br>
<br>.volume-popup.show {
<br> display: block;
<br>}
<br>
<br>.volume-slider {
<br> width: 4px;
<br> height: 80px;
<br> background: var(--border-medium);
<br> border-radius: 2px;
<br> outline: none;
<br> -webkit-appearance: slider-vertical;
<br> appearance: slider-vertical;
<br> cursor: pointer;
<br> transition: all var(--transition-fast);
<br> writing-mode: vertical-lr;
<br> direction: rtl;
<br>}
<br>
<br>.volume-slider:hover {
<br> background: var(--primary-light);
<br>}
<br>
<br>.volume-slider::-webkit-slider-thumb {
<br> -webkit-appearance: none;
<br> width: 14px;
<br> height: 14px;
<br> background: var(--primary-color);
<br> border-radius: 50%;
<br> cursor: pointer;
<br> border: 2px solid var(--white);
<br> box-shadow: var(--shadow-sm);
<br> transition: all var(--transition-fast);
<br>}
<br>
<br>.volume-slider::-webkit-slider-thumb:hover {
<br> transform: scale(1.2);
<br> box-shadow: var(--shadow-md);
<br>}
<br>
<br>.volume-slider::-moz-range-thumb {
<br> width: 14px;
<br> height: 14px;
<br> background: var(--primary-color);
<br> border-radius: 50%;
<br> cursor: pointer;
<br> border: 2px solid var(--white);
<br> box-shadow: var(--shadow-sm);
<br>}
<br>
<br>/* 최소화 버튼 */
<br>.minimize-btn {
<br> position: absolute;
<br> top: calc(var(--spacing-sm) * 0.8);
<br> right: calc(var(--spacing-sm) * 0.8);
<br> /* background: rgb(from var(--accent-color) r g b / 70%); */
<br> background: transparent;
<br> background: var(--accent-color);
<br> /* border: 1px solid var(--bg-glass-dark); */
<br> border: none;
<br> color: var(--white);
<br> font-size: 12px;
<br> cursor: pointer;
<br> padding: 0;
<br> border-radius: var(--btn-primary-radius);
<br> transition: all var(--transition-fast);
<br> display: flex;
<br> align-items: center;
<br> justify-content: center;
<br> font-family: var(--f-pre);
<br>}
<br>
<br>.minimize-btn:hover {
<br> background: var(--primary-color);
<br> transform: scale(1.1);
<br>}
<br>
<br>/* 스크롤바 스타일 */
<br>.playlist-container::-webkit-scrollbar {
<br> width: 4px;
<br>}
<br>
<br>.playlist-container::-webkit-scrollbar-track {
<br> background: var(--gray-100);
<br> border-radius: 2px;
<br>}
<br>
<br>.playlist-container::-webkit-scrollbar-thumb {
<br> background: var(--primary-color);
<br> border-radius: 2px;
<br> transition: all var(--transition-fast);
<br>}
<br>
<br>.playlist-container::-webkit-scrollbar-thumb:hover {
<br> background: var(--primary-dark);
<br>}
<br>
<br>/* 애니메이션 */
<br>@keyframes slideInUp {
<br> from {
<br> transform: translateY(100px);
<br> opacity: 0;
<br> }
<br> to {
<br> transform: translateY(0);
<br> opacity: 1;
<br> }
<br>}
<br>
<br>@keyframes pulse {
<br> 0% { transform: scale(1); }
<br> 50% { transform: scale(1.05); }
<br> 100% { transform: scale(1); }
<br>}
<br>
<br>.bgm-player.minimized:hover {
<br> animation: pulse 0.6s ease infinite;
<br>}
<br>
<br>/* 로딩 상태 */
<br>.track-info.loading::after {
<br> content: '';
<br> display: inline-block;
<br> width: 12px;
<br> height: 12px;
<br> margin-left: var(--spacing-xs);
<br> border: 2px solid var(--border-light);
<br> border-radius: 50%;
<br> border-top-color: var(--primary-color);
<br> animation: spin 1s linear infinite;
<br>}
<br>
<br>@keyframes spin {
<br> to {
<br> transform: rotate(360deg);
<br> }
<br>}
<br>
<br>/* 포커스 상태 */
<br>.control-btn:focus,
<br>.list-toggle-btn:focus,
<br>.minimize-btn:focus {
<br> outline: 2px solid var(--primary-color);
<br> outline-offset: 2px;
<br>}
<br>
<br>.volume-slider:focus {
<br> outline: 2px solid var(--primary-color);
<br> outline-offset: 2px;
<br>}
<br>
<br>/* 접근성 개선 */
<br>@media (prefers-reduced-motion: reduce) {
<br> .bgm-player,
<br> .control-btn,
<br> .list-toggle-btn,
<br> .minimize-btn,
<br> .volume-slider::-webkit-slider-thumb,
<br> .toggle-icon {
<br> transition: none;
<br> }
<br>
<br> .bgm-player.minimized:hover {
<br> animation: none;
<br> }
<br>}
<br>
<br>/* 고해상도 디스플레이 대응 */
<br>@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
<br> .bgm-player {
<br> backdrop-filter: blur(20px);
<br> }
<br>} <br></div>
</details><details class="fold">
<summary>bgm.js</summary>
<div class="fold-content">let player;
<br>let playerReady = false;
<br>let isPlaying = false;
<br>let currentVolume = 30;
<br>let isMinimized = false;
<br>let userInteracted = false;
<br>let readyToPlay = false;
<br>let isPlaylistOpen = false;
<br>let playlistData = ☐;
<br>let currentTrackIndex = 0;
<br>
<br>// 셔플/반복 관련 변수
<br>let isShuffleOn = false;
<br>let repeatMode = 'all'; // 'all', 'one', 'none'
<br>let shuffledOrder = ☐; // 셔플된 인덱스 순서
<br>let shuffledIndex = 0; // 셔플 순서에서의 현재 위치
<br>let isVolumeOpen = false;
<br>
<br>// localStorage 키
<br>const BGM_STORAGE_KEY = 'ra0_bgm_settings';
<br>
<br>// localStorage 저장
<br>function saveBGMSettings() {
<br> try {
<br> const settings = {
<br> volume: currentVolume,
<br> shuffle: isShuffleOn,
<br> repeat: repeatMode
<br> };
<br> localStorage.setItem(BGM_STORAGE_KEY, JSON.stringify(settings));
<br> } catch (e) {
<br> }
<br>}
<br>
<br>// localStorage 복원
<br>function loadBGMSettings() {
<br> try {
<br> const saved = localStorage.getItem(BGM_STORAGE_KEY);
<br> if (saved) {
<br> const settings = JSON.parse(saved);
<br> if (typeof settings.volume === 'number') {
<br> currentVolume = settings.volume;
<br> }
<br> if (typeof settings.shuffle === 'boolean') {
<br> isShuffleOn = settings.shuffle;
<br> }
<br> if (['all', 'one', 'none'].includes(settings.repeat)) {
<br> repeatMode = settings.repeat;
<br> }
<br> }
<br> } catch (e) {
<br> }
<br>}
<br>
<br>// 셔플 순서 생성
<br>function generateShuffledOrder() {
<br> if (!playlistData.length) return;
<br>
<br> shuffledOrder = [...Array(playlistData.length).keys()];
<br> // Fisher-Yates 셔플
<br> for (let i = shuffledOrder.length - 1; i > 0; i--) {
<br> const j = Math.floor(Math.random() * (i + 1));
<br> [shuffledOrder[i], shuffledOrder[j]] = [shuffledOrder[j], shuffledOrder[i]];
<br> }
<br>
<br> // 현재 곡을 첫 번째로 이동
<br> const currentPos = shuffledOrder.indexOf(currentTrackIndex);
<br> if (currentPos > 0) {
<br> shuffledOrder.splice(currentPos, 1);
<br> shuffledOrder.unshift(currentTrackIndex);
<br> }
<br> shuffledIndex = 0;
<br>}
<br>
<br>// 셔플 토글
<br>function toggleShuffle() {
<br> isShuffleOn = !isShuffleOn;
<br>
<br> if (isShuffleOn) {
<br> generateShuffledOrder();
<br> }
<br>
<br> updateShuffleUI();
<br> saveBGMSettings();
<br>}
<br>
<br>// 반복 모드 순환
<br>function cycleRepeatMode() {
<br> if (repeatMode === 'all') {
<br> repeatMode = 'one';
<br> } else if (repeatMode === 'one') {
<br> repeatMode = 'none';
<br> } else {
<br> repeatMode = 'all';
<br> }
<br>
<br> updateRepeatUI();
<br> saveBGMSettings();
<br>}
<br>
<br>// 셔플 UI 업데이트
<br>function updateShuffleUI() {
<br> const shuffleBtn = document.getElementById('shuffle-btn');
<br> if (shuffleBtn) {
<br> if (isShuffleOn) {
<br> shuffleBtn.classList.add('active');
<br> } else {
<br> shuffleBtn.classList.remove('active');
<br> }
<br> }
<br>}
<br>
<br>// 반복 UI 업데이트
<br>function updateRepeatUI() {
<br> const repeatBtn = document.getElementById('repeat-btn');
<br> if (!repeatBtn) return;
<br>
<br> repeatBtn.classList.remove('active', 'repeat-one');
<br>
<br> if (repeatMode === 'all') {
<br> repeatBtn.classList.add('active');
<br> repeatBtn.innerHTML = '<i class="fa-solid fa-repeat"></i>';
<br> } else if (repeatMode === 'one') {
<br> repeatBtn.classList.add('active', 'repeat-one');
<br> repeatBtn.innerHTML = '<i class="fa-solid fa-repeat"></i><span class="repeat-one-badge">1</span>';
<br> } else {
<br> repeatBtn.innerHTML = '<i class="fa-solid fa-repeat"></i>';
<br> }
<br>}
<br>
<br>// 볼륨 팝업 토글
<br>function toggleVolume() {
<br> isVolumeOpen = !isVolumeOpen;
<br> const popup = document.getElementById('volume-popup');
<br> if (popup) {
<br> if (isVolumeOpen) {
<br> popup.classList.add('show');
<br> } else {
<br> popup.classList.remove('show');
<br> }
<br> }
<br>}
<br>
<br>// 볼륨 아이콘 업데이트
<br>function updateVolumeIcon() {
<br> const volumeBtn = document.getElementById('volume-btn');
<br> if (!volumeBtn) return;
<br>
<br> let icon = 'fa-volume-high';
<br> if (currentVolume == 0) {
<br> icon = 'fa-volume-xmark';
<br> } else if (currentVolume < 30) {
<br> icon = 'fa-volume-off';
<br> } else if (currentVolume < 70) {
<br> icon = 'fa-volume-low';
<br> }
<br>
<br> volumeBtn.innerHTML = `<i class="fa-solid ${icon}"></i>`;
<br>}
<br>
<br>// Pixel 스킨 볼륨 바 업데이트 함수
<br>function updatePixelVolumeBar(volume) {
<br> const pixelVolBar = document.getElementById('pixel-vol-bar');
<br> if (!pixelVolBar) return;
<br>
<br> const segments = pixelVolBar.querySelectorAll('.vol-seg');
<br> const filledCount = Math.round(volume / 10); // 0-100 → 0-10
<br>
<br> segments.forEach((seg, index) => {
<br> if (index < filledCount) {
<br> seg.classList.add('filled');
<br> } else {
<br> seg.classList.remove('filled');
<br> }
<br> });
<br>}
<br>
<br>// 다음 트랙 인덱스 계산 (셔플/반복 고려)
<br>function getNextTrackIndex() {
<br> if (playlistData.length <= 1) return 0;
<br>
<br> if (repeatMode === 'one') {
<br> return currentTrackIndex;
<br> }
<br>
<br> if (isShuffleOn) {
<br> shuffledIndex++;
<br> if (shuffledIndex >= shuffledOrder.length) {
<br> if (repeatMode === 'none') {
<br> return -1; // 재생 중지
<br> }
<br> shuffledIndex = 0;
<br> generateShuffledOrder(); // 새로운 셔플 순서
<br> }
<br> return shuffledOrder[shuffledIndex];
<br> } else {
<br> const nextIndex = currentTrackIndex + 1;
<br> if (nextIndex >= playlistData.length) {
<br> if (repeatMode === 'none') {
<br> return -1; // 재생 중지
<br> }
<br> return 0;
<br> }
<br> return nextIndex;
<br> }
<br>}
<br>
<br>// 이전 트랙 인덱스 계산 (셔플/반복 고려)
<br>function getPrevTrackIndex() {
<br> if (playlistData.length <= 1) return 0;
<br>
<br> if (isShuffleOn) {
<br> shuffledIndex--;
<br> if (shuffledIndex < 0) {
<br> shuffledIndex = shuffledOrder.length - 1;
<br> }
<br> return shuffledOrder[shuffledIndex];
<br> } else {
<br> return currentTrackIndex > 0 ? currentTrackIndex - 1 : playlistData.length - 1;
<br> }
<br>}
<br>
<br>// 보안 검증 함수들
<br>function validateOrigin() {
<br> // 현재 실행 중인 도메인은 항상 허용 (자동 감지)
<br> const currentOrigin = window.location.hostname;
<br>
<br> // localhost 또는 실제 도메인(점이 포함된 hostname)이면 허용
<br> if (currentOrigin === 'localhost' || currentOrigin.includes('.')) {
<br> return true;
<br> }
<br>
<br> // 그 외에는 거부 (비정상적인 경우)
<br> return false;
<br>}
<br>
<br>// 보안 YouTube API 준비
<br>function onYouTubeIframeAPIReady() {
<br> if (!validateOrigin()) {
<br> return;
<br> }
<br>
<br> initBGMPlayer();
<br>}
<br>
<br>async function initBGMPlayer() {
<br> // 추가 보안 검증
<br> if (!validateOrigin()) {
<br> return;
<br> }
<br>
<br> if (!bgmConfig.youtube) {
<br> return;
<br> }
<br>
<br> // YouTube Player 생성 (보안 강화된 설정)
<br> let playerDiv = document.getElementById('youtube-player');
<br> if (!playerDiv) {
<br> playerDiv = document.createElement('div');
<br> playerDiv.id = 'youtube-player';
<br> playerDiv.style.display = 'none';
<br> document.body.appendChild(playerDiv);
<br> }
<br>
<br> // 보안 강화된 playerVars
<br> // iframe 내부에서 실행될 때 최상위 페이지의 origin 사용
<br> let pageOrigin;
<br> try {
<br> pageOrigin = window.top.location.origin;
<br> } catch(e) {
<br> // cross-origin인 경우 현재 origin 사용
<br> pageOrigin = window.location.origin;
<br> }
<br>
<br> const securePlayerVars = {
<br> autoplay: 0,
<br> controls: 0,
<br> showinfo: 0,
<br> rel: 0,
<br> iv_load_policy: 3,
<br> enablejsapi: 1,
<br> modestbranding: 1,
<br> fs: 0,
<br> cc_load_policy: 0,
<br> disablekb: 1,
<br> origin: pageOrigin
<br> };
<br>
<br> if (bgmConfig.youtube.type === 'playlist') {
<br> player = new YT.Player('youtube-player', {
<br> height: '0',
<br> width: '0',
<br> playerVars: {
<br> ...securePlayerVars,
<br> listType: 'playlist',
<br> list: bgmConfig.youtube.id
<br> },
<br> events: {
<br> 'onReady': onPlayerReady,
<br> 'onStateChange': onPlayerStateChange,
<br> 'onError': onPlayerError
<br> }
<br> });
<br> } else {
<br> player = new YT.Player('youtube-player', {
<br> height: '0',
<br> width: '0',
<br> videoId: bgmConfig.youtube.id,
<br> playerVars: securePlayerVars,
<br> events: {
<br> 'onReady': onPlayerReady,
<br> 'onStateChange': onPlayerStateChange,
<br> 'onError': onPlayerError
<br> }
<br> });
<br> }
<br>}
<br>
<br>function onPlayerReady(event) {
<br>
<br> playerReady = true;
<br> readyToPlay = true;
<br>
<br> // 저장된 설정 복원
<br> loadBGMSettings();
<br>
<br> player.setVolume(currentVolume);
<br>
<br> // 볼륨 슬라이더 동기화
<br> const volumeSlider = document.getElementById('volume-slider');
<br> if (volumeSlider) {
<br> volumeSlider.value = currentVolume;
<br> }
<br>
<br> // 플레이어 표시
<br> setTimeout(() => {
<br> const bgmPlayer = document.getElementById('bgm-player');
<br> if (bgmPlayer) {
<br> bgmPlayer.classList.add('show');
<br> }
<br> }, 500);
<br>
<br> updateTrackInfo();
<br> updateControls();
<br> loadPlaylist();
<br>
<br> // 셔플/반복/볼륨 UI 초기화
<br> updateShuffleUI();
<br> updateRepeatUI();
<br> updateVolumeIcon();
<br>
<br> // 사용자 상호작용 대기
<br> setupUserInteractionListeners();
<br> showBGMNotice();
<br>}
<br>
<br>// 보안 강화된 플레이리스트 로드
<br>async function loadPlaylist() {
<br> if (!playerReady) return;
<br>
<br> try {
<br> const playlist = player.getPlaylist();
<br> if (playlist && playlist.length > 1) {
<br> const toggleBtn = document.getElementById('list-toggle-btn');
<br> if (toggleBtn) {
<br> toggleBtn.style.display = 'flex';
<br> }
<br>
<br> playlistData = playlist;
<br>
<br> // 셔플이 켜져있으면 셔플 순서 생성
<br> if (isShuffleOn) {
<br> generateShuffledOrder();
<br> }
<br>
<br> await loadPlaylistTitles();
<br> updatePlaylistUI();
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 보안 강화된 제목 가져오기
<br>async function fetchVideoTitle(videoId) {
<br> // videoId 검증
<br> if (!videoId || typeof videoId !== 'string' || !/^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
<br> return null;
<br> }
<br>
<br> try {
<br> // 안전한 URL 구성
<br> const safeUrl = `URL
<br>
<br> const response = await fetch(safeUrl, {
<br> method: 'GET',
<br> headers: {
<br> 'Accept': 'application/json',
<br> },
<br> credentials: 'omit',
<br> referrerPolicy: 'no-referrer'
<br> });
<br>
<br> if (!response.ok) {
<br> throw new Error(`HTTP ${response.status}`);
<br> }
<br>
<br> const data = await response.json();
<br>
<br> // 응답 데이터 검증
<br> if (data && typeof data.title === 'string' && data.title.trim() !== '') {
<br> // XSS 방지를 위한 제목 정리
<br> return data.title.replace(/<[^>]*>/g, '').trim();
<br> }
<br>
<br> return null;
<br> } catch (error) {
<br> return null;
<br> }
<br>}
<br>
<br>// 보안 강화된 사용자 상호작용 리스너
<br>function setupUserInteractionListeners() {
<br> const events = ['click', 'touchstart', 'keydown', 'scroll', 'mousemove'];
<br>
<br> const handleUserInteraction = (e) => {
<br> // 이벤트 검증
<br> if (!e || !e.type) {
<br> return;
<br> }
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br>
<br> // 재생 전 최종 보안 검증
<br> if (readyToPlay && player && playerReady ) {
<br> startBGM();
<br> }
<br>
<br> hideBGMNotice();
<br>
<br> events.forEach(event => {
<br> document.removeEventListener(event, handleUserInteraction, true);
<br> });
<br> }
<br> };
<br>
<br> events.forEach(event => {
<br> document.addEventListener(event, handleUserInteraction, true);
<br> });
<br>}
<br>
<br>// 보안 강화된 BGM 시작
<br>function startBGM() {
<br> if (!player || !playerReady) {
<br> return;
<br> }
<br>
<br> try {
<br> player.playVideo();
<br>
<br> setTimeout(() => {
<br> try {
<br> const newState = player.getPlayerState();
<br>
<br> if (newState === YT.PlayerState.PLAYING) {
<br> // 재생 성공
<br> } else if (newState === YT.PlayerState.BUFFERING) {
<br> // 버퍼링 중
<br> } else {
<br> // 재생 실패 시 재시도
<br> setTimeout(() => {
<br> if (true) {
<br> player.playVideo();
<br> }
<br> }, 1000);
<br> }
<br> } catch (error) {
<br> }
<br> }, 500);
<br>
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 보안 강화된 플레이어 상태 변경
<br>function onPlayerStateChange(event) {
<br> const bgmPlayer = document.getElementById('bgm-player');
<br>
<br> if (event.data === YT.PlayerState.PLAYING) {
<br> isPlaying = true;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-pause"></i>';
<br> if (bgmPlayer) bgmPlayer.classList.add('playing');
<br>
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br>
<br> } else if (event.data === YT.PlayerState.PAUSED) {
<br> isPlaying = false;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
<br> if (bgmPlayer) bgmPlayer.classList.remove('playing');
<br> } else if (event.data === YT.PlayerState.ENDED) {
<br> // 셔플/반복 모드에 따른 재생 처리
<br> if (playlistData.length > 1) {
<br> const nextIndex = getNextTrackIndex();
<br>
<br> if (nextIndex === -1) {
<br> // repeatMode === 'none' 이고 마지막 곡인 경우
<br> isPlaying = false;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
<br> return;
<br> }
<br>
<br> currentTrackIndex = nextIndex;
<br>
<br> setTimeout(() => {
<br> try {
<br> player.playVideoAt(currentTrackIndex);
<br> } catch (error) {
<br> player.seekTo(0);
<br> player.playVideo();
<br> }
<br> }, 500);
<br>
<br> updatePlaylistUI();
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } else {
<br> // 단일 곡
<br> if (repeatMode === 'none') {
<br> isPlaying = false;
<br> const playBtn = document.getElementById('play-btn');
<br> if (playBtn) playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
<br> return;
<br> }
<br>
<br> // 1곡 반복 또는 전체 반복 (단일곡은 동일)
<br> setTimeout(() => {
<br> try {
<br> player.seekTo(0);
<br> player.playVideo();
<br> } catch (error) {
<br> }
<br> }, 500);
<br> }
<br> }
<br>
<br> updateControls();
<br>}
<br>
<br>// 보안 강화된 플레이 토글
<br>function togglePlay() {
<br> if (!playerReady || !true) return;
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> }
<br>
<br> try {
<br> if (isPlaying) {
<br> player.pauseVideo();
<br> } else {
<br> player.playVideo();
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function stopTrack() {
<br> if (!playerReady) return;
<br>
<br> try {
<br> player.stopVideo();
<br> isPlaying = false;
<br> updatePlayPauseButton();
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 트랙 이동 함수들 (셔플 지원)
<br>function previousTrack() {
<br> if (!playerReady || playlistData.length <= 1) return;
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> }
<br>
<br> try {
<br> currentTrackIndex = getPrevTrackIndex();
<br> player.playVideoAt(currentTrackIndex);
<br> updatePlaylistUI();
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function nextTrack() {
<br> if (!playerReady || playlistData.length <= 1) return;
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> }
<br>
<br> try {
<br> const nextIndex = getNextTrackIndex();
<br> if (nextIndex === -1) {
<br> // repeatMode === 'none' 마지막 곡
<br> return;
<br> }
<br> currentTrackIndex = nextIndex;
<br> player.playVideoAt(currentTrackIndex);
<br> updatePlaylistUI();
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } catch (error) {
<br> }
<br>}
<br>
<br>async function loadPlaylistTitles() {
<br> // 보안 체크 제거됨
<br>
<br> window.playlistTitles = new Array(playlistData.length).fill(null);
<br>
<br> const titlePromises = playlistData.map(async (videoId, index) => {
<br> try {
<br> const title = await fetchVideoTitle(videoId);
<br> window.playlistTitles[index] = title || `곡 ${index + 1}`;
<br>
<br> if (title) {
<br> updatePlaylistUI();
<br> }
<br> } catch (error) {
<br> window.playlistTitles[index] = `곡 ${index + 1}`;
<br> }
<br> });
<br>
<br> await Promise.all(titlePromises);
<br>}
<br>
<br>function updatePlaylistUI() {
<br> const playlistEl = document.getElementById('playlist');
<br> if (!playlistEl || !playlistData.length) return;
<br>
<br> playlistEl.innerHTML = '';
<br>
<br> playlistData.forEach((videoId, index) => {
<br> const li = document.createElement('li');
<br>
<br> let title;
<br> if (window.playlistTitles && window.playlistTitles[index]) {
<br> // XSS 방지
<br> title = window.playlistTitles[index].replace(/<[^>]*>/g, '');
<br> } else {
<br> title = `곡 ${index + 1} (로딩중...)`;
<br> }
<br>
<br> li.textContent = title;
<br> li.onclick = () => {
<br> // 클릭 시 보안 검증
<br> if (true) {
<br> playTrack(index);
<br> }
<br> };
<br>
<br> if (index === currentTrackIndex) {
<br> li.classList.add('current');
<br> }
<br>
<br> playlistEl.appendChild(li);
<br> });
<br>}
<br>
<br>function playTrack(index) {
<br> if (!playerReady || !playlistData.length || !true) return;
<br>
<br> try {
<br> player.playVideoAt(index);
<br> currentTrackIndex = index;
<br> updatePlaylistUI();
<br>
<br> setTimeout(() => {
<br> updateTrackInfo();
<br> updateCurrentTrackTitle();
<br> }, 2000);
<br> } catch (error) {
<br> }
<br>}
<br>
<br>async function updateCurrentTrackTitle() {
<br> if (!playerReady || !true) return;
<br>
<br> try {
<br> const videoData = player.getVideoData();
<br>
<br> if (videoData && videoData.title && videoData.title.trim() !== '') {
<br> if (window.playlistTitles) {
<br> // XSS 방지
<br> window.playlistTitles[currentTrackIndex] = videoData.title.replace(/<[^>]*>/g, '');
<br> updatePlaylistUI();
<br> }
<br> return;
<br> }
<br>
<br> if (playlistData[currentTrackIndex]) {
<br> const title = await fetchVideoTitle(playlistData[currentTrackIndex]);
<br> if (title && window.playlistTitles) {
<br> window.playlistTitles[currentTrackIndex] = title;
<br> updatePlaylistUI();
<br> }
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function togglePlaylist() {
<br>
<br>
<br> const container = document.getElementById('playlist-container');
<br> const toggleBtn = document.getElementById('list-toggle-btn');
<br>
<br> if (!container || !toggleBtn) return;
<br>
<br> isPlaylistOpen = !isPlaylistOpen;
<br>
<br> if (isPlaylistOpen) {
<br> container.style.display = 'block';
<br> toggleBtn.classList.add('active');
<br> } else {
<br> container.style.display = 'none';
<br> toggleBtn.classList.remove('active');
<br> }
<br>}
<br>
<br>function togglePlayer() {
<br>
<br>
<br> const playerElement = document.getElementById('bgm-player');
<br> if (!playerElement) return;
<br>
<br> isMinimized = !isMinimized;
<br>
<br> if (isMinimized) {
<br> playerElement.classList.add('minimized');
<br> if (isPlaylistOpen) {
<br> togglePlaylist();
<br> }
<br> } else {
<br> playerElement.classList.remove('minimized');
<br> }
<br>}
<br>
<br>function onPlayerError(event) {
<br> const trackInfo = document.getElementById('track-info');
<br> if (trackInfo) {
<br> trackInfo.textContent = '재생 오류 발생';
<br> }
<br>}
<br>
<br>function showBGMNotice() {
<br> if (document.getElementById('bgm-notice')) return;
<br>
<br> const notice = document.createElement('div');
<br> notice.id = 'bgm-notice';
<br> notice.innerHTML = `
<br> <div style="
<br> position: fixed;
<br> bottom: 20px;
<br> left: 20px;
<br> background: rgba(0,0,0,0.9);
<br> color: white;
<br> padding: 12px 16px;
<br> border-radius: 8px;
<br> font-size: 13px;
<br> z-index: 9999;
<br> cursor: pointer;
<br> transition: all 0.3s ease;
<br> box-shadow: 0 4px 12px rgba(0,0,0,0.3);
<br> backdrop-filter: blur(10px);
<br> animation: slideIn 0.3s ease;
<br> " onclick="handleNoticeClick()">
<br> 페이지를 클릭하면 BGM이 재생됩니다
<br> <span style="margin-left: 10px; opacity: 0.7; font-size: 11px;">[클릭하여 닫기]</span>
<br> </div>
<br> <style>
<br> @keyframes slideIn {
<br> from { transform: translateY(100px); opacity: 0; }
<br> to { transform: translateY(0); opacity: 1; }
<br> }
<br> </style>
<br> `;
<br> document.body.appendChild(notice);
<br>
<br> setTimeout(() => {
<br> hideBGMNotice();
<br> }, 10000);
<br>}
<br>
<br>function handleNoticeClick() {
<br> if (!userInteracted) {
<br> userInteracted = true;
<br>
<br> if (readyToPlay && player && playerReady) {
<br> startBGM();
<br> }
<br> }
<br> hideBGMNotice();
<br>}
<br>
<br>function hideBGMNotice() {
<br> const notice = document.getElementById('bgm-notice');
<br> if (notice) {
<br> const noticeEl = notice.firstElementChild;
<br> noticeEl.style.opacity = '0';
<br> noticeEl.style.transform = 'translateY(20px)';
<br> setTimeout(() => {
<br> if (notice.parentNode) {
<br> notice.remove();
<br> }
<br> }, 300);
<br> }
<br>}
<br>
<br>function updateTrackInfo() {
<br> if (!playerReady || !true) return;
<br>
<br> const trackInfo = document.getElementById('track-info');
<br> if (!trackInfo) return;
<br>
<br> try {
<br> const videoData = player.getVideoData();
<br>
<br> if (videoData && videoData.title) {
<br> // XSS 방지를 위한 제목 정리
<br> const safeTitle = videoData.title.replace(/<[^>]*>/g, '');
<br> trackInfo.textContent = safeTitle;
<br> } else {
<br> if (playlistData.length > 1) {
<br> trackInfo.textContent = `곡 ${currentTrackIndex + 1}`;
<br> } else {
<br> trackInfo.textContent = '재생 중...';
<br> }
<br> }
<br>
<br> // 썸네일 업데이트
<br> updateThumbnail();
<br> } catch (error) {
<br> if (playlistData.length > 1) {
<br> trackInfo.textContent = `곡 ${currentTrackIndex + 1}`;
<br> } else {
<br> trackInfo.textContent = '재생 중...';
<br> }
<br> }
<br>}
<br>
<br>// 썸네일 업데이트 함수
<br>function updateThumbnail() {
<br> if (!playerReady) return;
<br>
<br> try {
<br> const videoData = player.getVideoData();
<br> let videoId = null;
<br>
<br> if (videoData && videoData.video_id) {
<br> videoId = videoData.video_id;
<br> } else if (playlistData.length > 0 && playlistData[currentTrackIndex]) {
<br> videoId = playlistData[currentTrackIndex];
<br> }
<br>
<br> if (videoId) {
<br> const thumbnailUrl = `URL
<br>
<br> // CD 스킨 썸네일
<br> const cdThumb = document.getElementById('cd-thumbnail');
<br> if (cdThumb) {
<br> cdThumb.src = thumbnailUrl;
<br> }
<br>
<br> // Vinyl 스킨 썸네일
<br> const vinylThumb = document.getElementById('vinyl-thumbnail');
<br> if (vinylThumb) {
<br> vinylThumb.src = thumbnailUrl;
<br> }
<br>
<br> // Neon 스킨 썸네일
<br> const neonThumb = document.getElementById('neon-thumbnail');
<br> if (neonThumb) {
<br> neonThumb.src = thumbnailUrl;
<br> }
<br>
<br> // Pixel 스킨 썸네일
<br> const pixelThumb = document.getElementById('pixel-thumbnail');
<br> if (pixelThumb) {
<br> pixelThumb.src = thumbnailUrl;
<br> }
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>function updateControls() {
<br> if (!playerReady || !true) return;
<br>
<br> try {
<br> const playlist = player.getPlaylist();
<br> const prevBtn = document.getElementById('prev-btn');
<br> const nextBtn = document.getElementById('next-btn');
<br>
<br> if (prevBtn && nextBtn) {
<br> if (playlist && playlist.length > 1) {
<br> prevBtn.disabled = false;
<br> nextBtn.disabled = false;
<br> } else {
<br> prevBtn.disabled = true;
<br> nextBtn.disabled = true;
<br> }
<br> }
<br> } catch (error) {
<br> }
<br>}
<br>
<br>// 보안 강화된 DOM 로드 이벤트
<br>document.addEventListener('DOMContentLoaded', function() {
<br> // 볼륨 컨트롤 이벤트 (보안 강화)
<br> const volumeSlider = document.getElementById('volume-slider');
<br> if (volumeSlider) {
<br> volumeSlider.addEventListener('input', function() {
<br> // 볼륨 조절 시 보안 검증
<br> // 보안 체크 제거됨
<br>
<br> if (!userInteracted) {
<br> userInteracted = true;
<br> hideBGMNotice();
<br> if (readyToPlay && player && playerReady) {
<br> startBGM();
<br> }
<br> }
<br>
<br> currentVolume = parseInt(this.value);
<br>
<br> if (playerReady && player) {
<br> player.setVolume(currentVolume);
<br> }
<br>
<br> updateVolumeIcon();
<br> saveBGMSettings();
<br>
<br> // Retro 스킨 볼륨 표시 업데이트
<br> const volDisplay = document.getElementById('vol-display');
<br> if (volDisplay) {
<br> volDisplay.textContent = currentVolume + '%';
<br> }
<br>
<br> // Pixel 스킨 볼륨 바 업데이트
<br> updatePixelVolumeBar(currentVolume);
<br> });
<br> }
<br>
<br> // 초기 픽셀 볼륨 바 설정
<br> updatePixelVolumeBar(currentVolume);
<br>
<br> // 외부 클릭 시 볼륨 팝업 닫기
<br> document.addEventListener('click', function(e) {
<br> const volumeControl = document.querySelector('.volume-control');
<br> if (volumeControl && !volumeControl.contains(e.target) && isVolumeOpen) {
<br> isVolumeOpen = false;
<br> const popup = document.getElementById('volume-popup');
<br> if (popup) popup.classList.remove('show');
<br> }
<br> });
<br>
<br> // 보안 YouTube API 로드
<br> if (typeof YT === 'undefined') {
<br> const script = document.createElement('script');
<br> script.src = 'URL';
<br> script.onload = function() {
<br> // YouTube API 로드 성공
<br> };
<br> script.onerror = function() {
<br> };
<br> document.head.appendChild(script);
<br> }
<br>});
<br>
<br>// 페이지 언로드 시 정리
<br>window.addEventListener('beforeunload', function() {
<br> if (player && typeof player.destroy === 'function') {
<br> try {
<br> player.destroy();
<br> } catch (error) {
<br> }
<br> }
<br>}); <br></div>
</details><details class="fold">
<summary>intro.php</summary>
<div class="fold-content"><?php<br>define('_INTRO_', true);<br>include_once('./_common.php');<br><br>// 로그인한 회원은 메인으로 이동<br>if ($is_member) {<br>$main_link = get_main_link();<br><br>// iframe 내부에서는 PostMessage로 메인 이동<br>if (isset($_GET['iframe_skip'])) {<br>echo "<script><br>if (window.parent && window.parent !== window) {<br>window.parent.postMessage({type: 'navigate', url: '".$main_link."'}, '*');<br>} else {<br>location.href = '".$main_link."';<br>}<br></script>";<br>exit;<br>}<br>}<br><br>include_once(G5_PATH.'/head.sub.php');<br>?><br><br><div class="intro-container"><br><div class="site-logo"><br><?php<br>$logo_url = $design['logo_image_url'];<br>$use_logo = $design['use_logo'] == '1';<br>$main_link = get_main_link();<br>?><br><br><a href="<?=$main_link?>" class="logo-link"><br><?php if ($use_logo): ?><br><img src="<?php echo htmlspecialchars($design['logo_image_url'] ?? G5_IMG_URL.'/logo.png'); ?>" alt="<?php echo $config['cf_title']; ?>"><br><?php else: ?><br><span class="logo-text"><?php echo $config['cf_title']; ?></span><br><?php endif; ?><br></a><br></div><br><br><div class="intro-content"><br><?php if ($config['cf_visit'] == '1') { ?><br><!-- 비회원: 로그인 폼 (비공개 설정일 때만 표시) --><br><div class="login_form"><br><form name="flogin" action="<?php echo $login_action_url ?>" onsubmit="return flogin_submit(this);" method="post"><br><fieldset><br><div class="login_input"><br><input type="text" name="mb_id" maxlength="20" placeholder="아이디" required><br><input type="password" name="mb_password" maxlength="20" placeholder="비밀번호" required><br></div><br><button type="submit" class="btn_login">LOGIN</button><br></fieldset><br></form><br><div class="login_links"><br><a href="<?php echo G5_BBS_URL ?>/register.php">회원가입</a><br><a href="<?php echo G5_BBS_URL ?>/password_lost.php">비밀번호 찾기</a><br></div><br></div><br><?php } ?><br></div><br></div><br><br><script><br>function flogin_submit(f) {<br>if (!f.mb_id.value) {<br>alert('아이디를 입력해 주세요.');<br>f.mb_id.focus();<br>return false;<br>}<br><br>if (!f.mb_password.value) {<br>alert('비밀번호를 입력해 주세요.');<br>f.mb_password.focus();<br>return false;<br>}<br><br>return true;<br>}<br></script><br><br><script><br>$(document).ready(function(){<br>// 메뉴 위젯 숨기기<br>parent.$('.tf-menu-widget').hide();<br>parent.$('.tf-menu-mobile-btn').hide();<br>});<br><br>// 페이지 벗어날 때 다시 보이기<br>$(window).on("beforeunload", function(){<br>parent.$('.tf-menu-widget').show();<br>parent.$('.tf-menu-mobile-btn').show();<br>});<br></script><br><br><?php include_once(G5_PATH.'/tail.sub.php'); ?></div>
</details><p><br></p></div>
- 이전글ㄴ 26.06.22
댓글목록
등록된 댓글이 없습니다.