From 869e4fca78d63c3d386b5d331d59328404ad6d96 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 21 Dec 2025 10:30:54 -0700 Subject: [PATCH] add previous and next ep shortcuts --- src/assets/locales/en.json | 5 +- .../overlays/KeyboardCommandsModal.tsx | 10 + .../player/internals/KeyboardEvents.tsx | 222 +++++++++++++++++- 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 04c10f8e..4fc7e564 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -269,12 +269,15 @@ "syncSubtitlesLater": "Sync subtitles later (+0.5s)", "barrelRoll": "Do a barrel roll! 🌀", "closeOverlay": "Close overlay/modal", + "nextEpisode": "Next episode", + "previousEpisode": "Previous episode", "widescreenMode": "to toggle the widescreen button visibility", "copyLinkWithTime": "+ click the title to copy the link with time" }, "conditions": { "notInWatchParty": "Not in watch party", - "whenPaused": "When paused" + "whenPaused": "When paused", + "showsOnly": "Shows only" } } }, diff --git a/src/components/overlays/KeyboardCommandsModal.tsx b/src/components/overlays/KeyboardCommandsModal.tsx index e70928cf..baee9639 100644 --- a/src/components/overlays/KeyboardCommandsModal.tsx +++ b/src/components/overlays/KeyboardCommandsModal.tsx @@ -53,6 +53,16 @@ const getShortcutGroups = (t: (key: string) => string): ShortcutGroup[] => [ description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), condition: t("global.keyboardShortcuts.conditions.whenPaused"), }, + { + key: "N", + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + }, + { + key: "P", + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + }, ], }, { diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx index 627c3edb..6d6acdca 100644 --- a/src/components/player/internals/KeyboardEvents.tsx +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -1,11 +1,15 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getMetaFromId } from "@/backend/metadata/getmeta"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { useCaptions } from "@/components/player/hooks/useCaptions"; +import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { useVolume } from "@/components/player/hooks/useVolume"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayStack } from "@/stores/interface/overlayStack"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; +import { useProgressStore } from "@/stores/progress"; import { useSubtitleStore } from "@/stores/subtitles"; import { useEmpheralVolumeStore } from "@/stores/volume"; import { useWatchPartyStore } from "@/stores/watchParty"; @@ -20,6 +24,16 @@ export function KeyboardEvents() { const duration = usePlayerStore((s) => s.progress.duration); const { setVolume, toggleMute } = useVolume(); const isInWatchParty = useWatchPartyStore((s) => s.enabled); + const meta = usePlayerStore((s) => s.meta); + const { setDirectMeta } = usePlayerMeta(); + const setShouldStartFromBeginning = usePlayerStore( + (s) => s.setShouldStartFromBeginning, + ); + const updateItem = useProgressStore((s) => s.updateItem); + const sourceId = usePlayerStore((s) => s.sourceId); + const setLastSuccessfulSource = usePreferencesStore( + (s) => s.setLastSuccessfulSource, + ); const { toggleLastUsed } = useCaptions(); const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); @@ -47,6 +61,202 @@ export function KeyboardEvents() { const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay); + // Episode navigation functions + const navigateToNextEpisode = useCallback(async () => { + if (!meta || meta.type !== "show" || !meta.episode) return; + + // Check if we're at the last episode of the current season + const isLastEpisode = + meta.episode.number === meta.episodes?.[meta.episodes.length - 1]?.number; + + if (!isLastEpisode) { + // Navigate to next episode in current season + const nextEp = meta.episodes?.find( + (v) => v.number === meta.episode!.number + 1, + ); + if (nextEp) { + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + const metaCopy = { ...meta }; + metaCopy.episode = nextEp; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } else { + // Navigate to first episode of next season + if (!meta.tmdbId) return; + + try { + const data = await getMetaFromId(MWMediaType.SERIES, meta.tmdbId); + if (data?.meta.type !== MWMediaType.SERIES) return; + + const nextSeason = data.meta.seasons?.find( + (season) => season.number === (meta.season?.number ?? 0) + 1, + ); + + if (nextSeason) { + const seasonData = await getMetaFromId( + MWMediaType.SERIES, + meta.tmdbId, + nextSeason.id, + ); + + if (seasonData?.meta.type === MWMediaType.SERIES) { + const nextSeasonEpisodes = seasonData.meta.seasonData.episodes + .filter((episode) => { + // Simple aired check - episodes without air_date are considered aired + return ( + !episode.air_date || new Date(episode.air_date) <= new Date() + ); + }) + .map((episode) => ({ + number: episode.number, + title: episode.title, + tmdbId: episode.id, + air_date: episode.air_date, + })); + + if (nextSeasonEpisodes.length > 0) { + const nextEp = nextSeasonEpisodes[0]; + + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + + const metaCopy = { ...meta }; + metaCopy.episode = nextEp; + metaCopy.season = { + number: nextSeason.number, + title: nextSeason.title, + tmdbId: nextSeason.id, + }; + metaCopy.episodes = nextSeasonEpisodes; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } + } + } catch (error) { + console.error("Failed to load next season:", error); + } + } + }, [ + meta, + setDirectMeta, + setShouldStartFromBeginning, + updateItem, + sourceId, + setLastSuccessfulSource, + ]); + + const navigateToPreviousEpisode = useCallback(async () => { + if (!meta || meta.type !== "show" || !meta.episode) return; + + // Check if we're at the first episode of the current season + const isFirstEpisode = meta.episode.number === meta.episodes?.[0]?.number; + + if (!isFirstEpisode) { + // Navigate to previous episode in current season + const prevEp = meta.episodes?.find( + (v) => v.number === meta.episode!.number - 1, + ); + if (prevEp) { + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + const metaCopy = { ...meta }; + metaCopy.episode = prevEp; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } else { + // Navigate to last episode of previous season + if (!meta.tmdbId) return; + + try { + const data = await getMetaFromId(MWMediaType.SERIES, meta.tmdbId); + if (data?.meta.type !== MWMediaType.SERIES) return; + + const prevSeason = data.meta.seasons?.find( + (season) => season.number === (meta.season?.number ?? 0) - 1, + ); + + if (prevSeason) { + const seasonData = await getMetaFromId( + MWMediaType.SERIES, + meta.tmdbId, + prevSeason.id, + ); + + if (seasonData?.meta.type === MWMediaType.SERIES) { + const prevSeasonEpisodes = seasonData.meta.seasonData.episodes + .filter((episode) => { + // Simple aired check - episodes without air_date are considered aired + return ( + !episode.air_date || new Date(episode.air_date) <= new Date() + ); + }) + .map((episode) => ({ + number: episode.number, + title: episode.title, + tmdbId: episode.id, + air_date: episode.air_date, + })); + + if (prevSeasonEpisodes.length > 0) { + const prevEp = prevSeasonEpisodes[prevSeasonEpisodes.length - 1]; + + if (sourceId) { + setLastSuccessfulSource(sourceId); + } + + const metaCopy = { ...meta }; + metaCopy.episode = prevEp; + metaCopy.season = { + number: prevSeason.number, + title: prevSeason.title, + tmdbId: prevSeason.id, + }; + metaCopy.episodes = prevSeasonEpisodes; + setShouldStartFromBeginning(true); + setDirectMeta(metaCopy); + const defaultProgress = { duration: 0, watched: 0 }; + updateItem({ + meta: metaCopy, + progress: defaultProgress, + }); + } + } + } + } catch (error) { + console.error("Failed to load previous season:", error); + } + } + }, [ + meta, + setDirectMeta, + setShouldStartFromBeginning, + updateItem, + sourceId, + setLastSuccessfulSource, + ]); + const dataRef = useRef({ setShowVolume, setVolume, @@ -74,6 +284,8 @@ export function KeyboardEvents() { boostTimeoutRef, isPendingBoostRef, enableHoldToBoost, + navigateToNextEpisode, + navigateToPreviousEpisode, }); useEffect(() => { @@ -104,6 +316,8 @@ export function KeyboardEvents() { boostTimeoutRef, isPendingBoostRef, enableHoldToBoost, + navigateToNextEpisode, + navigateToPreviousEpisode, }; }, [ setShowVolume, @@ -127,6 +341,8 @@ export function KeyboardEvents() { setSpeedBoosted, setShowSpeedIndicator, enableHoldToBoost, + navigateToNextEpisode, + navigateToPreviousEpisode, ]); useEffect(() => { @@ -304,6 +520,10 @@ export function KeyboardEvents() { } if (k === "Escape") dataRef.current.router.close(); + // Episode navigation (shows only) + if (keyL === "n") dataRef.current.navigateToNextEpisode(); + if (keyL === "p") dataRef.current.navigateToPreviousEpisode(); + // captions if (keyL === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors