Add YouTube playback and queue flow

This commit is contained in:
2026-06-11 21:13:48 -07:00
parent 2866d33dec
commit 20f6d4d192
8 changed files with 612 additions and 101 deletions

View File

@@ -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;