/** Watch page: player, vote meter, description, comments and the up-next rail. */
import { h, icon, mount, clear } from '../dom.js';
import { api } from '../api.js';
import { state } from '../store.js';
import { navigate } from '../router.js';
import {
videoCard, avatar, channelAvatarLink, subscribeButton, toast, confirmDialog,
dropdown, menuItem, emptyState, requireSignIn, spinner,
} from '../ui.js';
import { compact, full, timeAgo, longDate, plural } from '../format.js';
export async function watchView({ id }) {
const root = h('div', {}, spinner());
let data;
try {
data = await api.video(id);
} catch (err) {
return emptyState('That video is not here', err.message,
h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
}
clear(root);
const { video, related } = data;
document.title = `${video.title} · MeTube`;
const player = buildPlayer(video);
const primary = h('div.watch__primary', {},
player,
h('h1.watch__title', { text: video.title }),
buildActionBar(video),
buildDescription(video),
buildComments(video),
);
const rail = h('aside.watch__rail', {},
h('div.comments__head', { style: { marginBottom: '14px' } },
h('span.comments__count', { text: 'Up next' }),
),
related.length
? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
related.map((v) => videoCard(v, { compactRow: true })))
: h('div', { style: { color: 'var(--haze)' }, text: 'Nothing else to show yet.' }),
);
root.append(h('div.watch', {}, primary, rail));
return root;
}
function buildPlayer(video) {
const media = h('video', {
src: video.src,
poster: video.thumb || null,
controls: true,
autoplay: true,
playsinline: true,
preload: 'metadata',
});
// A view counts once playback actually starts.
let counted = false;
media.addEventListener('playing', async () => {
if (counted) return;
counted = true;
try {
const { views } = await api.countView(video.id);
document.querySelector('#view-count')?.replaceChildren(document.createTextNode(full(views)));
} catch { /* a missed view is not worth interrupting playback for */ }
});
media.addEventListener('error', () => {
toast('This video could not be played.', 'error');
});
return h('div.player', {}, media);
}
function buildActionBar(video) {
const channel = video.channel;
const likeCount = h('span.num', { text: compact(video.likes) });
const dislikeCount = h('span.num', { text: compact(video.dislikes) });
const likeBtn = h('button.vote.vote--up', { type: 'button', 'aria-label': 'Like this video' },
icon('like', 19), likeCount);
const dislikeBtn = h('button.vote.vote--down', { type: 'button', 'aria-label': 'Dislike this video' },
icon('dislike', 19), dislikeCount);
const ratioFill = h('div.ratio__fill');
const ratio = h('div.ratio', { title: 'Share of votes that are likes' }, ratioFill);
const paintVotes = () => {
likeBtn.classList.toggle('is-on', video.myVote === 1);
dislikeBtn.classList.toggle('is-on', video.myVote === -1);
likeBtn.setAttribute('aria-pressed', String(video.myVote === 1));
dislikeBtn.setAttribute('aria-pressed', String(video.myVote === -1));
likeCount.textContent = compact(video.likes);
dislikeCount.textContent = compact(video.dislikes);
const total = video.likes + video.dislikes;
ratio.style.opacity = total ? '1' : '0.35';
ratioFill.style.width = total ? `${(video.likes / total) * 100}%` : '0%';
};
const castVote = async (value) => {
if (!state.user) return requireSignIn('Sign in to like videos.');
const next = video.myVote === value ? 0 : value;
likeBtn.disabled = dislikeBtn.disabled = true;
try {
const result = await api.vote(video.id, next);
Object.assign(video, result);
paintVotes();
} catch (err) {
toast(err.message, 'error');
} finally {
likeBtn.disabled = dislikeBtn.disabled = false;
}
};
likeBtn.addEventListener('click', () => castVote(1));
dislikeBtn.addEventListener('click', () => castVote(-1));
paintVotes();
const saveBtn = h('button.btn.btn--ghost', { type: 'button' });
const paintSave = () => {
mount(saveBtn, icon('bookmark', 18), video.saved ? 'Saved' : 'Save');
saveBtn.classList.toggle('is-on', !!video.saved);
saveBtn.style.color = video.saved ? 'var(--amber)' : '';
};
saveBtn.addEventListener('click', async () => {
if (!state.user) return requireSignIn('Sign in to save videos for later.');
try {
const { saved } = await api.toggleSave(video.id);
video.saved = saved;
paintSave();
toast(saved ? 'Saved to Watch later' : 'Removed from Watch later', saved ? 'good' : '');
} catch (err) {
toast(err.message, 'error');
}
});
paintSave();
const shareBtn = h('button.btn.btn--ghost', { type: 'button' }, icon('share', 18), 'Share');
shareBtn.addEventListener('click', async () => {
const url = `${location.origin}/watch/${video.id}`;
try {
if (navigator.share) await navigator.share({ title: video.title, url });
else {
await navigator.clipboard.writeText(url);
toast('Link copied to clipboard', 'good');
}
} catch { /* the person dismissed the share sheet */ }
});
const actions = h('div.watch__actions', {},
h('div', {}, h('div.votes', {}, likeBtn, dislikeBtn), ratio),
saveBtn,
shareBtn,
video.isOwner ? ownerMenu(video) : null,
);
const subsLabel = h('span.channelline__subs', {},
h('span.num', { text: compact(channel.subscriberCount) }),
` ${channel.subscriberCount === 1 ? 'subscriber' : 'subscribers'}`,
);
return h('div.watch__bar', {},
h('div.channelline', {},
channelAvatarLink(channel),
h('div', { style: { minWidth: 0 } },
h('a.channelline__name', { href: `/@${channel.username}`, text: channel.displayName }),
subsLabel,
),
h('div', { style: { marginLeft: '8px' } },
subscribeButton(channel, {
onUpdate: ({ subscriberCount, isSubscribed }) => {
mount(subsLabel,
h('span.num', { text: compact(subscriberCount) }),
` ${subscriberCount === 1 ? 'subscriber' : 'subscribers'}`);
if (isSubscribed) toast(`You will see new videos from ${channel.displayName}`, 'good');
},
}),
),
),
actions,
);
}
function ownerMenu(video) {
const trigger = h('button.iconbtn', { type: 'button', 'aria-label': 'Video options' }, icon('dots', 20));
return dropdown(trigger, (close) => [
menuItem('Edit details', 'edit', () => { close(); navigate(`/edit/${video.id}`); }),
h('div.menu__sep'),
menuItem('Delete video', 'trash', async () => {
close();
const ok = await confirmDialog({
title: 'Delete this video?',
text: `“${video.title}” and all of its comments will be removed permanently.`,
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
try {
await api.deleteVideo(video.id);
toast('Video deleted', 'good');
navigate('/');
} catch (err) {
toast(err.message, 'error');
}
}, { danger: true }),
]);
}
function buildDescription(video) {
const body = h('div.descbox__body.is-clamped', { text: video.description || 'No description.' });
const more = h('button.descbox__more', { type: 'button' }, 'Show more');
more.addEventListener('click', () => {
const clamped = body.classList.toggle('is-clamped');
more.textContent = clamped ? 'Show more' : 'Show less';
});
const box = h('div.descbox', {},
h('div.descbox__meta', {},
h('span.descbox__tag', { text: video.category }),
h('span', {}, h('span.num#view-count', { text: full(video.views) }), ` ${video.views === 1 ? 'view' : 'views'}`),
h('span', { text: longDate(video.createdAt) }),
h('span', {}, h('span.num', { text: compact(video.commentCount) }), ` ${video.commentCount === 1 ? 'comment' : 'comments'}`),
),
body,
);
// Only offer the toggle when there is something hidden to reveal.
requestAnimationFrame(() => {
if (body.scrollHeight > body.clientHeight + 4) box.append(more);
});
return box;
}
/* -------------------------------------------------------------- comments -- */
function buildComments(video) {
const section = h('section.comments', { 'aria-label': 'Comments' });
const list = h('div');
let sort = 'top';
const countLabel = h('span.comments__count', { text: plural(video.commentCount, 'comment') });
const sortButtons = ['top', 'new'].map((key) =>
h('button.sortlink', {
type: 'button',
class: key === sort ? 'is-on' : '',
onclick: async (event) => {
sort = key;
for (const btn of event.target.parentElement.querySelectorAll('.sortlink')) btn.classList.remove('is-on');
event.target.classList.add('is-on');
await load();
},
}, key === 'top' ? 'Top' : 'Newest'),
);
const head = h('div.comments__head', {}, countLabel, h('div', {}, sortButtons));
const bumpCount = (delta) => {
video.commentCount = Math.max(0, video.commentCount + delta);
countLabel.textContent = plural(video.commentCount, 'comment');
};
const blankSlate = () =>
h('div', { data: { blank: 'true' }, style: { color: 'var(--haze)', padding: '10px 0' },
text: 'No comments yet. Start the conversation.' });
const load = async () => {
mount(list, spinner());
try {
const { comments } = await api.comments(video.id, sort);
mount(list, comments.length
? comments.map((c) => commentNode(c, video, { onCountChange: bumpCount, reload: load }))
: blankSlate());
} catch (err) {
mount(list, h('div', { style: { color: 'var(--rose)' }, text: err.message }));
}
};
section.append(head, composer(video, {
onPosted: (comment) => {
bumpCount(1);
if (list.firstElementChild?.dataset.blank) clear(list);
list.prepend(commentNode(comment, video, { onCountChange: bumpCount, reload: load }));
},
}), list);
load();
return section;
}
function composer(video, { onPosted, parentId = null, onCancel = null, autofocus = false }) {
if (!state.user) {
return h('div.composer', {},
avatar({ displayName: '?' }),
h('div.composer__main', {},
h('div', { style: { color: 'var(--haze)', padding: '7px 0' } },
h('a', { href: `/signin?next=${encodeURIComponent(location.pathname)}`, style: { color: 'var(--amber)', fontWeight: '700' } }, 'Sign in'),
' to join the conversation.',
),
),
);
}
const input = h('textarea.composer__input', {
rows: '1',
placeholder: parentId ? 'Write a reply…' : 'Add a comment…',
maxlength: '2000',
'aria-label': parentId ? 'Write a reply' : 'Add a comment',
});
const submit = h('button.btn.btn--primary.btn--sm', { type: 'submit', disabled: true }, parentId ? 'Reply' : 'Comment');
const cancel = h('button.btn.btn--ghost.btn--sm', { type: 'button' }, 'Cancel');
const autosize = () => {
input.style.height = 'auto';
input.style.height = `${Math.min(input.scrollHeight, 260)}px`;
};
input.addEventListener('input', () => {
submit.disabled = !input.value.trim();
autosize();
});
input.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') form.requestSubmit();
if (event.key === 'Escape' && onCancel) onCancel();
});
const form = h('form.composer__main', {
onsubmit: async (event) => {
event.preventDefault();
const body = input.value.trim();
if (!body) return;
submit.disabled = true;
try {
const { comment } = await api.addComment(video.id, body, parentId);
input.value = '';
autosize();
onPosted(comment);
onCancel?.();
} catch (err) {
toast(err.message, 'error');
} finally {
submit.disabled = !input.value.trim();
}
},
}, input, h('div.composer__actions', {}, onCancel ? cancel : null, submit));
cancel.addEventListener('click', () => onCancel?.());
if (autofocus) requestAnimationFrame(() => input.focus());
return h('div.composer', {}, avatar(state.user), form);
}
function commentNode(comment, video, { onCountChange, reload, isReply = false }) {
const likeCount = h('span.num', { text: comment.likes ? compact(comment.likes) : '' });
const likeBtn = h('button.tinybtn', { type: 'button', 'aria-label': 'Like this comment' }, icon('like', 16), likeCount);
const paint = () => {
likeBtn.classList.toggle('is-on', comment.myVote === 1);
likeBtn.setAttribute('aria-pressed', String(comment.myVote === 1));
likeCount.textContent = comment.likes ? compact(comment.likes) : '';
};
likeBtn.addEventListener('click', async () => {
if (!state.user) return requireSignIn('Sign in to like comments.');
try {
const result = await api.likeComment(comment.id, comment.myVote === 1 ? 0 : 1);
Object.assign(comment, result);
paint();
} catch (err) {
toast(err.message, 'error');
}
});
paint();
const repliesWrap = h('div.comment__replies');
const replySlot = h('div');
const renderReplies = () => {
mount(repliesWrap, comment.replies.map((r) => commentNode(r, video, { onCountChange, reload, isReply: true })));
repliesWrap.hidden = comment.replies.length === 0;
};
const replyBtn = h('button.tinybtn', { type: 'button' }, icon('reply', 16), 'Reply');
replyBtn.addEventListener('click', () => {
if (!state.user) return requireSignIn('Sign in to reply.');
if (replySlot.firstChild) return clear(replySlot);
mount(replySlot, composer(video, {
parentId: comment.parentId ?? comment.id,
autofocus: true,
onCancel: () => clear(replySlot),
onPosted: (created) => {
comment.replies.push(created);
renderReplies();
clear(replySlot);
onCountChange(1);
},
}));
});
const deleteBtn = comment.canDelete
? h('button.tinybtn', { type: 'button', onclick: async () => {
const ok = await confirmDialog({
title: 'Delete this comment?',
text: 'The comment and any replies to it will be removed.',
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
try {
await api.deleteComment(comment.id);
onCountChange(-(1 + comment.replies.length));
toast('Comment deleted', 'good');
reload();
} catch (err) {
toast(err.message, 'error');
}
} }, icon('trash', 16), 'Delete')
: null;
const node = h('article.comment', {},
h('a', { href: `/@${comment.author.username}` }, avatar(comment.author, isReply ? 'sm' : '')),
h('div.comment__main', {},
h('div.comment__head', {},
h('a.comment__author', { href: `/@${comment.author.username}`, text: comment.author.displayName }),
comment.isCreator ? h('span.comment__badge', { text: 'Creator' }) : null,
h('span.comment__when', { text: timeAgo(comment.createdAt) }),
),
h('div.comment__body', { text: comment.body }),
h('div.comment__actions', {}, likeBtn, isReply ? null : replyBtn, deleteBtn),
replySlot,
isReply ? null : repliesWrap,
),
);
if (!isReply) renderReplies();
return node;
}