INE live player

디시인사이드 INE 갤러리의 영상을 재생합니다.

  1. // ==UserScript==
  2. // @name INE live player
  3. // @version 0.2.2
  4. // @description 디시인사이드 INE 갤러리의 영상을 재생합니다.
  5. // @author Kak-ine
  6. // @match https://*.dcinside.com/*
  7. // @icon https://www.google.com/s2/favicons?sz=64&domain=dcinside.com
  8. // @grant none
  9. // @license MIT
  10. // @namespace https://greatest.deepsurf.us/ko/scripts/523536-dc-streaming
  11. // ==/UserScript==
  12.  
  13. // TODO: 디시인사이드 화면 없애고 영상 플레이어만 띄우는 옵션 추가
  14.  
  15. (async () => {
  16. 'use strict';
  17.  
  18. const galleryBaseUrl = 'https://gall.dcinside.com/mini/board/lists?id=ineviolet';
  19. const maxRetries = 5;
  20. const keyword = "아이네 -";
  21. let currentIndex = -1;
  22. let isHidden = false; // 🔥 숨김 상태 여부 저장
  23. let shuffledItems = [];
  24.  
  25. // 🔥 새로 추가: { title: ..., videoUrl: ... } 형태의 배열
  26. const videoItems = [];
  27.  
  28. // 📌 랜덤 딜레이 (2~5초)
  29. const delay = (min = 2000, max = 5000) => new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * (max - min + 1)) + min));
  30.  
  31. // 📌 도메인 변경 함수 (dcm6 → dcm1)
  32. function replaceDomain(videoUrl) {
  33. return videoUrl.replace('dcm6', 'dcm1');
  34. }
  35.  
  36. // 📌 최대 페이지 수 자동 추출
  37. const fetchMaxPageNumber = async (retryCount = 0) => {
  38. try {
  39. const response = await fetch(galleryBaseUrl);
  40. const text = await response.text();
  41. const parser = new DOMParser();
  42. const doc = parser.parseFromString(text, 'text/html');
  43. const totalPageElement = doc.querySelector('span.num.total_page');
  44. return parseInt(totalPageElement.textContent.trim());
  45. } catch (error) {
  46. if (retryCount < maxRetries) {
  47. await delay();
  48. return fetchMaxPageNumber(retryCount + 1);
  49. } else {
  50. return 1;
  51. }
  52. }
  53. };
  54.  
  55. // 📌 동영상 링크 추출
  56. const fetchVideoUrl = async (postUrl, retryCount = 0) => {
  57. try {
  58. const response = await fetch(postUrl);
  59. const text = await response.text();
  60. const parser = new DOMParser();
  61. const doc = parser.parseFromString(text, 'text/html');
  62.  
  63. const iframeElement = doc.querySelector('iframe[id^="movieIcon"]');
  64. if (iframeElement) {
  65. const iframeSrc = iframeElement.getAttribute('src');
  66. const iframeResponse = await fetch(iframeSrc);
  67. const iframeText = await iframeResponse.text();
  68. const iframeDoc = parser.parseFromString(iframeText, 'text/html');
  69. const videoElement = iframeDoc.querySelector('video.dc_mv source');
  70. return videoElement ? replaceDomain(videoElement.getAttribute('src')) : null;
  71. }
  72. return null;
  73.  
  74. } catch (error) {
  75. console.warn(`❌ 비디오 링크 수집 실패: ${error.message}, retryCount: ${retryCount}`);
  76. if (retryCount > 0) {
  77. retryCount--;
  78. await delay();
  79. return fetchVideoUrl(postUrl, retryCount)
  80. }
  81. }
  82. return null;
  83. };
  84.  
  85. // 📌 게시글 링크 수집 (순차적 페이지 + 재시도 기능)
  86. const fetchPostLinksSeq = async (maxPageNumber, retryCount = 0) => {
  87.  
  88. let i = 0;
  89. let retry = 0
  90. for (i = 0; i < maxPageNumber; i++) {
  91. const PageUrl = `${galleryBaseUrl}&page=${i + 1}`;
  92.  
  93. try {
  94. const response = await fetch(PageUrl, {
  95. headers: { 'User-Agent': navigator.userAgent }
  96. });
  97. // await delay();
  98.  
  99. if (!response.ok) throw new Error(`응답 실패 (상태 코드: ${response.status})`);
  100.  
  101. const text = await response.text();
  102. const parser = new DOMParser();
  103. const doc = parser.parseFromString(text, 'text/html');
  104.  
  105. const links = doc.querySelectorAll('a[href*="/mini/board/view"]');
  106. const postLinks = [];
  107.  
  108. links.forEach(link => {
  109. const href = link.getAttribute('href');
  110. const title = link.textContent.trim() || "";
  111. // 🔥 "아이네" 포함 제목만 수집
  112. if (href && title.includes(keyword)) {
  113. // 갤러리 글 주소
  114. const postUrl = `https://gall.dcinside.com${href}`;
  115. postLinks.push({ postUrl, title });
  116. }
  117. });
  118.  
  119. if (postLinks.length === 0) throw new Error('게시글 링크를 찾을 수 없음');
  120. console.log(`📄 ${PageUrl} 페이지에서 ${postLinks.length}개의 게시글 링크 수집 완료`);
  121.  
  122. // 🔥 각 postUrl에서 videoUrl 추출, videoItems에 저장
  123. for (const item of postLinks) {
  124. const videoUrl = await fetchVideoUrl(item.postUrl, retryCount);
  125. await delay();
  126. if (videoUrl) {
  127. videoItems.push({
  128. title: item.title,
  129. videoUrl: videoUrl
  130. });
  131. console.log(item.title, videoUrl);
  132. }
  133. }
  134. console.log(`📄 ${PageUrl} 페이지에서 ${videoItems.length}개의 동영상 링크 수집 완료`);
  135.  
  136.  
  137. } catch (error) {
  138. console.warn(`❌ 게시글 링크 수집 실패: ${error.message}, retryCount: ${retry}`);
  139. if (retry >= retryCount) {
  140. retry = 0
  141. continue
  142. }
  143. i--;
  144. retry++;
  145. }
  146. }
  147. };
  148.  
  149.  
  150. // 📌 동영상 재생
  151. function playVideo(videoUrl) {
  152. const existingVideo = document.getElementById('autoPlayedVideo');
  153. if (existingVideo) existingVideo.remove();
  154.  
  155. const videoPlayer = document.createElement('video');
  156. videoPlayer.id = 'autoPlayedVideo';
  157. videoPlayer.src = videoUrl;
  158. videoPlayer.controls = true;
  159. videoPlayer.autoplay = true;
  160. videoPlayer.muted = false;
  161. videoPlayer.volume = 0.5;
  162. videoPlayer.style.position = 'fixed';
  163. videoPlayer.style.bottom = '100px';
  164. videoPlayer.style.right = '20px';
  165. videoPlayer.style.width = '480px';
  166. videoPlayer.style.zIndex = 9999;
  167. videoPlayer.style.boxShadow = '0px 0px 10px rgba(0, 0, 0, 0.5)';
  168. videoPlayer.style.borderRadius = '10px';
  169.  
  170. // 📌 숨김 상태일 때 영상도 숨김 처리
  171. videoPlayer.style.display = isHidden ? 'none' : 'block';
  172.  
  173. document.body.appendChild(videoPlayer);
  174.  
  175. videoPlayer.onended = () => {
  176. playNextVideo(); // 🔥 자동으로 다음 영상 재생
  177. };
  178. }
  179.  
  180. // Fisher–Yates shuffle 예시
  181. function shuffleArray(array) {
  182. for (let i = array.length - 1; i > 0; i--) {
  183. const j = Math.floor(Math.random() * (i + 1));
  184. [array[i], array[j]] = [array[j], array[i]];
  185. }
  186. }
  187.  
  188. function shufflePlay() {
  189. if (shuffledItems.length <= 1) return; // 곡이 1개 이하라면 셔플 불필요
  190.  
  191. // 1) 현재 재생 중인 곡을 변수에 저장
  192. const currentTrack = shuffledItems[currentIndex];
  193.  
  194. // 2) 배열에서 제거
  195. // (splice로 해당 인덱스의 요소를 추출)
  196. shuffledItems.splice(currentIndex, 1);
  197.  
  198. // 3) 나머지 곡들 무작위 셔플
  199. // (Fisher–Yates 알고리즘 등)
  200. shuffleArray(shuffledItems);
  201.  
  202. // 4) 다시 currentIndex 위치에 삽입
  203. shuffledItems.splice(currentIndex, 0, currentTrack);
  204.  
  205. // console.log('✅ 셔플(현재 곡 유지) 완료:', shuffledItems.map(item=>item.title));
  206. // 필요 시 UI 갱신
  207. createPlaylistUI();
  208. }
  209.  
  210. const playPreviousVideo = () => {
  211. currentIndex--;
  212. if (currentIndex < 0) {
  213. console.log("❌ 이전 영상이 없습니다.");
  214. currentIndex = 0;
  215. return;
  216. }
  217. playVideo(shuffledItems[currentIndex].videoUrl);
  218.  
  219. createPlaylistUI();
  220. }
  221.  
  222. // 📌 다음 영상 재생
  223. function playNextVideo() {
  224. // 순서대로 재생하기 위해 currentIndex 증가
  225.  
  226. currentIndex++;
  227. // 범위 체크: 인덱스가 videoItems 길이를 초과하면 더 이상 영상 없음
  228. if (currentIndex >= videoItems.length) {
  229. currentIndex = 0
  230. }
  231.  
  232. // 해당 index의 영상 불러오기
  233. const item = shuffledItems[currentIndex];
  234. console.log(`▶ [${currentIndex}] ${item.title} 재생`);
  235. playVideo(item.videoUrl);
  236.  
  237. // 🔥 재생 목록 UI 갱신
  238. createPlaylistUI();
  239. }
  240.  
  241. // 📌 재생/일시정지 버튼 상태 토글 (아이콘 변경)
  242. function togglePlayPause() {
  243. const video = document.getElementById('autoPlayedVideo');
  244. const playPauseButton = document.getElementById('playPauseButton');
  245.  
  246. if (video) {
  247. if (video.paused) {
  248. video.play();
  249. playPauseButton.innerText = '⏸'; // 🔥 일시정지 아이콘으로 변경
  250. } else {
  251. video.pause();
  252. playPauseButton.innerText = '▶'; // 🔥 재생 아이콘으로 변경
  253. }
  254. } else {
  255. playNextVideo();
  256. playPauseButton.innerText = '⏸';
  257. // 🔥 재생 목록 UI 갱신
  258. createPlaylistUI();
  259. }
  260. }
  261.  
  262. function createPlaylistUI() {
  263. // 기존 UI 제거
  264. const existing = document.getElementById('playlistContainer');
  265. if (existing) existing.remove();
  266.  
  267. const container = document.createElement('div');
  268. container.id = 'playlistContainer';
  269. container.style.position = 'fixed';
  270. container.style.bottom = '10px';
  271. container.style.padding = '10px';
  272. container.style.width = '250px';
  273. container.style.border = '1px solid #ccc';
  274. container.style.borderRadius = '8px';
  275. container.style.background = 'rgba(255, 255, 255, 0.8)';
  276. container.style.zIndex = 9999;
  277.  
  278. if (isHidden) {
  279. container.style.right = '70px';
  280. } else {
  281. container.style.right = '230px';
  282. }
  283.  
  284. // 스크롤 영역 설정
  285. container.style.maxHeight = '60px';
  286. container.style.overflowY = 'auto';
  287. container.style.scrollBehavior = 'smooth'; // 스무스 스크롤
  288.  
  289. const list = document.createElement('ul');
  290. list.style.margin = '0';
  291. list.style.padding = '0 0 0 20px';
  292.  
  293. let activeLi = null; // 현재 곡에 해당하는 <li>를 저장할 변수
  294.  
  295. for (let i = 0; i < shuffledItems.length; i++) {
  296. const item = shuffledItems[i];
  297. const pli = document.createElement('li');
  298. const cleanedTitle = item.title.replace(/^아이네 - /, "");
  299. pli.textContent = cleanedTitle;
  300.  
  301. // 현재 곡 배경 강조
  302. if (i === currentIndex) {
  303. pli.style.backgroundColor = '#cceeff';
  304. pli.style.fontWeight = 'bold';
  305. pli.classList.add('activeSong'); // 식별용 클래스
  306. activeLi = pli; // 아래에서 scrollIntoView()에 사용
  307. }
  308.  
  309. // 곡 클릭 시
  310. pli.addEventListener('click', () => {
  311. currentIndex = i;
  312. playVideo(item.videoUrl);
  313. createPlaylistUI();
  314. });
  315.  
  316. list.appendChild(pli);
  317. }
  318.  
  319. container.appendChild(list);
  320. document.body.appendChild(container);
  321.  
  322. // 📌 UI 생성 후, 현재 곡이 있는 li 위치로 스크롤 이동
  323. if (activeLi) {
  324. activeLi.scrollIntoView({
  325. behavior: 'smooth',
  326. block: 'center'
  327. });
  328. }
  329. }
  330.  
  331.  
  332.  
  333. // 📌 Fancy 버튼 컨트롤 패널 + 버튼 디자인 개선
  334. function createFancyControlPanel() {
  335. const controlPanel = document.createElement('div');
  336. controlPanel.id = 'fancyControlPanel';
  337. controlPanel.style.position = 'fixed';
  338. controlPanel.style.bottom = '40px';
  339. controlPanel.style.right = '-250px'; // 📌 숨김 상태
  340. controlPanel.style.display = 'flex';
  341. controlPanel.style.gap = '0px';
  342. controlPanel.style.padding = '5px';
  343. // controlPanel.style.background = 'rgba(0, 0, 0, 0.3)';
  344. controlPanel.style.borderRadius = '30px';
  345. controlPanel.style.boxShadow = '0px 4px 15px rgba(0, 0, 0, 0.3)';
  346. controlPanel.style.zIndex = '10000';
  347. controlPanel.style.width = '180px';
  348. controlPanel.style.transition = 'right 0.3s ease';
  349.  
  350. // 📌 펼치기 버튼 (📂)
  351. const expandButton = document.createElement('button');
  352. expandButton.id = 'expandControlPanel';
  353. expandButton.innerText = '⬅︎'; // 아이콘 변경
  354. expandButton.style.position = 'fixed';
  355. expandButton.style.bottom = '40px';
  356. expandButton.style.right = '20px';
  357. expandButton.style.padding = '10px';
  358. expandButton.style.fontSize = '18px';
  359. expandButton.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
  360. expandButton.style.color = '#ffffff';
  361. // expandButton.style.border = 'none';
  362. expandButton.style.borderRadius = '50%';
  363. expandButton.style.cursor = 'pointer';
  364. expandButton.style.boxShadow = '0px 2px 6px rgba(0, 0, 0, 0.3)';
  365. expandButton.style.zIndex = '10001';
  366.  
  367. // 📌 펼치기 버튼 클릭 시 패널 열기
  368. expandButton.addEventListener('click', () => {
  369. controlPanel.style.right = '20px';
  370. expandButton.style.display = 'none';
  371.  
  372. isHidden = false; // 🔥 숨김 상태 해제
  373.  
  374. // 🔥 영상도 같이 표시
  375. const videoPlayer = document.getElementById('autoPlayedVideo');
  376. if (videoPlayer) {
  377. videoPlayer.style.display = 'block';
  378. // 플레이리스트 위치 조절 (isHidden에 의해 위치 조절)
  379. createPlaylistUI()
  380. }
  381. });
  382.  
  383. // 📌 버튼 목록 (숨기기 버튼 포함)
  384. const buttons = [
  385. { id: 'prevVideoButton', text: '⏮', action: playPreviousVideo },
  386. { id: 'playPauseButton', text: '▶', action: togglePlayPause }, // 🔥 상태에 따라 변경
  387. { id: 'nextVideoButton', text: '⏭', action: playNextVideo },
  388. { id: 'nextVideoButton', text: '🔀', action: shufflePlay },
  389. { id: 'hidePanelButton', text: '➡︎', action: () => { // 📂 숨기기 버튼으로 변경
  390. controlPanel.style.right = '-250px';
  391. expandButton.style.display = 'block';
  392.  
  393. // 🔥 영상도 같이 숨기기
  394. const videoPlayer = document.getElementById('autoPlayedVideo');
  395. if (videoPlayer) {
  396. videoPlayer.style.display = 'none';
  397. }
  398. isHidden = true; // 🔥 숨김 상태 유지
  399.  
  400. // 플레이리스트만 표시되게 갱신 (isHidden에 의해 위치 조절)
  401. createPlaylistUI()
  402. }}
  403. ];
  404.  
  405. // 📌 버튼 생성 및 디자인 적용
  406. buttons.forEach(btn => {
  407. const button = document.createElement('button');
  408. button.id = btn.id;
  409. button.innerText = btn.text;
  410. button.style.width = '45px';
  411. button.style.height = '45px';
  412. button.style.fontSize = '20px';
  413. button.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
  414. button.style.color = '#000';
  415. // button.style.border = '1px solid rgba(0, 0, 0, 0.3)';
  416. button.style.borderRadius = '50%';
  417. button.style.cursor = 'pointer';
  418. button.style.boxShadow = 'none';
  419. button.style.transition = 'transform 0.2s ease, background-color 0.2s ease';
  420.  
  421. // 📌 버튼 호버 효과 (부드러운 확대 + 색상 변경)
  422. button.addEventListener('mouseover', () => {
  423. button.style.transform = 'scale(1.2)';
  424. //button.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
  425. button.style.color = '#ffffff';
  426. });
  427.  
  428. button.addEventListener('mouseout', () => {
  429. button.style.transform = 'scale(1)';
  430. // button.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
  431. button.style.color = '#000';
  432. });
  433.  
  434. button.addEventListener('click', btn.action);
  435. controlPanel.appendChild(button);
  436. });
  437.  
  438. // 📌 버튼 및 패널 추가
  439. document.body.appendChild(controlPanel);
  440. document.body.appendChild(expandButton);
  441. }
  442.  
  443.  
  444. // ✅ Base64 디코딩 함수
  445. function decodeBase64(data) {
  446. return decodeURIComponent(escape(atob(data)));
  447. }
  448.  
  449. const response = await fetch('https://kak-ine.github.io/data/videos.json');
  450. const fetchedItems = await response.json();
  451.  
  452. // ✅ 배열 전체 디코딩
  453. const decodedData = fetchedItems.map(item => ({
  454. title: decodeBase64(item.title),
  455. videoUrl: decodeBase64(item.videoUrl)
  456. }));
  457.  
  458.  
  459. // DONE: Github Action에서 DB에 daily update 하도록 자동화 //
  460.  
  461. // ///////////////// Depecated ////////////////////////
  462. // // 미니 갤러리 크롤링하여 비디오 링크 수집
  463. // const maxPageNumber = await fetchMaxPageNumber();
  464. // await fetchPostLinksSeq(maxPageNumber, 5);
  465. // console.log('수집된 videoItems:', videoItems);
  466. // ///////////////// Depecated ////////////////////////
  467.  
  468. // DB로 부터 로드
  469. videoItems.push(...decodedData);
  470. shuffledItems = videoItems.slice()
  471. createFancyControlPanel();
  472. })();