<style scoped>
.player-shell {
  position: relative;
  width: 100%;
}

.player {
  display: block;
  width: 100%;
  height: auto;
  max-height: 80vh;
  max-width: 100%;
  object-fit: contain;
}

.keyboard-help {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.95);
  color: var(--bulma-white);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  z-index: 1000;
  border-radius: 4px;
  overflow-y: auto;
  padding: 2rem;
}

.shortcuts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 2rem;
  max-width: 1000px;
  width: 100%;
}

.shortcut-section {
  text-align: left;
}

.shortcut-section h3 {
  color: var(--bulma-primary);
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.shortcut-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.75rem;
  font-size: 0.95rem;
}

.shortcut-item span:first-child {
  flex: 1;
}

.shortcut-item .kbd-group {
  display: flex;
  gap: 0.5rem;
  margin-left: auto;
}

.shortcut-key {
  background: rgba(255, 255, 255, 0.1);
  padding: 0.25rem 0.5rem;
  border-radius: 3px;
  margin-right: 1rem;
  font-family: monospace;
  font-weight: bold;
  min-width: 60px;
  text-align: center;
}

.help-close-hint {
  margin-top: 2rem;
  font-size: 0.9rem;
  color: var(--bulma-light);
}
</style>

<template>
  <div v-if="infoLoaded">
    <div class="player-shell">
      <video
        class="player"
        ref="video"
        :poster="uri(thumbnail)"
        playsinline
        controls
        crossorigin="anonymous"
        preload="auto"
        autoplay
      >
        <source
          v-for="source in sources"
          :key="source.src"
          :src="source.src"
          @error="source.onerror"
          :type="source.type"
        />
        <track
          v-for="(track, i) in tracks"
          :key="track.file"
          :kind="track.kind"
          :label="track.label"
          :srclang="track.lang"
          :src="track.file"
          :default="notFirefox && 0 === i"
        />
      </video>

      <div class="flex items-center justify-between gap-3">
        <div>
          <button
            v-if="infoLoaded && !usingHls && hasVideoStream"
            type="button"
            class="hidden items-center gap-2 text-info transition-colors hover:text-info/80 md:inline-flex"
            @click.prevent="forceSwitchToHls"
          >
            <UIcon name="i-lucide-refresh-cw" class="size-4" />
            <span>Trouble playing? switch to HLS stream.</span>
          </button>
        </div>
        <div>
          <button
            type="button"
            class="hidden items-center gap-2 text-toned transition-colors hover:text-default md:inline-flex"
            @click="showHelp = !showHelp"
          >
            <UIcon name="i-lucide-circle-help" class="size-4" />
            <span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
          </button>
        </div>
      </div>

      <div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false">
        <h2 style="margin-bottom: 1.5rem">Keyboard Shortcuts</h2>

        <div class="shortcuts-grid">
          <div class="shortcut-section">
            <h3>Playback Control</h3>
            <div class="shortcut-item">
              <span>Play/Pause</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">SPACE</kbd>
                <kbd class="shortcut-key">K</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Rewind 10s</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">J</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Forward 10s</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">L</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Mute/Unmute</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">M</kbd>
              </div>
            </div>
          </div>

          <div class="shortcut-section">
            <h3>Seeking</h3>
            <div class="shortcut-item">
              <span>Seek Back 5s</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">←</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Seek Forward 5s</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">→</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Jump to Start</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">HOME</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Jump to End</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">END</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Jump to %</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">0-9</kbd>
              </div>
            </div>
          </div>

          <div class="shortcut-section">
            <h3>Volume & Speed</h3>
            <div class="shortcut-item">
              <span>Increase Volume</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">↑</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Decrease Volume</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">↓</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Increase Speed</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">'</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Decrease Speed</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">;</kbd>
              </div>
            </div>
          </div>

          <div class="shortcut-section">
            <h3>Display & Other</h3>
            <div class="shortcut-item">
              <span>Fullscreen</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">F</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Picture in Picture (PiP)</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">P</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Toggle Captions</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">C</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Frame Advance</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">.</kbd>
              </div>
            </div>
            <div class="shortcut-item">
              <span>Frame Rewind</span>
              <div class="kbd-group">
                <kbd class="shortcut-key">,</kbd>
              </div>
            </div>
          </div>
        </div>

        <div class="help-close-hint">
          Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close
        </div>
      </div>
    </div>
  </div>
  <div v-else class="flex justify-center py-10">
    <UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-toned" />
  </div>
