diff --git a/package-lock.json b/package-lock.json index 1566cca..35fc687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "chart.js": "^4.5.0", "dashjs": "^5.0.0", "hammerjs": "^2.0.8", - "plyr": "^3.7.8", + "plyr": "^3.8.4", "tw-elements": "^1.1.0", "vidstack": "^1.12.13" }, @@ -3132,6 +3132,15 @@ "postcss": "^8.0.9" } }, + "node_modules/tw-elements/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/unplugin": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", @@ -3349,15 +3358,6 @@ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" - }, - "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "engines": { - "node": ">= 6" - } } } } diff --git a/package.json b/package.json index 49ccc36..1f06027 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "chart.js": "^4.5.0", "dashjs": "^5.0.0", "hammerjs": "^2.0.8", - "plyr": "^3.7.8", + "plyr": "^3.8.4", "tw-elements": "^1.1.0", "vidstack": "^1.12.13" } diff --git a/resources/css/player.css b/resources/css/player.css index d9812aa..7d7b78c 100644 --- a/resources/css/player.css +++ b/resources/css/player.css @@ -645,6 +645,82 @@ to { transform: rotate(360deg); } } +/* ---- Player Switcher Toggle ---- */ +.player-switcher { + position: absolute; + top: 12px; + right: 12px; + z-index: 100; + display: flex; + align-items: center; + gap: 8px; + background: rgba(10, 10, 10, 0.65); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 9999px; + padding: 6px 4px 6px 14px; + transition: all 0.3s ease; + opacity: 0; + pointer-events: none; +} + +.aspect-\[16\/9\]:hover .player-switcher, +.player-switcher--visible { + opacity: 1; + pointer-events: auto; +} + +.player-switcher__label { + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.player-switcher__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 14px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 9999px; + color: rgba(255, 255, 255, 0.85); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: 'Figtree', sans-serif; + white-space: nowrap; +} + +.player-switcher__btn:hover { + background: rgba(198, 30, 84, 0.2); + border-color: rgba(198, 30, 84, 0.4); + color: #fff; +} + +.player-switcher__btn i { + font-size: 11px; +} + +.player-switcher__btn--active { + background: rgba(198, 30, 84, 0.25); + border-color: rgba(198, 30, 84, 0.5); + color: #fff; +} + +.player-switcher__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #c61e54; + box-shadow: 0 0 6px rgba(198, 30, 84, 0.6); +} + /* ---- Fullscreen (remove rounded borders) ---- */ .hstream-player:fullscreen, .hstream-player:-webkit-full-screen { diff --git a/resources/js/player-plyr.js b/resources/js/player-plyr.js new file mode 100644 index 0000000..10e3d35 --- /dev/null +++ b/resources/js/player-plyr.js @@ -0,0 +1,436 @@ +// Plyr Fallback Player +import Plyr from 'plyr'; +import 'plyr/dist/plyr.css'; + +import * as dashjs from 'dashjs'; +import SubtitlesOctopus from '@jellyfin/libass-wasm'; + +import { initMobileWidescreen } from './player-mobile'; +import { mobileDoubleClick } from './player-mobile'; +import { playNextPlaylistVideo } from './playlist'; +import { addVideoTracks } from './player-data'; +import { addSubtitleTracks } from './player-data'; +import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select'; +import { isIOS } from './detect-ios'; +import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement'; +import { renderHeatmap } from './player/player-heatmap'; + +var player = null; +var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"')); +var dashSupported = dashjs.supportsMediaSource(); +var apiResponse = {}; +var volume = 0.5; +var muted = false; +var captions = true; +var lastTime = 0.0; +var streamServer = ''; +var streamServers = []; +var streamServerIndex = 0; +var streamServerCount = 0; +var ambientMode = true; +var serverFallback = false; +var saveInterval; +var watchTracked = false; +var subtitleInstance = null; + +function trackWatchTime() { + if (watchTracked) return; + var video = document.getElementsByTagName('video')[0]; + if (video && video.currentTime >= 10) { + watchTracked = true; + var episodeId = document.getElementById('e_id').value; + window.axios.post('/watched/track', { + episode_id: episodeId + }).then(function () { + console.log('Watch tracked for episode ' + episodeId); + }).catch(function (error) { + console.error('Failed to track watch: ' + error); + }); + } +} + +var controls = [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'duration', + 'mute', + 'volume', + 'captions', + 'settings', + 'fullscreen', +]; + +if (localStorage.hstreamVolume) { + volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2); + console.log('Loaded Audio Volume from Local Storage: ' + volume); +} + +if (localStorage.hstreamCaptions) { + captions = (localStorage.getItem('hstreamCaptions') == 'true'); + console.log('Loaded Captions Status from Local Storage: ' + captions); +} + +if (localStorage.hstreamMuted) { + muted = (localStorage.getItem('hstreamMuted') == 'true'); + console.log('Loaded Muted Status from Local Storage: ' + muted); +} + +if (localStorage.hstreamServerFallback) { + serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true'); + console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback); +} + +if (!av1Supported) { + document.getElementById('av1-unsupported').classList.remove('hidden'); +} + +function initDash(data, player) { + const video = document.querySelector('video'); + + data.forEach(function (el) { + if (el.mode === 'mpd' && el.size === player.config.quality.selected) { + const dash = dashjs.MediaPlayer().create(); + dash.initialize(video, el.src, true); + window.player = player; + window.dash = dash; + } + }); +} + +function setCanvasDimension(canvas, video) { + canvas.height = video.offsetHeight; + canvas.width = video.offsetWidth; +} + +function paintStaticVideo(ctx, video) { + if (localStorage.theme == 'light') { + return; + } + if (!ambientMode) { + return; + } + ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight); +} + +function toggleAmbientMode() { + let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0]; + if (ambientMode) { + ambientMode = false; + localStorage.ambientMode = 'false'; + setCanvasDimension(canvas, video); + document.getElementById('ambient-mode-toggle').innerHTML = 'Ambient Mode'; + } else { + ambientMode = true; + localStorage.ambientMode = 'true'; + setCanvasDimension(canvas, video); + paintStaticVideo(ctx, video); + document.getElementById('ambient-mode-toggle').innerHTML = 'Ambient Mode'; + } +} + +function toggleAsiaServer() { + if (serverFallback) { + serverFallback = false; + localStorage.hstreamServerFallback = 'false'; + document.getElementById('server-fallback-toggle').innerHTML = 'Fallback Server'; + streamServers = apiResponse.stream_domains; + } else { + serverFallback = true; + localStorage.hstreamServerFallback = 'true'; + document.getElementById('server-fallback-toggle').innerHTML = 'Fallback Server'; + streamServers = apiResponse.asia_stream_domains; + } + + streamServerCount = streamServers.length; + streamServerIndex = Math.floor(Math.random() * streamServerCount); + streamServer = streamServers[streamServerIndex]; + console.log('Selected Server: ' + streamServer); + + if (player) { + clearInterval(saveInterval); + stopEngagementTracking(); + player.destroy(); + } + initPlayer(); +} + +function initSubtitles(lang) { + if (isIOS()) { + return; + } + + if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) { + subtitleInstance.dispose(); + } + + let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/'; + + if (lang != 'en') { + newSubUrl += 'autotrans/' + lang + '.ass'; + } else { + newSubUrl += 'eng.ass'; + } + + let subFont = '/fonts/Figtree-ExtraBold.woff2'; + if (lang == 'hi') { + subFont = '/fonts/Hind-SemiBold.ttf'; + } + + var options = { + video: document.getElementsByTagName('video')[0], + subUrl: newSubUrl, + workerUrl: '/build/js/subtitles-octopus-worker.js', + legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', + fonts: [subFont], + renderMode: 'wasm-blend', + }; + + subtitleInstance = new SubtitlesOctopus(options); +} + +function initPlayer() { + player = new Plyr('#player', { + controls, + quality: { + default: 720, + options: [2161, 2160, 1081, 1080, 720] + }, + i18n: { + qualityLabel: { + 2161: '2160p48', + 2160: '2160p', + 1081: '1080p48', + 1080: '1080p', + 720: '720p' + }, + qualityBadge: { + 2161: 'UHD@48', + 1081: 'FHD@48', + 1080: 'FHD', + }, + }, + fullscreen: { enabled: true, fallback: true, iosNative: true } + }); + + var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported); + + player.source = { + type: 'video', + title: apiResponse.title, + poster: apiResponse.poster, + previewThumbnails: { + enabled: true, + src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt', + }, + sources: data, + tracks: addSubtitleTracks(streamServer, apiResponse) + }; + + player.volume = volume; + player.muted = muted; + player.captions.language = 'en'; + player.captions.active = captions; + + if (dashSupported && !apiResponse.legacy) { + player.on('qualitychange', () => { + initDash(data, player); + }); + + initDash(data, player); + } + + let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0]; + setCanvasDimension(canvas, video); + paintStaticVideo(ctx, video); + + var allItems = document.getElementsByClassName('plyr__control--forward'); + var lastItem = allItems[allItems.length - 1]; + lastItem.insertAdjacentHTML('afterend', ''); + document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode); + + if (localStorage.ambientMode == 'false') { + toggleAmbientMode(); + } + + lastItem = allItems[allItems.length - 1]; + let value = 'Off'; + if (serverFallback) { value = 'On'; } + lastItem.insertAdjacentHTML('afterend', ''); + document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer); + + var clickedPlay = false; + + player.on('play', () => { + if (!clickedPlay) { + player.stop(); + console.log('Stopped video, because user didn\'t click play.'); + } + + const episodeId = document.getElementById('e_id').value; + startEngagementTracking(episodeId); + + setCanvasDimension(canvas, video); + console.log('Play => Function Loop()'); + var $this = video; + (function loop() { + if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) { + ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight); + setTimeout(loop, 24000 / 1001); + } + })(); + }); + + player.on('seeked', () => { + paintStaticVideo(ctx, video); + if (player.currentTime > 0) { + lastTime = player.currentTime; + } + console.log('Seeked => paintStaticVideo() at ' + player.currentTime); + }); + + window.addEventListener('resize', () => { + setCanvasDimension(canvas, video); + if (player.paused) { + paintStaticVideo(ctx, video); + } + }); + + player.on('captionsenabled', () => { + document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible'; + localStorage.setItem('hstreamCaptions', 'true'); + console.log('Set Captions Status to Local Storage: true'); + }); + + player.on('captionsdisabled', () => { + document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden'; + localStorage.setItem('hstreamCaptions', 'false'); + console.log('Set Captions Status to Local Storage: false'); + }); + + player.on('volumechange', () => { + console.log('Saving Audio Volume to Local Storage: ' + player.volume); + localStorage.setItem('hstreamVolume', player.volume.toString()); + console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString()); + localStorage.setItem('hstreamMuted', player.muted.toString()); + }); + + player.on('ended', () => { + playNextPlaylistVideo(); + }); + + player.on('timeupdate', () => { + trackWatchTime(); + }); + + player.on('languagechange', (event) => { + let lang = event.detail.plyr.captions.language; + + console.log('Subtitle Event ' + lang); + initSubtitles(lang); + }); + + function playerPlayTemp() { + clickedPlay = true; + } + + document.querySelectorAll('[data-plyr="play"]').forEach(play => + play.addEventListener('click', playerPlayTemp) + ); + + document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp); + + initMobileWidescreen(); + + setTimeout(function () { + const params = new URLSearchParams(window.location.search); + const time = parseInt(params.get('t')); + if (!isNaN(time)) { + player.currentTime = time; + console.log('Skipping to ' + time); + } + if (lastTime > 0) { + player.currentTime = lastTime; + console.log('Skipping to ' + lastTime); + } + }, 500); + + player.on('ready', () => { + mobileDoubleClick(player); + + const video = document.querySelector('video'); + const episodeId = document.getElementById('e_id').value; + if (video && video.duration) { + renderHeatmap(episodeId, video.duration); + } else if (video) { + video.addEventListener('loadedmetadata', function onMeta() { + video.removeEventListener('loadedmetadata', onMeta); + renderHeatmap(episodeId, video.duration); + }); + } + }); + + var settingElements = document.getElementsByClassName('plyr__control--forward'); + if (settingElements.length == 3) { + settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex)); + + var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes; + if (settingNodes.length == 4) { + document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount)); + } + + document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle); + document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle); + let serverSelects = document.getElementsByClassName('change_server'); + for (let i = 0; i < serverSelects.length; i++) { + serverSelects[i].addEventListener('click', function () { + streamServerIndex = Number(this.value); + streamServer = streamServers[streamServerIndex]; + console.log('Selected Server: ' + streamServer); + + if (player) { + clearInterval(saveInterval); + stopEngagementTracking(); + player.destroy(); + } + initPlayer(); + }); + } + } + + saveInterval = setInterval(function () { + lastTime = player.currentTime; + console.log('Last Player Position: ' + lastTime); + }, 10000); +} + +export function initPlyrPlayer(episodeId) { + window.axios.post('/player/api', { + episode_id: episodeId + }).then(function (response) { + if (response.status == 200) { + apiResponse = response.data; + streamServers = apiResponse.stream_domains; + + if (serverFallback) { + streamServers = apiResponse.asia_stream_domains; + } + + streamServerCount = streamServers.length; + streamServerIndex = Math.floor(Math.random() * streamServerCount); + streamServer = streamServers[streamServerIndex]; + console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex); + + initPlayer(); + } + }).catch(function (error) { + var alert = document.getElementById('player-alert'); + if (alert) { + alert.innerText = 'The player encountered a problem: ' + error; + alert.classList.remove('hidden'); + } + }); + + return player; +} diff --git a/resources/js/player.js b/resources/js/player.js index d0a436c..a5d9bed 100644 --- a/resources/js/player.js +++ b/resources/js/player.js @@ -268,38 +268,49 @@ async function initVidstackPlayer() { }); } -window.axios.post('/player/api', { - episode_id: document.getElementById('e_id').value -}).then(function (response) { - if (response.status === 200) { - apiResponse = response.data; - streamServers = apiResponse.stream_domains || []; - fallbackServers = apiResponse.asia_stream_domains || []; +window.setPlayerPreference = function(pref) { + localStorage.setItem('hstreamPlayerPreference', pref); + window.location.reload(); +}; - const cdnCount = streamServers.length; - if (cdnCount > 0) { - streamServerIndex = Math.floor(Math.random() * cdnCount); - streamServer = streamServers[streamServerIndex]; - } else { - const fallbackCount = fallbackServers.length; - streamServerIndex = Math.floor(Math.random() * fallbackCount); - streamServer = fallbackServers[streamServerIndex]; +const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream'; + +if (playerPreference === 'plyr' && !isIOS()) { + import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value)); +} else { + window.axios.post('/player/api', { + episode_id: document.getElementById('e_id').value + }).then(function (response) { + if (response.status === 200) { + apiResponse = response.data; + streamServers = apiResponse.stream_domains || []; + fallbackServers = apiResponse.asia_stream_domains || []; + + const cdnCount = streamServers.length; + if (cdnCount > 0) { + streamServerIndex = Math.floor(Math.random() * cdnCount); + streamServer = streamServers[streamServerIndex]; + } else { + const fallbackCount = fallbackServers.length; + streamServerIndex = Math.floor(Math.random() * fallbackCount); + streamServer = fallbackServers[streamServerIndex]; + } + + streamServerCount = streamServers.length + fallbackServers.length; + console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex); + + if (!isIOS()) { + initPlayer(); + } else { + console.log('Detected Apple device. Using Vidstack fallback player.'); + initVidstackPlayer(); + } } - - streamServerCount = streamServers.length + fallbackServers.length; - console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex); - - if (!isIOS()) { - initPlayer(); - } else { - console.log('Detected Apple device. Using Vidstack fallback player.'); - initVidstackPlayer(); + }).catch(function (error) { + var alert = document.getElementById('player-alert'); + if (alert) { + alert.innerText = 'The player encountered a problem: ' + error; + alert.classList.remove('hidden'); } - } -}).catch(function (error) { - var alert = document.getElementById('player-alert'); - if (alert) { - alert.innerText = 'The player encountered a problem: ' + error; - alert.classList.remove('hidden'); - } -}); + }); +} diff --git a/resources/views/stream/partials/player.blade.php b/resources/views/stream/partials/player.blade.php index 134d815..b94fd76 100644 --- a/resources/views/stream/partials/player.blade.php +++ b/resources/views/stream/partials/player.blade.php @@ -8,6 +8,28 @@ @endif