patx/youtube-clone

/** 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;
}