</template>

<script setup lang="ts">
import { useStorage } from '@vueuse/core';
import { watch } from 'vue';
import Hls from 'hls.js';
import { disableOpacity, enableOpacity } from '~/utils';
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';

import type { StoreItem } from '~/types/store';
import type { file_info, video_source_element, video_track_element } from '~/types/video';

const config = useConfigStore();

const props = defineProps<{ item: StoreItem }>();
const emitter = defineEmits<{
  (e: 'closeModel'): void;
  (e: 'error', message: string): void;
}>();

const video = useTemplateRef<HTMLVideoElement>('video');
const tracks = ref<Array<video_track_element>>([]);
const sources = ref<Array<video_source_element>>([]);

const thumbnail = ref('/images/placeholder.png');
const artist = ref('');
const title = ref('');
const isAudio = ref(false);
const hasVideoStream = ref(false);
const videoWidth = ref<number | undefined>(undefined);
const videoHeight = ref<number | undefined>(undefined);
const volume = useStorage('player_volume', 1);
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox');
const infoLoaded = ref(false);
const destroyed = ref(false);
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
const havePoster = ref(false);
const showHelp = ref(false);
const usingHls = ref(false);

let unbindMediaSessionListeners: null | (() => void) = null;
let hls: Hls | null = null;
let cleanupKeyboardShortcuts: null | (() => void) = null;

const handle_event = (e: KeyboardEvent) => {
  if ('Escape' !== e.key) {
    return;
  }
  emitter('closeModel');
};

const bindMediaSessionListeners = (el: HTMLVideoElement) => {
  const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget);
  const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget);
  const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget);
  const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget);
  const onPause = async (e: Event) => {
    const target = (e.currentTarget as HTMLVideoElement) ?? null;
    if (!target || destroyed.value) {
      return;
    }
    const dataUrl = await captureFrame(target);
    if (dataUrl) {
      thumbnail.value = dataUrl;
      havePoster.value = true;
      applyMediaSessionMetadata();
    }
  };

  el.addEventListener('loadedmetadata', onLoadedMetadata);
  el.addEventListener('timeupdate', onTimeUpdate);
  el.addEventListener('ratechange', onRateChange);
  el.addEventListener('seeked', onSeeked);
  el.addEventListener('pause', onPause);

  return () => {
    el.removeEventListener('loadedmetadata', onLoadedMetadata);
    el.removeEventListener('timeupdate', onTimeUpdate);
    el.removeEventListener('ratechange', onRateChange);
    el.removeEventListener('seeked', onSeeked);
    el.removeEventListener('pause', onPause);
  };
};

const updateMediaSessionPosition = (target: EventTarget | null) => {
  if (false === 'mediaSession' in navigator) {
    return;
  }
  const el = (target as HTMLVideoElement) ?? null;
  if (!el || destroyed.value) {
    return;
  }
  const d = el.duration;
  if (false === Number.isFinite(d) || 0 >= d) {
    return;
  }
  try {
    navigator.mediaSession.setPositionState({
      duration: d,
      playbackRate: el.playbackRate,
      position: el.currentTime,
    });
  } catch {}
};

const volume_change_handler = () => {
  const el = video.value;
  if (!el) {
    return;
  }
  volume.value = el.volume;
  updateMediaSessionPosition(el);
};

const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
  if (!el || destroyed.value) {
    return '';
  }
  if (0 === el.videoWidth || 0 === el.videoHeight) {
    return '';
  }

  const w = el.videoWidth;
  const h = el.videoHeight;

  try {
    if ('OffscreenCanvas' in window) {
      const c = new (window as any).OffscreenCanvas(w, h);
      const ctx = c.getContext('2d');
      if (!ctx) {
        return '';
      }
      ctx.drawImage(el, 0, 0, w, h);
      const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 });
      return await new Promise<string>((r) => {
        const fr = new FileReader();
        fr.onload = () => r(String(fr.result));
        fr.readAsDataURL(blob);
      });
    } else {
      const c = document.createElement('canvas');
      c.width = w;
      c.height = h;
      const ctx = c.getContext('2d');
      if (!ctx) {
        return '';
      }
      ctx.drawImage(el, 0, 0, w, h);
      return c.toDataURL('image/jpeg', 0.86);
    }
  } catch {
    return '';
  }
};

