Hi,
My Name is Hugh, and I would like to share with you a wonderful new MIT Licensed single page project of mine that uses the youtube data-api and iframe-api to display a youtube playlist of songs on your concretecms website and play them.
To View a demo visit: (it’s a work in-progress)
https://www.princegeorge.cc/pgmusic
(don’t forget to click “load cache”)
My current version integrates the web components standard into its development scheme and is not very optimized for mobile yet…
But I wanted to share the single page with people if they wanted to have something similar for their own needs:
/applications/single_pages/pgmusic/view.php
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<?php
$enableJS = true;
$u = new \Concrete\Core\User\User();
$c = \Concrete\Core\Page\Page::getCurrentPage();
// Disable JS if in edit mode (for a logged-in user)
if ($u->isRegistered() && $c->isEditMode()) {
$enableJS = false;
}
?>
<section>
<?php
$a = new Area('Main');
$a->setAreaGridMaximumColumns(12);
$a->enableGridContainer();
$a->display($c);
?>
</section>
<?php if ($enableJS) : ?>
<div class="container my-4">
<div id="music-page-container" class="border border-white p-3">
<playlist-viewer></playlist-viewer>
<music-player></music-player>
<video-player></video-player>
</div>
</div>
<!-- Templates for Web Components -->
<template id="playlist-viewer-template">
<style>
:host {
display: block;
margin-bottom: 20px;
background-color: black;
}
.playlist-scroll {
max-height: 400px;
overflow-y: auto;
background: black;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 10px;
}
.playlist-item {
padding: 8px;
cursor: pointer;
transition: background-color 0.2s;
background-color: black;
}
.playlist-item:hover {
background-color: rgba(255,255,255,0.1);
}
.playlist-item.playing {
background-color: rgba(255,255,255,0.1);
font-weight: bold;
}
.spinner {
display: none;
}
#playlist-heading {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 15px;
}
#search-container {
flex-grow: 1;
display: flex;
align-items: center;
gap: 5px;
}
#search-input {
width: 100%;
max-width: 300px;
}
@media (max-width: 576px) {
#playlist-heading {
flex-direction: column;
align-items: stretch;
}
#search-container {
width: 100%;
margin-bottom: 10px;
}
#search-input {
max-width: none;
}
}
</style>
<div id="playlist-heading">
<div id="search-container">
<label for="search-input" class="form-label mb-0">Search:</label>
<input type="text" id="search-input" class="form-control" placeholder="Search channel or track..." />
</div>
<button id="clear-cache-button" class="btn btn-outline-secondary">Clear Cache</button>
<button id="load-cache-button" class="btn btn-outline-primary">
Load Cache <span id="load-spinner" class="spinner">🔄</span>
</button>
</div>
<div id="playlist-scroll" class="playlist-scroll">
<!-- Playlist items inserted here -->
</div>
</template>
<template id="music-player-template">
<style>
.body {
background-color: black;
}
:host {
display: block;
background: black;
color: #fff;
padding: 20px;
border-radius: 0.25rem;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
border: solid 1px white;
}
.track-info {
margin: 15px 0;
text-align: center;
font-size: 1.1rem;
color: white !important;
}
.marquee-content {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
display: inline-block;
color: white !important;
}
@keyframes scrolling {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
#controls {
display: flex;
flex-direction: column;
gap: 15px;
}
.controls-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
font-size: 1rem;
transition: background-color 0.2s, transform 0.1s;
}
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
#play-pause-button {
background-color: rgba(0,0,0,0);
border: solid 1px white;
color: white;
}
#play-pause-button:hover {
background-color: #0b5ed7;
}
#prev-button, #next-button {
background-color: rgba(0,0,0,0);
border: solid 1px white;
color: white;
}
#prev-button:hover, #next-button:hover {
background-color: rgba(255,255,255,0.1);
border: solid 1px white;
}
input[type="range"] {
width: 150px;
margin: 0 10px;
}
#time-display {
font-size: 0.9rem;
min-width: 70px;
text-align: center;
}
#random-checkbox {
margin-right: 5px;
}
label {
display: flex;
align-items: center;
color: #fff;
}
@media (max-width: 576px) {
:host {
padding: 15px;
}
.controls-row {
flex-direction: column;
gap: 15px;
}
input[type="range"] {
width: 100%;
max-width: 200px;
}
#time-display {
width: 100%;
}
}
</style>
<div id="track-info" class="track-info">
<div id="marquee-container" class="marquee-content">
<span id="channel-and-track"></span>
</div>
</div>
<div id="controls">
<div class="controls-row">
<button id="play-pause-button">Play</button>
<button id="prev-button">Previous</button>
<button id="next-button">Next</button>
<input type="range" id="time-slider" value="0" min="0" max="100" />
<span id="time-display">0:00 / 0:00</span>
</div>
<div class="controls-row">
<label><input type="checkbox" id="random-checkbox" /> Shuffle</label>
<input type="range" id="volume-slider" min="0" max="100" value="50" />
</div>
</div>
</template>
<!-- Load External Libraries (YouTube / Fuse.js) -->
<script src="https://www.youtube.com/iframe_api"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>
<!-- PHP logic to load our .js files from /application/single_pages/pgmusic/js/ -->
<?php
$jsDir = DIR_APPLICATION . '/single_pages/pgmusic/js';
$jsFiles = scandir($jsDir);
$scripts = [];
foreach ($jsFiles as $file) {
// Load all JS except main.js first
if (pathinfo($file, PATHINFO_EXTENSION) === 'js' && $file !== 'main.js') {
$scripts[] = '<script defer src="' . BASE_URL . '/application/single_pages/pgmusic/js/' . $file . '"></script>';
}
}
// Load main.js last
$scripts[] = '<script defer src="' . BASE_URL . '/application/single_pages/pgmusic/js/main.js"></script>';
echo implode("\n", $scripts);
?>
<style>
#music-page-container {
background-color: black;
}
</style>
<!-- open player button global style-->
<style>
.hide-button {
border: 1px solid white;
padding: 5px;
background-color: transparent;
color: white;
cursor: pointer;
}
.hide-button:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
</style>
<?php endif; ?>
application/single_pages/pgmusic/js/main.js
// Example path: application/single_pages/pgmusic/js/main.js
// This file handles all the data fetching, caching, and "glue" logic
document.addEventListener('DOMContentLoaded', () => {
const PLAYLIST_ID = 'PL6103487B4355FADB'; //or your sick ass playlist
const API_KEY = 'Youtube_Data_API_Key_setup_too_only_work_on_your_domain'; // your own
const LS_PLAYLIST_KEY = 'myYoutubePlaylist';
const LS_VIDEODATA_KEY = 'myYoutubeVideoData';
const LS_CURRENT_VIDEOID = 'myCurrentVideoID';
const LS_CURRENT_TIME = 'myCurrentTime';
const LS_IS_PLAYING = 'myIsPlaying';
const LS_LAST_PLAYTIMESTAMP = 'myLastPlayTimestamp';
const LS_SHUFFLE_ENABLED = 'myShuffleEnabled';
const LS_SHUFFLE_HISTORY = 'myShuffleHistory';
const LS_BACK_HISTORY_INDEX = 'myBackHistoryIndex';
const LS_VOLUME = 'myVolume';
const RESUME_THRESHOLD = 600 * 1000; // 10 minutes
const MAX_IDS_PER_REQUEST = 50; // YouTube API chunk size
let videoIds = [];
let videoData = {};
let nextPageToken = '';
let reachedEnd = false;
let shouldResumePlayback = false;
const playlistViewer = document.querySelector('playlist-viewer');
const musicPlayer = document.querySelector('music-player');
// Expose some state so PlaylistViewer can request more pages
window.playlistState = {
reachedEnd: () => reachedEnd,
fetchNextPage: fetchPlaylistPage,
loadMoreCache: loadMoreCache,
updateComponents: updateComponents
};
// When user clicks in <playlist-viewer> to select a video
playlistViewer.addEventListener('select-video', (e) => {
musicPlayer.loadVideo(e.detail.videoId, 0, true);
});
// Keep the highlight in sync when <music-player> changes videos
musicPlayer.addEventListener('video-changed', (e) => {
playlistViewer.highlightVideo(e.detail.videoId);
});
// Clear localStorage / re-fetch
playlistViewer.addEventListener('clear-cache', () => {
localStorage.clear();
videoIds = [];
videoData = {};
nextPageToken = '';
reachedEnd = false;
fetchPlaylistPage();
});
// Load entire playlist into cache (recursively fetch)
playlistViewer.addEventListener('load-cache', () => {
loadMoreCache();
});
// On player-ready (fired by MusicPlayer after the YouTube player is set)
musicPlayer.addEventListener('player-ready', () => {
const storedVid = localStorage.getItem(LS_CURRENT_VIDEOID);
const storedTime = parseFloat(localStorage.getItem(LS_CURRENT_TIME) || '0');
const storedPlaying = localStorage.getItem(LS_IS_PLAYING) === '1';
const lastPlayTs = parseInt(localStorage.getItem(LS_LAST_PLAYTIMESTAMP) || '0', 10);
const now = Date.now();
const timeSinceLastPlay = now - lastPlayTs;
// Decide if we should restore the old track
if (storedVid && videoIds.includes(storedVid) && timeSinceLastPlay <= RESUME_THRESHOLD) {
// Load the previous video/time but do not auto-play here
musicPlayer.loadVideo(storedVid, storedTime, false);
// If it was playing before, we note that user might want to resume
if (storedPlaying) {
shouldResumePlayback = true;
setupUserInteractionHandlers();
}
} else {
// Start fresh from the first or random track if shuffle is enabled
const initialVid = musicPlayer.shuffleEnabled
? videoIds[Math.floor(Math.random() * videoIds.length)]
: videoIds[0];
if (initialVid) {
musicPlayer.loadVideo(initialVid, 0, false);
localStorage.setItem(LS_CURRENT_VIDEOID, initialVid);
localStorage.setItem(LS_CURRENT_TIME, '0');
localStorage.setItem(LS_IS_PLAYING, '0');
localStorage.setItem(LS_LAST_PLAYTIMESTAMP, now.toString());
playlistViewer.highlightVideo(initialVid);
const playlistScroll = playlistViewer.shadowRoot.getElementById('playlist-scroll');
playlistScroll.scrollTop = 0;
}
}
});
// Sync changes if multiple tabs are open, or if localStorage changes
window.addEventListener('storage', (event) => {
if (event.key === LS_CURRENT_VIDEOID && event.newValue !== musicPlayer._currentVideoId) {
const storedTime = parseFloat(localStorage.getItem(LS_CURRENT_TIME) || '0');
musicPlayer.loadVideo(event.newValue, storedTime, musicPlayer.isPlaying);
}
if (event.key === LS_IS_PLAYING) {
const shouldPlay = event.newValue === '1';
if (shouldPlay && !musicPlayer.isPlaying) musicPlayer.play();
else if (!shouldPlay && musicPlayer.isPlaying) musicPlayer.pause();
}
});
// Either load from cache (if available) or fetch anew
loadOrInitData();
function loadOrInitData() {
const storedIds = localStorage.getItem(LS_PLAYLIST_KEY);
const storedData = localStorage.getItem(LS_VIDEODATA_KEY);
if (storedIds && storedData) {
try {
videoIds = JSON.parse(storedIds) || [];
videoData = JSON.parse(storedData) || {};
updateComponents();
} catch (e) {
console.warn('Invalid stored data, refetching...', e);
fetchPlaylistPage();
}
} else {
fetchPlaylistPage();
}
}
async function fetchPlaylistPage() {
if (reachedEnd) return;
let url = `https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&maxResults=50&playlistId=${PLAYLIST_ID}&key=${API_KEY}`;
if (nextPageToken) url += `&pageToken=${nextPageToken}`;
try {
console.log('Fetching playlist page:', url);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP error! Status: ${resp.status}`);
const data = await resp.json();
if (data.error) throw new Error(`API Error: ${data.error.message} (Code: ${data.error.code})`);
// Accumulate all video IDs
const newIds = data.items.map((item) => item.contentDetails.videoId);
videoIds = [...new Set([...videoIds, ...newIds])];
nextPageToken = data.nextPageToken || '';
reachedEnd = !nextPageToken;
localStorage.setItem(LS_PLAYLIST_KEY, JSON.stringify(videoIds));
// Fetch details for these new IDs
await fetchVideoDetails();
updateComponents();
} catch (err) {
console.error('Fetch playlist error:', err);
reachedEnd = true;
//alert(`Error loading playlist: ${err.message}`);
}
}
async function fetchVideoDetails() {
// Only fetch details for videoIds we haven’t processed yet
const toFetch = videoIds.filter(
(vid) => !videoData[vid] && typeof vid === 'string' && vid.length === 11
);
if (!toFetch.length) return;
// Because the YouTube API can only handle up to 50 IDs at once
const chunks = [];
for (let i = 0; i < toFetch.length; i += MAX_IDS_PER_REQUEST) {
chunks.push(toFetch.slice(i, i + MAX_IDS_PER_REQUEST));
}
for (const chunk of chunks) {
const ids = chunk.join(',');
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,status&id=${ids}&key=${API_KEY}`;
try {
console.log('Fetching video details for IDs:', ids);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP error! Status: ${resp.status}`);
const data = await resp.json();
if (data.error) throw new Error(`API Error: ${data.error.message} (Code: ${data.error.code})`);
console.log('Video details response:', data);
data.items.forEach((item) => {
// Only keep public + embeddable items
if (item.status.embeddable && item.status.privacyStatus !== 'private') {
videoData[item.id] = {
title: item.snippet.title,
channelTitle: item.snippet.channelTitle,
channelId: item.snippet.channelId,
};
}
});
localStorage.setItem(LS_VIDEODATA_KEY, JSON.stringify(videoData));
} catch (e) {
console.error('Fetch video details error:', e);
//alert(`Error fetching video details: ${e.message}`);
break;
}
}
}
async function loadMoreCache() {
const spinner = playlistViewer.shadowRoot.getElementById('load-spinner');
spinner.style.display = 'inline-block';
try {
while (!reachedEnd) {
await fetchPlaylistPage();
// Slight delay to avoid hammering requests
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (err) {
console.error('Error loading full cache:', err);
//alert(`Error loading full playlist: ${err.message}`);
} finally {
spinner.style.display = 'none';
}
}
function updateComponents() {
// Convert videoIds + videoData into an array for our custom elements
let videoObjects = videoIds.map((vid) => ({
vid,
title: videoData[vid]?.title || vid,
channelTitle: videoData[vid]?.channelTitle || 'Unknown Channel',
channelId: videoData[vid]?.channelId || '',
}));
// Filter out any that have channelTitle === 'Unknown Channel'
videoObjects = videoObjects.filter(obj => {
return obj.channelTitle !== 'Unknown Channel';
});
// Provide to the custom elements
playlistViewer.playlistData = videoObjects;
musicPlayer.playlistData = videoObjects;
}
function setupUserInteractionHandlers() {
// On user gestures, attempt to .play() if we set shouldResumePlayback
const resumePlayback = () => {
if (shouldResumePlayback && !musicPlayer.isPlaying) {
musicPlayer.play();
shouldResumePlayback = false;
document.removeEventListener('click', resumePlayback);
document.removeEventListener('keydown', resumePlayback);
document.removeEventListener('mousemove', resumePlayback);
document.removeEventListener('touchstart', resumePlayback);
document.removeEventListener('scroll', resumePlayback);
}
};
document.addEventListener('click', resumePlayback);
document.addEventListener('keydown', resumePlayback);
document.addEventListener('mousemove', resumePlayback);
document.addEventListener('touchstart', resumePlayback);
document.addEventListener('scroll', resumePlayback);
}
});