Add YouTube playback and queue flow
This commit is contained in:
307
public/app.js
307
public/app.js
@@ -3,7 +3,8 @@ const elements = {
|
||||
playerScreen: document.querySelector('#player-screen'),
|
||||
form: document.querySelector('#stream-form'),
|
||||
url: document.querySelector('#stream-url'),
|
||||
next: document.querySelector('#next'),
|
||||
play: document.querySelector('#play'),
|
||||
queue: document.querySelector('#queue'),
|
||||
entryMessage: document.querySelector('#entry-message'),
|
||||
libraryPanel: document.querySelector('#library-panel'),
|
||||
recentPanel: document.querySelector('#recent-panel'),
|
||||
@@ -17,7 +18,15 @@ const elements = {
|
||||
cancelFavorites: document.querySelector('#cancel-favorites'),
|
||||
addFavorite: document.querySelector('#add-favorite'),
|
||||
saveFavorites: document.querySelector('#save-favorites'),
|
||||
favoritesManager: document.querySelector('#favorites-manager'),
|
||||
favoritesEditorList: document.querySelector('#favorites-editor-list'),
|
||||
favoriteWizard: document.querySelector('#favorite-wizard'),
|
||||
favoriteWizardStep: document.querySelector('#favorite-wizard-step'),
|
||||
favoriteWizardBack: document.querySelector('#favorite-wizard-back'),
|
||||
favoriteTitleField: document.querySelector('#favorite-title-field'),
|
||||
favoriteUrlField: document.querySelector('#favorite-url-field'),
|
||||
favoriteTitle: document.querySelector('#favorite-title'),
|
||||
favoriteUrl: document.querySelector('#favorite-url'),
|
||||
favoritesMessage: document.querySelector('#favorites-message'),
|
||||
audio: document.querySelector('#audio'),
|
||||
canvas: document.querySelector('#screen'),
|
||||
@@ -67,6 +76,7 @@ const state = {
|
||||
recentUrls: [],
|
||||
favorites: [],
|
||||
favoriteModalTrigger: null,
|
||||
favoriteWizard: createFavoriteWizardState(),
|
||||
playbackOffset: 0,
|
||||
duration: null,
|
||||
seekable: false,
|
||||
@@ -86,6 +96,14 @@ void loadEntryLists();
|
||||
|
||||
elements.form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await playCurrentUrl();
|
||||
});
|
||||
|
||||
elements.queue.addEventListener('click', () => {
|
||||
void queueCurrentUrl();
|
||||
});
|
||||
|
||||
async function playCurrentUrl() {
|
||||
setEntryMessage('');
|
||||
setFormBusy(true);
|
||||
|
||||
@@ -113,7 +131,33 @@ elements.form.addEventListener('submit', async (event) => {
|
||||
} finally {
|
||||
setFormBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function queueCurrentUrl() {
|
||||
setEntryMessage('');
|
||||
setFormBusy(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recent-urls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: elements.url.value }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Failed to queue URL.');
|
||||
}
|
||||
|
||||
state.recentUrls = Array.isArray(payload.urls) ? payload.urls : [];
|
||||
renderRecentUrls();
|
||||
setEntryMessage('Queued');
|
||||
} catch (error) {
|
||||
setEntryMessage(error.message);
|
||||
} finally {
|
||||
setFormBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
elements.recentList.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-recent-index]');
|
||||
@@ -158,28 +202,49 @@ elements.closeFavorites.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.cancelFavorites.addEventListener('click', () => {
|
||||
if (state.favoriteWizard.active) {
|
||||
showFavoritesManager();
|
||||
return;
|
||||
}
|
||||
|
||||
closeFavoritesModal();
|
||||
});
|
||||
|
||||
elements.addFavorite.addEventListener('click', () => {
|
||||
elements.favoritesEditorList.append(createFavoriteEditorRow());
|
||||
focusLastFavoriteTitle();
|
||||
startFavoriteWizard('add');
|
||||
});
|
||||
|
||||
elements.favoritesEditorList.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-remove-favorite]');
|
||||
const removeButton = event.target.closest('[data-remove-favorite]');
|
||||
const editButton = event.target.closest('[data-edit-favorite]');
|
||||
|
||||
if (!button) {
|
||||
if (removeButton) {
|
||||
void removeFavorite(Number(removeButton.dataset.removeFavorite));
|
||||
return;
|
||||
}
|
||||
|
||||
button.closest('.favorite-editor-row')?.remove();
|
||||
ensureFavoriteEditorRows();
|
||||
if (editButton) {
|
||||
startFavoriteWizard('edit', Number(editButton.dataset.editFavorite));
|
||||
}
|
||||
});
|
||||
|
||||
elements.favoritesForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
void saveFavoritesFromModal();
|
||||
void advanceFavoriteWizard();
|
||||
});
|
||||
|
||||
elements.favoriteWizardBack.addEventListener('click', () => {
|
||||
if (!state.favoriteWizard.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.favoriteWizard.step === 'url') {
|
||||
state.favoriteWizard.step = 'title';
|
||||
syncFavoriteWizard();
|
||||
return;
|
||||
}
|
||||
|
||||
showFavoritesManager();
|
||||
});
|
||||
|
||||
elements.favoritesModal.addEventListener('click', (event) => {
|
||||
@@ -855,6 +920,17 @@ function createClientTelemetry() {
|
||||
};
|
||||
}
|
||||
|
||||
function createFavoriteWizardState() {
|
||||
return {
|
||||
active: false,
|
||||
mode: 'add',
|
||||
index: -1,
|
||||
step: 'title',
|
||||
title: '',
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
function noteClientTelemetry(name, count = 1) {
|
||||
if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) {
|
||||
return;
|
||||
@@ -1259,7 +1335,8 @@ function setEntryMessage(message) {
|
||||
|
||||
function setFormBusy(isBusy) {
|
||||
elements.url.disabled = isBusy;
|
||||
elements.next.disabled = isBusy;
|
||||
elements.play.disabled = isBusy;
|
||||
elements.queue.disabled = isBusy;
|
||||
|
||||
for (const button of elements.libraryPanel.querySelectorAll('button')) {
|
||||
button.disabled = isBusy;
|
||||
@@ -1351,82 +1428,178 @@ function createEmptyListMessage(text) {
|
||||
function openFavoritesModal() {
|
||||
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
elements.favoritesMessage.textContent = '';
|
||||
renderFavoriteEditorRows(state.favorites);
|
||||
showFavoritesManager();
|
||||
elements.favoritesModal.hidden = false;
|
||||
elements.favoritesModal.classList.add('is-open');
|
||||
focusFirstFavoriteField();
|
||||
elements.addFavorite.focus();
|
||||
}
|
||||
|
||||
function closeFavoritesModal() {
|
||||
elements.favoritesModal.hidden = true;
|
||||
elements.favoritesModal.classList.remove('is-open');
|
||||
elements.favoritesMessage.textContent = '';
|
||||
state.favoriteWizard = createFavoriteWizardState();
|
||||
state.favoriteModalTrigger?.focus();
|
||||
state.favoriteModalTrigger = null;
|
||||
}
|
||||
|
||||
function renderFavoriteEditorRows(items) {
|
||||
const rows = items.length > 0 ? items : [{ title: '', url: '' }];
|
||||
function showFavoritesManager() {
|
||||
state.favoriteWizard = createFavoriteWizardState();
|
||||
elements.favoritesMessage.textContent = '';
|
||||
elements.favoritesManager.hidden = false;
|
||||
elements.favoriteWizard.hidden = true;
|
||||
elements.favoriteWizardBack.hidden = true;
|
||||
elements.saveFavorites.hidden = true;
|
||||
elements.cancelFavorites.textContent = 'Done';
|
||||
renderFavoriteManager();
|
||||
}
|
||||
|
||||
function renderFavoriteManager() {
|
||||
elements.favoritesEditorList.replaceChildren(
|
||||
...rows.map((item) => createFavoriteEditorRow(item)),
|
||||
...state.favorites.map((item, index) => createFavoriteManagerRow(item, index)),
|
||||
);
|
||||
}
|
||||
|
||||
function createFavoriteEditorRow(item = { title: '', url: '' }) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'favorite-editor-row';
|
||||
|
||||
const title = document.createElement('input');
|
||||
title.type = 'text';
|
||||
title.placeholder = 'Title';
|
||||
title.setAttribute('aria-label', 'Favorite title');
|
||||
title.autocomplete = 'off';
|
||||
title.maxLength = 120;
|
||||
title.value = item.title ?? '';
|
||||
title.dataset.favoriteTitle = '';
|
||||
|
||||
const url = document.createElement('input');
|
||||
url.type = 'url';
|
||||
url.placeholder = 'URL';
|
||||
url.setAttribute('aria-label', 'Favorite URL');
|
||||
url.autocomplete = 'off';
|
||||
url.value = item.url ?? '';
|
||||
url.dataset.favoriteUrl = '';
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'remove-favorite';
|
||||
remove.dataset.removeFavorite = '';
|
||||
remove.setAttribute('aria-label', 'Remove favorite');
|
||||
remove.textContent = 'Remove';
|
||||
|
||||
row.append(title, url, remove);
|
||||
return row;
|
||||
}
|
||||
|
||||
function ensureFavoriteEditorRows() {
|
||||
if (elements.favoritesEditorList.children.length === 0) {
|
||||
elements.favoritesEditorList.append(createFavoriteEditorRow());
|
||||
if (state.favorites.length === 0) {
|
||||
elements.favoritesEditorList.replaceChildren(createEmptyListMessage('No favorites'));
|
||||
}
|
||||
}
|
||||
|
||||
function focusFirstFavoriteField() {
|
||||
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
|
||||
field?.focus();
|
||||
function createFavoriteManagerRow(item, index) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'favorite-manager-row';
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'favorite-summary';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'favorite-title';
|
||||
title.textContent = item.title;
|
||||
|
||||
const url = document.createElement('div');
|
||||
url.className = 'favorite-url';
|
||||
url.textContent = item.url;
|
||||
|
||||
const edit = document.createElement('button');
|
||||
edit.type = 'button';
|
||||
edit.className = 'secondary-button favorite-action';
|
||||
edit.dataset.editFavorite = String(index);
|
||||
edit.textContent = 'Edit';
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'remove-favorite favorite-action';
|
||||
remove.dataset.removeFavorite = String(index);
|
||||
remove.textContent = 'Remove';
|
||||
|
||||
summary.append(title, url);
|
||||
row.append(summary, edit, remove);
|
||||
return row;
|
||||
}
|
||||
|
||||
function focusLastFavoriteTitle() {
|
||||
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
|
||||
const row = rows[rows.length - 1];
|
||||
row?.querySelector('[data-favorite-title]')?.focus();
|
||||
}
|
||||
function startFavoriteWizard(mode, index = -1) {
|
||||
const item = mode === 'edit' ? state.favorites[index] : null;
|
||||
|
||||
if (mode === 'edit' && !item) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.favoriteWizard = {
|
||||
active: true,
|
||||
mode,
|
||||
index,
|
||||
step: 'title',
|
||||
title: item?.title ?? '',
|
||||
url: item?.url ?? elements.url.value.trim(),
|
||||
};
|
||||
|
||||
async function saveFavoritesFromModal() {
|
||||
elements.favoritesMessage.textContent = '';
|
||||
elements.favoritesManager.hidden = true;
|
||||
elements.favoriteWizard.hidden = false;
|
||||
elements.favoriteWizardBack.hidden = false;
|
||||
elements.saveFavorites.hidden = false;
|
||||
elements.cancelFavorites.textContent = 'Cancel';
|
||||
syncFavoriteWizard();
|
||||
}
|
||||
|
||||
function syncFavoriteWizard() {
|
||||
const wizard = state.favoriteWizard;
|
||||
const isUrlStep = wizard.step === 'url';
|
||||
|
||||
elements.favoriteWizardStep.textContent = isUrlStep ? 'URL' : 'Title';
|
||||
elements.favoriteTitleField.hidden = isUrlStep;
|
||||
elements.favoriteUrlField.hidden = !isUrlStep;
|
||||
elements.favoriteTitle.value = wizard.title;
|
||||
elements.favoriteUrl.value = wizard.url;
|
||||
elements.saveFavorites.textContent = isUrlStep ? 'Save' : 'Next';
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (isUrlStep) {
|
||||
elements.favoriteUrl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
elements.favoriteTitle.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function advanceFavoriteWizard() {
|
||||
if (!state.favoriteWizard.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.favoritesMessage.textContent = '';
|
||||
const wizard = state.favoriteWizard;
|
||||
|
||||
if (wizard.step === 'title') {
|
||||
const title = elements.favoriteTitle.value.trim();
|
||||
|
||||
if (!title) {
|
||||
elements.favoritesMessage.textContent = 'Favorite needs a title.';
|
||||
elements.favoriteTitle.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
wizard.title = title;
|
||||
wizard.step = 'url';
|
||||
syncFavoriteWizard();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = elements.favoriteUrl.value.trim();
|
||||
|
||||
if (!url) {
|
||||
elements.favoritesMessage.textContent = 'Favorite needs a URL.';
|
||||
elements.favoriteUrl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
wizard.url = url;
|
||||
|
||||
const nextFavorites = wizard.mode === 'edit'
|
||||
? state.favorites.map((item, index) => (index === wizard.index ? { title: wizard.title, url: wizard.url } : item))
|
||||
: [...state.favorites, { title: wizard.title, url: wizard.url }];
|
||||
|
||||
await saveFavoritesList(nextFavorites, {
|
||||
onSuccess: () => {
|
||||
showFavoritesManager();
|
||||
elements.addFavorite.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeFavorite(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= state.favorites.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFavorites = state.favorites.filter((_item, itemIndex) => itemIndex !== index);
|
||||
await saveFavoritesList(nextFavorites);
|
||||
}
|
||||
|
||||
async function saveFavoritesList(favorites, { onSuccess = () => {} } = {}) {
|
||||
setFavoritesModalBusy(true);
|
||||
|
||||
try {
|
||||
const favorites = readFavoriteEditorRows();
|
||||
const response = await fetch('/api/favorites', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1440,7 +1613,8 @@ async function saveFavoritesFromModal() {
|
||||
|
||||
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
|
||||
renderFavorites();
|
||||
closeFavoritesModal();
|
||||
renderFavoriteManager();
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
elements.favoritesMessage.textContent = error.message;
|
||||
} finally {
|
||||
@@ -1448,15 +1622,6 @@ async function saveFavoritesFromModal() {
|
||||
}
|
||||
}
|
||||
|
||||
function readFavoriteEditorRows() {
|
||||
return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')]
|
||||
.map((row) => ({
|
||||
title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '',
|
||||
url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '',
|
||||
}))
|
||||
.filter((item) => item.title || item.url);
|
||||
}
|
||||
|
||||
function setFavoritesModalBusy(isBusy) {
|
||||
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
|
||||
field.disabled = isBusy;
|
||||
|
||||
Reference in New Issue
Block a user