import type { Language } from '../runtime'; import type { IVideoCard, IVideoData, PaginatedResponse } from '../types'; import { VideosApiService } from '../services/api/videos.service'; interface UseNextVideoParams { /** Текущий ролик (реактивно — геттер). */ data: () => IVideoData; /** Related текущей ?page (обычная страница) или playlistUpcoming (плейлист). */ suggestedVideos: () => PaginatedResponse | null | undefined; /** Задан только на странице плейлиста — путь плейлиста без slug текущего ролика. */ basePath: () => string | undefined; } /** * Логика «следующего ролика» для плеера: вычисляет next для ховер-превью и выполняет * переход по кнопке next / автопереходу (externalAdvance). * * Обычная страница видео: переключение через `?view={id|url}` (landing-path остаётся, * useFetchVideo рефетчит). Related приходит постранично — у конца листа заранее * догружается следующая страница, а при переходе через границу обновляется и `?page`, * чтобы секция related показывала лист, с которого этот ролик. В самом конце всех * страниц — по кругу на первый ролик. * * Страница плейлиста (`basePath`): реальный переход на `{basePath}/{id}`, без пагинации. */ export function useNextVideo({ data, suggestedVideos, basePath }: UseNextVideoParams) { const route = useRoute(); const featureFlags = useRuntimeConfig().public.featureFlags; const lang = useLang() as Language; const slug = useSlug(); const isMobile = useState('isMobile'); const perPage = computed(() => isMobile.value ? 6 : 14); const cardKey = computed<'url' | 'id'>(() => featureFlags.FetchByUrlEnabled ? 'url' : 'id'); const currentCardId = computed(() => String(route.query.view || data()?.[cardKey.value] || '')); // Режим воспроизведения плейлиста (тумблеры cycle/random в PlayerPlaylist). // Random работает только на странице плейлиста — на обычной странице видео related // всегда идёт по порядку. const { mode: playMode } = usePlaylistMode(); const isRandom = computed(() => !!basePath() && playMode.value === 'random'); const currentItems = computed(() => suggestedVideos()?.items ?? []); const currentPage = computed(() => suggestedVideos()?.currentPage ?? 1); const allPages = computed(() => suggestedVideos()?.allPages ?? 1); const currentIndex = computed(() => currentItems.value.findIndex((item) => String(item[cardKey.value] ?? '') === currentCardId.value) ); // Первый ролик первой страницы — для зацикливания в самом конце всех страниц related. const firstOverall = ref(); // Префетч следующей страницы related — для ховер-превью и перехода через границу листа. const nextPageItems = ref([]); watch(suggestedVideos, (list) => { if (currentPage.value === 1 && list?.items?.length) firstOverall.value = list.items[0]; nextPageItems.value = []; }, { immediate: true }); // Случайный ролик в режиме random. Индекс фиксируем в ref и перекатываем при смене // текущего ролика/списка/режима — чтобы ховер-превью и реальный переход вели на один // и тот же ролик. Только на клиенте: Math.random в SSR даст рассинхрон гидрации. // Текущий ролик в playlistUpcoming уже исключён, так что попасть в себя нельзя. const randomIndex = ref(0); if (import.meta.client) { watch([currentCardId, currentItems, isRandom], () => { const length = currentItems.value.length; randomIndex.value = length > 1 ? Math.floor(Math.random() * length) : 0; }, { immediate: true }); } // Следующий ролик: за текущим в текущем листе; на конце листа — первый следующей // страницы (если догружена); на самом конце всех страниц — первый ролик вообще. const nextVideo = computed(() => { const items = currentItems.value; if (!items.length) return undefined; if (isRandom.value) return items[randomIndex.value] ?? items[0]; const idx = currentIndex.value; if (idx === -1) return items[0]; if (idx + 1 < items.length) return items[idx + 1]; if (nextPageItems.value.length) return nextPageItems.value[0]; return firstOverall.value ?? items[0]; }); const { execute: fetchRelated } = useApiAction( featureFlags.FetchByUrlEnabled ? VideosApiService.getRelatedVideosByUrl : VideosApiService.getRelatedVideosById, true, ); let loadingMore = false; async function maybeLoadNextRelatedPage(): Promise { if (basePath() || loadingMore) return; if (nextPageItems.value.length || currentPage.value >= allPages.value) return; if (currentIndex.value < currentItems.value.length - 2) return; loadingMore = true; try { const res = await fetchRelated([{ page: currentPage.value + 1, 'per-page': perPage.value }, lang, slug.value]); nextPageItems.value = (res as PaginatedResponse | undefined)?.items ?? []; } finally { loadingMore = false; } } watch(currentCardId, () => maybeLoadNextRelatedPage(), { immediate: true }); function goNext(payload: { direction: 'next' | 'previous' | 'select' | 'ended' }): void { if (payload.direction === 'previous') return; const next = nextVideo.value; if (!next) return; // Плейлист: переключение по списку — реальный переход на id следующего ролика. const base = basePath(); if (base) { if (next.id) navigateTo(`${base}/${next.id}`); return; } const nextId = next[cardKey.value]; if (!nextId) return; const query = { ...route.query, view: nextId }; // Переход через границу листа related — обновляем ?page, чтобы секция related внизу // показывала лист, с которого этот ролик (или первый лист при зацикливании). const idx = currentIndex.value; if (idx >= 0 && idx + 1 >= currentItems.value.length) { query.page = (nextPageItems.value.length && currentPage.value < allPages.value) ? String(currentPage.value + 1) : '1'; } navigateTo({ path: route.path, query }); } return { nextVideo, goNext }; }