const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
  if (!el || destroyed.value) {
    return;
  }
  if (havePoster.value) {
    return;
  }
  if (0 === el.videoWidth || 0 === el.videoHeight) {
    return;
  }

  const dataUrl = await captureFrame(el);
  if (!dataUrl) {
    return;
  }

  thumbnail.value = dataUrl;
  havePoster.value = true;
  applyMediaSessionMetadata();
};

const restoreDefaultTextTrack = async () => {
  const el = video.value;
  if (!el) {
    return;
  }

  try {
    const tracksList = el.textTracks;
    if (!tracksList || 0 === tracksList.length) {
      return;
    }

    // Check if first track has cues - if not, we need to reload tracks
    const firstTrack = tracksList[0] as TextTrack | undefined;
    const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0);

    if (needsReload) {
      const trackElements = el.querySelectorAll('track');

      trackElements.forEach((trackEl, idx) => {
        const parent = trackEl.parentNode;
        const clone = trackEl.cloneNode(true) as HTMLTrackElement;
        trackEl.remove();
        setTimeout(() => {
          if (parent) {
            parent.appendChild(clone);
            // Set mode after cues load
            clone.addEventListener(
              'load',
              () => {
                const trackObj = clone.track;
                if (trackObj) {
                  trackObj.mode = idx === 0 ? 'showing' : 'disabled';
                }
              },
              { once: true },
            );
          }
        }, idx * 10);
      });

      return;
    }

    for (let i = 0; i < tracksList.length; i += 1) {
      const track = tracksList[i] as TextTrack | undefined;
      if (track) {
        track.mode = 'disabled';
      }
    }

    await new Promise((resolve) => setTimeout(resolve, 50));

    for (let i = 0; i < tracksList.length; i += 1) {
      const track = tracksList[i] as TextTrack | undefined;
      if (!track) {
        continue;
      }
      const newMode = 0 === i ? 'showing' : 'disabled';
      track.mode = newMode;
    }
  } catch (error) {
    console.warn('Failed to restore subtitle track state', error);
  }
};

