- // ==UserScript==
- // @name INE live player
- // @version 0.2.2
- // @description 디시인사이드 INE 갤러리의 영상을 재생합니다.
- // @author Kak-ine
- // @match https://*.dcinside.com/*
- // @icon https://www.google.com/s2/favicons?sz=64&domain=dcinside.com
- // @grant none
- // @license MIT
- // @namespace https://greatest.deepsurf.us/ko/scripts/523536-dc-streaming
- // ==/UserScript==
-
- // TODO: 디시인사이드 화면 없애고 영상 플레이어만 띄우는 옵션 추가
-
- (async () => {
- 'use strict';
-
- const galleryBaseUrl = 'https://gall.dcinside.com/mini/board/lists?id=ineviolet';
- const maxRetries = 5;
- const keyword = "아이네 -";
- let currentIndex = -1;
- let isHidden = false; // 🔥 숨김 상태 여부 저장
- let shuffledItems = [];
-
- // 🔥 새로 추가: { title: ..., videoUrl: ... } 형태의 배열
- const videoItems = [];
-
- // 📌 랜덤 딜레이 (2~5초)
- const delay = (min = 2000, max = 5000) => new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * (max - min + 1)) + min));
-
- // 📌 도메인 변경 함수 (dcm6 → dcm1)
- function replaceDomain(videoUrl) {
- return videoUrl.replace('dcm6', 'dcm1');
- }
-
- // 📌 최대 페이지 수 자동 추출
- const fetchMaxPageNumber = async (retryCount = 0) => {
- try {
- const response = await fetch(galleryBaseUrl);
- const text = await response.text();
- const parser = new DOMParser();
- const doc = parser.parseFromString(text, 'text/html');
- const totalPageElement = doc.querySelector('span.num.total_page');
- return parseInt(totalPageElement.textContent.trim());
- } catch (error) {
- if (retryCount < maxRetries) {
- await delay();
- return fetchMaxPageNumber(retryCount + 1);
- } else {
- return 1;
- }
- }
- };
-
- // 📌 동영상 링크 추출
- const fetchVideoUrl = async (postUrl, retryCount = 0) => {
- try {
- const response = await fetch(postUrl);
- const text = await response.text();
- const parser = new DOMParser();
- const doc = parser.parseFromString(text, 'text/html');
-
- const iframeElement = doc.querySelector('iframe[id^="movieIcon"]');
- if (iframeElement) {
- const iframeSrc = iframeElement.getAttribute('src');
- const iframeResponse = await fetch(iframeSrc);
- const iframeText = await iframeResponse.text();
- const iframeDoc = parser.parseFromString(iframeText, 'text/html');
- const videoElement = iframeDoc.querySelector('video.dc_mv source');
- return videoElement ? replaceDomain(videoElement.getAttribute('src')) : null;
- }
- return null;
-
- } catch (error) {
- console.warn(`❌ 비디오 링크 수집 실패: ${error.message}, retryCount: ${retryCount}`);
- if (retryCount > 0) {
- retryCount--;
- await delay();
- return fetchVideoUrl(postUrl, retryCount)
- }
- }
- return null;
- };
-
- // 📌 게시글 링크 수집 (순차적 페이지 + 재시도 기능)
- const fetchPostLinksSeq = async (maxPageNumber, retryCount = 0) => {
-
- let i = 0;
- let retry = 0
- for (i = 0; i < maxPageNumber; i++) {
- const PageUrl = `${galleryBaseUrl}&page=${i + 1}`;
-
- try {
- const response = await fetch(PageUrl, {
- headers: { 'User-Agent': navigator.userAgent }
- });
- // await delay();
-
- if (!response.ok) throw new Error(`응답 실패 (상태 코드: ${response.status})`);
-
- const text = await response.text();
- const parser = new DOMParser();
- const doc = parser.parseFromString(text, 'text/html');
-
- const links = doc.querySelectorAll('a[href*="/mini/board/view"]');
- const postLinks = [];
-
- links.forEach(link => {
- const href = link.getAttribute('href');
- const title = link.textContent.trim() || "";
- // 🔥 "아이네" 포함 제목만 수집
- if (href && title.includes(keyword)) {
- // 갤러리 글 주소
- const postUrl = `https://gall.dcinside.com${href}`;
- postLinks.push({ postUrl, title });
- }
- });
-
- if (postLinks.length === 0) throw new Error('게시글 링크를 찾을 수 없음');
- console.log(`📄 ${PageUrl} 페이지에서 ${postLinks.length}개의 게시글 링크 수집 완료`);
-
- // 🔥 각 postUrl에서 videoUrl 추출, videoItems에 저장
- for (const item of postLinks) {
- const videoUrl = await fetchVideoUrl(item.postUrl, retryCount);
- await delay();
- if (videoUrl) {
- videoItems.push({
- title: item.title,
- videoUrl: videoUrl
- });
- console.log(item.title, videoUrl);
- }
- }
- console.log(`📄 ${PageUrl} 페이지에서 ${videoItems.length}개의 동영상 링크 수집 완료`);
-
-
- } catch (error) {
- console.warn(`❌ 게시글 링크 수집 실패: ${error.message}, retryCount: ${retry}`);
- if (retry >= retryCount) {
- retry = 0
- continue
- }
- i--;
- retry++;
- }
- }
- };
-
-
- // 📌 동영상 재생
- function playVideo(videoUrl) {
- const existingVideo = document.getElementById('autoPlayedVideo');
- if (existingVideo) existingVideo.remove();
-
- const videoPlayer = document.createElement('video');
- videoPlayer.id = 'autoPlayedVideo';
- videoPlayer.src = videoUrl;
- videoPlayer.controls = true;
- videoPlayer.autoplay = true;
- videoPlayer.muted = false;
- videoPlayer.volume = 0.5;
- videoPlayer.style.position = 'fixed';
- videoPlayer.style.bottom = '100px';
- videoPlayer.style.right = '20px';
- videoPlayer.style.width = '480px';
- videoPlayer.style.zIndex = 9999;
- videoPlayer.style.boxShadow = '0px 0px 10px rgba(0, 0, 0, 0.5)';
- videoPlayer.style.borderRadius = '10px';
-
- // 📌 숨김 상태일 때 영상도 숨김 처리
- videoPlayer.style.display = isHidden ? 'none' : 'block';
-
- document.body.appendChild(videoPlayer);
-
- videoPlayer.onended = () => {
- playNextVideo(); // 🔥 자동으로 다음 영상 재생
- };
- }
-
- // Fisher–Yates shuffle 예시
- function shuffleArray(array) {
- for (let i = array.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [array[i], array[j]] = [array[j], array[i]];
- }
- }
-
- function shufflePlay() {
- if (shuffledItems.length <= 1) return; // 곡이 1개 이하라면 셔플 불필요
-
- // 1) 현재 재생 중인 곡을 변수에 저장
- const currentTrack = shuffledItems[currentIndex];
-
- // 2) 배열에서 제거
- // (splice로 해당 인덱스의 요소를 추출)
- shuffledItems.splice(currentIndex, 1);
-
- // 3) 나머지 곡들 무작위 셔플
- // (Fisher–Yates 알고리즘 등)
- shuffleArray(shuffledItems);
-
- // 4) 다시 currentIndex 위치에 삽입
- shuffledItems.splice(currentIndex, 0, currentTrack);
-
- // console.log('✅ 셔플(현재 곡 유지) 완료:', shuffledItems.map(item=>item.title));
- // 필요 시 UI 갱신
- createPlaylistUI();
- }
-
- const playPreviousVideo = () => {
- currentIndex--;
- if (currentIndex < 0) {
- console.log("❌ 이전 영상이 없습니다.");
- currentIndex = 0;
- return;
- }
- playVideo(shuffledItems[currentIndex].videoUrl);
-
- createPlaylistUI();
- }
-
- // 📌 다음 영상 재생
- function playNextVideo() {
- // 순서대로 재생하기 위해 currentIndex 증가
-
- currentIndex++;
- // 범위 체크: 인덱스가 videoItems 길이를 초과하면 더 이상 영상 없음
- if (currentIndex >= videoItems.length) {
- currentIndex = 0
- }
-
- // 해당 index의 영상 불러오기
- const item = shuffledItems[currentIndex];
- console.log(`▶ [${currentIndex}] ${item.title} 재생`);
- playVideo(item.videoUrl);
-
- // 🔥 재생 목록 UI 갱신
- createPlaylistUI();
- }
-
- // 📌 재생/일시정지 버튼 상태 토글 (아이콘 변경)
- function togglePlayPause() {
- const video = document.getElementById('autoPlayedVideo');
- const playPauseButton = document.getElementById('playPauseButton');
-
- if (video) {
- if (video.paused) {
- video.play();
- playPauseButton.innerText = '⏸'; // 🔥 일시정지 아이콘으로 변경
- } else {
- video.pause();
- playPauseButton.innerText = '▶'; // 🔥 재생 아이콘으로 변경
- }
- } else {
- playNextVideo();
- playPauseButton.innerText = '⏸';
- // 🔥 재생 목록 UI 갱신
- createPlaylistUI();
- }
- }
-
- function createPlaylistUI() {
- // 기존 UI 제거
- const existing = document.getElementById('playlistContainer');
- if (existing) existing.remove();
-
- const container = document.createElement('div');
- container.id = 'playlistContainer';
- container.style.position = 'fixed';
- container.style.bottom = '10px';
- container.style.padding = '10px';
- container.style.width = '250px';
- container.style.border = '1px solid #ccc';
- container.style.borderRadius = '8px';
- container.style.background = 'rgba(255, 255, 255, 0.8)';
- container.style.zIndex = 9999;
-
- if (isHidden) {
- container.style.right = '70px';
- } else {
- container.style.right = '230px';
- }
-
- // 스크롤 영역 설정
- container.style.maxHeight = '60px';
- container.style.overflowY = 'auto';
- container.style.scrollBehavior = 'smooth'; // 스무스 스크롤
-
- const list = document.createElement('ul');
- list.style.margin = '0';
- list.style.padding = '0 0 0 20px';
-
- let activeLi = null; // 현재 곡에 해당하는 <li>를 저장할 변수
-
- for (let i = 0; i < shuffledItems.length; i++) {
- const item = shuffledItems[i];
- const pli = document.createElement('li');
- const cleanedTitle = item.title.replace(/^아이네 - /, "");
- pli.textContent = cleanedTitle;
-
- // 현재 곡 배경 강조
- if (i === currentIndex) {
- pli.style.backgroundColor = '#cceeff';
- pli.style.fontWeight = 'bold';
- pli.classList.add('activeSong'); // 식별용 클래스
- activeLi = pli; // 아래에서 scrollIntoView()에 사용
- }
-
- // 곡 클릭 시
- pli.addEventListener('click', () => {
- currentIndex = i;
- playVideo(item.videoUrl);
- createPlaylistUI();
- });
-
- list.appendChild(pli);
- }
-
- container.appendChild(list);
- document.body.appendChild(container);
-
- // 📌 UI 생성 후, 현재 곡이 있는 li 위치로 스크롤 이동
- if (activeLi) {
- activeLi.scrollIntoView({
- behavior: 'smooth',
- block: 'center'
- });
- }
- }
-
-
-
- // 📌 Fancy 버튼 컨트롤 패널 + 버튼 디자인 개선
- function createFancyControlPanel() {
- const controlPanel = document.createElement('div');
- controlPanel.id = 'fancyControlPanel';
- controlPanel.style.position = 'fixed';
- controlPanel.style.bottom = '40px';
- controlPanel.style.right = '-250px'; // 📌 숨김 상태
- controlPanel.style.display = 'flex';
- controlPanel.style.gap = '0px';
- controlPanel.style.padding = '5px';
- // controlPanel.style.background = 'rgba(0, 0, 0, 0.3)';
- controlPanel.style.borderRadius = '30px';
- controlPanel.style.boxShadow = '0px 4px 15px rgba(0, 0, 0, 0.3)';
- controlPanel.style.zIndex = '10000';
- controlPanel.style.width = '180px';
- controlPanel.style.transition = 'right 0.3s ease';
-
- // 📌 펼치기 버튼 (📂)
- const expandButton = document.createElement('button');
- expandButton.id = 'expandControlPanel';
- expandButton.innerText = '⬅︎'; // 아이콘 변경
- expandButton.style.position = 'fixed';
- expandButton.style.bottom = '40px';
- expandButton.style.right = '20px';
- expandButton.style.padding = '10px';
- expandButton.style.fontSize = '18px';
- expandButton.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
- expandButton.style.color = '#ffffff';
- // expandButton.style.border = 'none';
- expandButton.style.borderRadius = '50%';
- expandButton.style.cursor = 'pointer';
- expandButton.style.boxShadow = '0px 2px 6px rgba(0, 0, 0, 0.3)';
- expandButton.style.zIndex = '10001';
-
- // 📌 펼치기 버튼 클릭 시 패널 열기
- expandButton.addEventListener('click', () => {
- controlPanel.style.right = '20px';
- expandButton.style.display = 'none';
-
- isHidden = false; // 🔥 숨김 상태 해제
-
- // 🔥 영상도 같이 표시
- const videoPlayer = document.getElementById('autoPlayedVideo');
- if (videoPlayer) {
- videoPlayer.style.display = 'block';
- // 플레이리스트 위치 조절 (isHidden에 의해 위치 조절)
- createPlaylistUI()
- }
- });
-
- // 📌 버튼 목록 (숨기기 버튼 포함)
- const buttons = [
- { id: 'prevVideoButton', text: '⏮', action: playPreviousVideo },
- { id: 'playPauseButton', text: '▶', action: togglePlayPause }, // 🔥 상태에 따라 변경
- { id: 'nextVideoButton', text: '⏭', action: playNextVideo },
- { id: 'nextVideoButton', text: '🔀', action: shufflePlay },
- { id: 'hidePanelButton', text: '➡︎', action: () => { // 📂 숨기기 버튼으로 변경
- controlPanel.style.right = '-250px';
- expandButton.style.display = 'block';
-
- // 🔥 영상도 같이 숨기기
- const videoPlayer = document.getElementById('autoPlayedVideo');
- if (videoPlayer) {
- videoPlayer.style.display = 'none';
- }
- isHidden = true; // 🔥 숨김 상태 유지
-
- // 플레이리스트만 표시되게 갱신 (isHidden에 의해 위치 조절)
- createPlaylistUI()
- }}
- ];
-
- // 📌 버튼 생성 및 디자인 적용
- buttons.forEach(btn => {
- const button = document.createElement('button');
- button.id = btn.id;
- button.innerText = btn.text;
- button.style.width = '45px';
- button.style.height = '45px';
- button.style.fontSize = '20px';
- button.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
- button.style.color = '#000';
- // button.style.border = '1px solid rgba(0, 0, 0, 0.3)';
- button.style.borderRadius = '50%';
- button.style.cursor = 'pointer';
- button.style.boxShadow = 'none';
- button.style.transition = 'transform 0.2s ease, background-color 0.2s ease';
-
- // 📌 버튼 호버 효과 (부드러운 확대 + 색상 변경)
- button.addEventListener('mouseover', () => {
- button.style.transform = 'scale(1.2)';
- //button.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
- button.style.color = '#ffffff';
- });
-
- button.addEventListener('mouseout', () => {
- button.style.transform = 'scale(1)';
- // button.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
- button.style.color = '#000';
- });
-
- button.addEventListener('click', btn.action);
- controlPanel.appendChild(button);
- });
-
- // 📌 버튼 및 패널 추가
- document.body.appendChild(controlPanel);
- document.body.appendChild(expandButton);
- }
-
-
- // ✅ Base64 디코딩 함수
- function decodeBase64(data) {
- return decodeURIComponent(escape(atob(data)));
- }
-
- const response = await fetch('https://kak-ine.github.io/data/videos.json');
- const fetchedItems = await response.json();
-
- // ✅ 배열 전체 디코딩
- const decodedData = fetchedItems.map(item => ({
- title: decodeBase64(item.title),
- videoUrl: decodeBase64(item.videoUrl)
- }));
-
-
- // DONE: Github Action에서 DB에 daily update 하도록 자동화 //
-
- // ///////////////// Depecated ////////////////////////
- // // 미니 갤러리 크롤링하여 비디오 링크 수집
- // const maxPageNumber = await fetchMaxPageNumber();
- // await fetchPostLinksSeq(maxPageNumber, 5);
- // console.log('수집된 videoItems:', videoItems);
- // ///////////////// Depecated ////////////////////////
-
- // DB로 부터 로드
- videoItems.push(...decodedData);
- shuffledItems = videoItems.slice()
- createFancyControlPanel();
- })();