onMounted(async () => {
  disableOpacity();
  const req = await request(makeDownload(config, props.item, 'api/file/info'));
  const response: file_info = await req.json();

  if (!req.ok) {
    emitter('error', response?.error || 'Failed to fetch video info. Unknown error');
    emitter('closeModel');
    return;
  }

  await nextTick();

  if (props.item.extras?.thumbnail) {
    thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail);
    havePoster.value = true;
  } else {
    if (response.sidecar?.image?.[0]?.file) {
      thumbnail.value = makeDownload(config, { filename: response.sidecar.image[0].file });
      havePoster.value = true;
    }
  }

  hasVideoStream.value =
    Array.isArray(response.ffprobe?.video) &&
    response.ffprobe.video.some((s) => 'video' === s.codec_type);

  // Extract video dimensions to prevent layout reflow
  if (hasVideoStream.value && response.ffprobe?.video) {
    const videoStream = response.ffprobe.video.find((s) => 'video' === s.codec_type);
    if (videoStream?.width && videoStream?.height) {
      videoWidth.value = videoStream.width;
      videoHeight.value = videoStream.height;
    }
  }

  if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
    isAudio.value = true;
  } else {
    if (false === hasVideoStream.value) {
      isAudio.value = true;
    }
  }

  if (isApple) {
    const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4');
    const src = makeDownload(
      config,
      props.item,
      allowedCodec ? 'api/download' : 'm3u8',
      allowedCodec ? false : true,
    );
    sources.value.push({
      src,
      type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
      onerror: (err: Event) => src_error(err),
    });
    usingHls.value = !allowedCodec;
  } else {
    const src = makeDownload(config, props.item, 'api/download', false);
    sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err) });
    usingHls.value = false;
  }

  if (props.item.extras?.channel) {
    artist.value = props.item.extras.channel;
  }

  if (!artist.value && props.item.extras?.uploader) {
    artist.value = props.item.extras.uploader;
  }

  if (props.item?.title) {
    title.value = props.item.title;
  } else {
    if (response?.title) {
      title.value = response.title;
    } else {
      if (response.ffprobe?.metadata?.tags?.title) {
        title.value = response.ffprobe.metadata.tags.title;
      }
    }
  }

  response.sidecar?.subtitle?.forEach((cap, _) => {
    tracks.value.push({
      kind: 'captions',
      label: cap.name,
      lang: cap.lang,
      file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`,
    });
  });

  if (isApple) {
    document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
  }

  infoLoaded.value = true;
  await nextTick();

  prepareVideoPlayer();

  if (video.value) {
    unbindMediaSessionListeners = bindMediaSessionListeners(video.value);
  }

  const keyboardShortcutsResult = useKeyboardShortcuts({
    videoElement: video,
    enabled: ref(true),
    closePlayer: () => emitter('closeModel'),
  });

  cleanupKeyboardShortcuts = keyboardShortcutsResult.attach();

  watch(keyboardShortcutsResult.showHelp, (newVal) => (showHelp.value = newVal));

  document.addEventListener('keydown', handle_event);
});

const applyMediaSessionMetadata = () => {
  if (false === 'mediaSession' in navigator) {
    return;
  }
  const meta: MediaMetadataInit = { title: title.value };
  if (artist.value) {
    meta.artist = artist.value;
  }
  if (thumbnail.value) {
    meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }];
  }
  try {
    navigator.mediaSession.metadata = new MediaMetadata(meta);
  } catch {}
};

onUpdated(() => prepareVideoPlayer());

onBeforeUnmount(() => {
  enableOpacity();
  if (hls) {
    hls.destroy();
    hls = null;
  }

  usingHls.value = false;

  document.removeEventListener('keydown', handle_event);

  if (cleanupKeyboardShortcuts) {
    cleanupKeyboardShortcuts();
    cleanupKeyboardShortcuts = null;
  }

  if (unbindMediaSessionListeners) {
    unbindMediaSessionListeners();
    unbindMediaSessionListeners = null;
  }

  if (title.value) {
    window.document.title = 'YTPTube';
  }

  if (!video.value) {
    return;
  }

  destroyed.value = true;

  try {
    video.value.pause();
    video.value.querySelectorAll('source').forEach((source) => source.removeAttribute('src'));
    video.value.removeEventListener('volumechange', volume_change_handler);
    video.value.load();
  } catch (e) {
    console.error(e);
  }
});

const prepareVideoPlayer = () => {
  if (!infoLoaded.value) {
    return;
  }

  applyMediaSessionMetadata();

  if (title.value) {
    window.document.title = `YTPTube - Playing: ${title.value}`;
  }

  if (!video.value) {
    return;
  }

  video.value.volume = volume.value;
  video.value.addEventListener('volumechange', volume_change_handler);
  restoreDefaultTextTrack();

  if (hasVideoStream.value) {
    if ('requestVideoFrameCallback' in video.value) {
      (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
    } else {
      const tryOnce = () => captureFirstFramePoster(video.value!);
      (video.value as any).addEventListener('loadeddata', tryOnce, { once: true });
    }
  }
};

const src_error = async (e: any) => {
  if (hls) {
    return;
  }

  await nextTick();
  if (destroyed.value) {
    return;
  }

  if (video.value && notFirefox && video.value.paused) {
    return;
  }

  console.warn('Source failed to load, attempting HLS fallback via hls.js...', e);
  attach_hls(makeDownload(config, props.item, 'm3u8', true));
};

const attach_hls = (link: string) => {
  if (!video.value) {
    return;
  }

  hls = new Hls({
    debug: false,
    enableWorker: true,
    lowLatencyMode: true,
    backBufferLength: 120,
    fragLoadingTimeOut: 200000,
  });

  hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata());
  hls.on(Hls.Events.MANIFEST_PARSED, async () => {
    await new Promise((resolve) => setTimeout(resolve, 100));
    await restoreDefaultTextTrack();
  });

  hls.on(Hls.Events.MEDIA_ATTACHED, async () => {
    await new Promise((resolve) => setTimeout(resolve, 200));
    await restoreDefaultTextTrack();
  });

  hls.on(Hls.Events.LEVEL_LOADED, () => {
    if (video.value) {
      if ('requestVideoFrameCallback' in video.value) {
        (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
      } else {
        const once = () => captureFirstFramePoster(video.value!);
        (video.value as any).addEventListener('loadeddata', once, { once: true });
      }
    }
  });

  hls.loadSource(link);
  hls.attachMedia(video.value);
  usingHls.value = true;
};

const forceSwitchToHls = () => {
  if (usingHls.value) {
    return;
  }

  if (!hasVideoStream.value) {
    useNotification().error('Cannot switch to HLS: stream has no video track.');
    return;
  }

  attach_hls(makeDownload(config, props.item, 'm3u8', true));
};
</script>
