Resume tracking: per-episode positions, player-agnostic fallback, series continue point

- Fix merged playback-state dropping season/episode, which broke series
  resume restore entirely.
- Track watch progress per episode (episodes map keyed S..E.., with done
  markers on completion) while keeping one continue point per series as
  the last watched episode, used for spoiler protection and season
  selection. MPC-BE tracker resumes from each episode's own position.
- Assumed-playback fallback for any player and server-only mode: a launch
  starts a session and the guessed position (base + elapsed, capped at
  runtime) is written when frontend activity resumes; the real MPC-BE
  tracker overrides guesses via timestamps. Frontend reports input
  activity (throttled) via POST /api/activity.
- Ignore watches under 5 minutes in both trackers.
- Episode tiles show a small quadrant-circle watch indicator; near end
  counts as fully watched, no data shows nothing.
- Fix episode audio ownership: single audioOwnerKey shared by mouse hover
  and keyboard/gamepad focus, no longer clobbered by playback sync;
  idle fade can be re-armed by continued activity.
This commit is contained in:
2026-09-10 20:56:03 +00:00
parent 6a6a012efe
commit 9ec4f877eb
9 changed files with 916 additions and 130 deletions
+31 -2
View File
@@ -177,6 +177,8 @@
:all-movies="mediaIndex?.movies ?? []"
:focus-episode="focusEpisode"
:has-resume-position="hasResumePosition"
:get-resume-point="getResumePoint"
:get-resume-episodes="getResumeEpisodes"
:get-root-name="getRootName"
@close="closeDetail"
@play="handlePlay"
@@ -201,6 +203,7 @@ import type {
MediaItem,
EpisodeWithSeries,
TaskInfo,
SeriesResumePoint,
} from "./types"
import {
playMedia,
@@ -208,6 +211,8 @@ import {
isMpcBeReachable,
fetchResumePositions,
getPlayerStatus,
type ResumePositionEntry,
type EpisodeWatchEntry,
} from "./api"
import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
@@ -497,7 +502,7 @@ const settings = useSettings()
const searchResults = ref<MediaItem[]>([])
const isSearching = ref(false)
const mpcBeConnected = ref(false)
const resumePositions = ref<Record<string, number>>({})
const resumePositions = ref<Record<string, ResumePositionEntry>>({})
const searchQuery = ref(getRouteSearchQuery())
const searchReturnPath = ref<string | null>(null)
const browsePanelRef = ref<HTMLElement | null>(null)
@@ -540,6 +545,10 @@ async function refreshResumePositions() {
resumePositions.value = await fetchResumePositions()
}
function refreshResumePositionsAsEvent() {
void refreshResumePositions()
}
async function refreshPlayerStatus() {
if (!isMpcFamilySelected()) {
mpcBeConnected.value = false
@@ -555,7 +564,25 @@ async function refreshPlayerStatus() {
function hasResumePosition(mediaId: string | null) {
if (!mediaId) return false
return Number(resumePositions.value[mediaId] || 0) > 0
return (resumePositions.value[mediaId]?.pos || 0) > 0
}
function getResumePoint(mediaId: string | null): SeriesResumePoint | null {
if (!mediaId) return null
const entry = resumePositions.value[mediaId]
if (!entry || entry.season === null || entry.episode === null) return null
return {
seasonNumber: entry.season,
episodeNumber: entry.episode,
positionSeconds: entry.pos,
}
}
function getResumeEpisodes(
mediaId: string | null,
): Record<string, EpisodeWatchEntry> | null {
if (!mediaId) return null
return resumePositions.value[mediaId]?.episodes ?? null
}
function startMpcBePolling() {
@@ -936,6 +963,7 @@ onMounted(() => {
document.addEventListener("keydown", handleDetailAdjacentKey)
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
window.addEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
})
onUnmounted(() => {
@@ -943,6 +971,7 @@ onUnmounted(() => {
document.removeEventListener("keydown", handleDetailAdjacentKey)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
window.removeEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
stopMpcBePolling()
})
+63 -5
View File
@@ -148,10 +148,25 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
}
/** Watch progress for one episode of a series. */
export interface EpisodeWatchEntry {
pos: number
done: boolean
}
/** One stored continue point. season/episode are set for series, null for movies. */
export interface ResumePositionEntry {
pos: number
season: number | null
episode: number | null
/** Per-episode watch progress for series, keyed "S<season>E<episode>". */
episodes?: Record<string, EpisodeWatchEntry>
}
/**
* Fetch merged resume positions from all roots.
*/
export async function fetchResumePositions(): Promise<Record<string, number>> {
export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
try {
const response = await fetch("/api/meta/playback-state")
if (!response.ok) return {}
@@ -160,12 +175,32 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
if (!positions || typeof positions !== "object") {
return {}
}
const normalized: Record<string, number> = {}
const normalized: Record<string, ResumePositionEntry> = {}
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue
const pos = (value as { pos?: unknown }).pos
if (typeof pos === "number" && Number.isFinite(pos) && pos > 0) {
normalized[slug] = pos
const entry = value as { pos?: unknown; season?: unknown; episode?: unknown }
if (typeof entry.pos !== "number" || !Number.isFinite(entry.pos) || entry.pos < 0) {
continue
}
normalized[slug] = {
pos: entry.pos,
season: typeof entry.season === "number" ? entry.season : null,
episode: typeof entry.episode === "number" ? entry.episode : null,
}
const rawEpisodes = (entry as { episodes?: unknown }).episodes
if (rawEpisodes && typeof rawEpisodes === "object") {
const watches: Record<string, EpisodeWatchEntry> = {}
for (const [key, watch] of Object.entries(
rawEpisodes as Record<string, unknown>,
)) {
if (!watch || typeof watch !== "object") continue
const w = watch as { pos?: unknown; done?: unknown }
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
watches[key] = { pos: w.pos, done: w.done === true }
}
if (Object.keys(watches).length > 0) {
normalized[slug].episodes = watches
}
}
}
return normalized
@@ -174,6 +209,29 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
}
}
/**
* Report that the user is actively interacting with the UI.
*
* Ends any server-side assumed-playback session (launched item is assumed
* watched while the UI sees no input). Throttled; fire-and-forget.
*/
let lastActivityReportAt = 0
export function reportUserActivity(): void {
const now = Date.now()
if (now - lastActivityReportAt < 5000) return
lastActivityReportAt = now
void fetch("/api/activity", { method: "POST" })
.then(async (response) => {
if (!response.ok) return
const data = await response.json().catch(() => null)
if (data?.finalized) {
// An assumed-playback position was just written; let views refetch.
window.dispatchEvent(new Event("mediahive:resume-updated"))
}
})
.catch(() => {})
}
/**
* Replace the full root set atomically
*/
+6 -2
View File
@@ -5,7 +5,8 @@
:series="item.data as Series"
:all-movies="allMovies"
:focus-episode="focusEpisode"
:has-resume-position="hasResumePosition"
:resume-point="getResumePoint(item.id)"
:resume-episodes="getResumeEpisodes(item.id)"
:get-root-name="getRootName"
@close="$emit('close')"
@play="handlePlay"
@@ -233,7 +234,8 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type { CastMember, MediaItem, Movie, MovieUi, Series, Torrent } from "../types"
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
import type { EpisodeWatchEntry } from "../api"
import {
getCoverUrl,
getVideoPreviewUrl,
@@ -260,6 +262,8 @@ const props = defineProps<{
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (mediaId: string | null) => boolean
getResumePoint: (mediaId: string | null) => SeriesResumePoint | null
getResumeEpisodes: (mediaId: string | null) => Record<string, EpisodeWatchEntry> | null
getRootName: (rootId: string | null | undefined) => string | null
}>()
const emit = defineEmits<{
+244 -55
View File
@@ -158,6 +158,14 @@
/>
</video>
<span class="ep-number">{{ episode.episode_number }}</span>
<span
v-if="episodeWatchIndicator(episode)"
class="ep-watch"
:title="
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
"
>{{ episodeWatchIndicator(episode) }}</span
>
<div class="tile-play"></div>
</div>
@@ -230,7 +238,7 @@
`Episode ${episodeReleaseMenu.episode?.episode_number}`
"
:releases="episodeReleaseMenuReleases"
:has-resume-position="props.hasResumePosition"
:has-resume-position="hasEpisodeResumePosition"
@play="handlePlayVersion"
@open-folder="handleOpenFolderFromMenu"
@close="closeEpisodeReleaseMenu"
@@ -241,8 +249,9 @@
<script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import type { Series, Season, Episode, MovieUi } from "../types"
import type { Series, Season, Episode, MovieUi, SeriesResumePoint } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import type { EpisodeWatchEntry } from "../api"
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
@@ -252,7 +261,8 @@ const props = defineProps<{
series: Series & { root_id?: string | null }
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean
resumePoint?: SeriesResumePoint | null
resumeEpisodes?: Record<string, EpisodeWatchEntry> | null
getRootName: (rootId: string | null | undefined) => string | null
}>()
@@ -269,7 +279,7 @@ const linkedMoviesNavRow = ref(3)
let navLayoutFrame: number | null = null
function getInitialSeasonIndex(): number {
const seasonNumber = props.focusEpisode?.seasonNumber
const seasonNumber = props.focusEpisode?.seasonNumber ?? props.resumePoint?.seasonNumber
if (typeof seasonNumber === "number") {
const index = props.series.seasons.findIndex((s) => s.season_number === seasonNumber)
if (index >= 0) return index
@@ -279,6 +289,10 @@ function getInitialSeasonIndex(): number {
const selectedSeasonIndex = ref(getInitialSeasonIndex())
// Tracks whether the user has deliberately navigated the season selector or
// episode grid; a late-arriving resume point must not yank focus afterwards.
const seasonUserInteracted = ref(false)
const selectedSeason = computed<Season | null>(
() => props.series.seasons[selectedSeasonIndex.value] || null,
)
@@ -286,6 +300,7 @@ const selectedSeason = computed<Season | null>(
function selectSeason(index: number) {
if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return
seasonUserInteracted.value = true
selectedSeasonIndex.value = index
episodeCursorIndex.value = null
scheduleEpisodeMediaReady()
@@ -316,16 +331,80 @@ function scheduleEpisodeMediaReady() {
}, EPISODE_MEDIA_SETTLE_MS)
}
// Spoiler avoidance: episodes ahead of the cursor (keyboard/gamepad focus, or
// mouse hover via the focus it triggers) are dimmed with their synopsis hidden.
// Spoiler avoidance: episodes past the visibility threshold are dimmed with
// their synopsis hidden (playback is stopped via the cursor watch). The
// threshold is the further of the cursor (keyboard/gamepad focus, or mouse
// hover via the focus it triggers) and the series' continue point, so
// already-watched episodes stay visible even when the cursor moves back.
const episodeCursorIndex = ref<number | null>(null)
// Global episode ordering across the series: seasons in list order,
// episodes in list order within each season.
function seasonEpisodeOffset(seasonIndex: number): number {
let total = 0
const seasons = props.series.seasons
for (let i = 0; i < seasonIndex && i < seasons.length; i += 1) {
total += seasons[i]?.episodes.length ?? 0
}
return total
}
const cursorGlobalIndex = computed(() =>
episodeCursorIndex.value === null
? null
: seasonEpisodeOffset(selectedSeasonIndex.value) + episodeCursorIndex.value,
)
const resumePointGlobalIndex = computed(() => {
const point = props.resumePoint
if (!point) return null
const seasonIndex = props.series.seasons.findIndex(
(s) => s.season_number === point.seasonNumber,
)
if (seasonIndex < 0) return null
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
(e) => e.episode_number === point.episodeNumber,
)
if (episodeIndex === undefined || episodeIndex < 0) return null
// The continue point itself stays visible; anything past it is hidden.
return seasonEpisodeOffset(seasonIndex) + episodeIndex
})
function isEpisodeAhead(episodeIndex: number): boolean {
return episodeCursorIndex.value !== null && episodeIndex > episodeCursorIndex.value
const threshold = Math.max(cursorGlobalIndex.value ?? -1, resumePointGlobalIndex.value ?? -1)
if (threshold < 0) return false
return seasonEpisodeOffset(selectedSeasonIndex.value) + episodeIndex > threshold
}
// Small watch-progress indicator per episode: quadrant circle chars, none
// when there is no watch data; near the end counts as fully watched.
function episodeWatchIndicator(episode: Episode): string | null {
const season = selectedSeason.value
const watches = props.resumeEpisodes
if (!season || !watches) return null
const watch = watches[`S${season.season_number}E${episode.episode_number}`]
if (!watch) return null
if (watch.done) return "●"
if (watch.pos <= 0) return null
const durationS = episode.runtime ? episode.runtime * 60 : null
if (!durationS) return "◔"
const fraction = watch.pos / durationS
if (fraction >= 0.95) return "●"
if (fraction >= 0.5) return "◕"
if (fraction >= 0.25) return "◑"
return "◔"
}
function handleEpisodeFocusIn(event: FocusEvent, episodeIndex: number) {
episodeCursorIndex.value = episodeIndex
// Keyboard/gamepad navigation lands here without any mouse event, so this
// is where those paths claim audio. Programmatic focus on open is
// suppressed so the auto-focused resume episode stays silent.
if (suppressNextFocusAudio) {
suppressNextFocusAudio = false
} else {
setAudioOwner(`${episodeIndex}`)
}
// Updating the cursor re-renders this tile's :class binding, and Vue's class
// patch rewrites the whole class attribute, clobbering the "nav-focused"
// class that the keyboard-navigation composable adds imperatively during
@@ -346,7 +425,14 @@ function getEpisodeStill(episode: Episode): string | undefined {
}
function handleVideoPlaying(event: Event) {
;(event.target as HTMLVideoElement | null)?.classList.add("is-playing")
const video = event.target as HTMLVideoElement | null
video?.classList.add("is-playing")
// Deferred hover audio: if this tile was hovered before its video started
// (lazy mount), unmute/ramp now that playback is running.
const key = video?.dataset.previewKey
if (video && key && key === audioOwnerKey) {
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
}
}
// Poster browser stage: the selected season is the topmost item of the left
@@ -536,11 +622,28 @@ watch(
{ immediate: true },
)
// Focus on matched episode when provided
// Focus target: an explicit episode (search match) wins; otherwise the
// series' continue point takes us to the season/episode being watched.
const episodeFocusTarget = computed(() => {
if (props.focusEpisode) return props.focusEpisode
const point = props.resumePoint
if (!point) return null
return { seasonNumber: point.seasonNumber, episodeNumber: point.episodeNumber }
})
// Focus on the target episode when provided. The resume-point fallback only
// applies until the user navigates on their own, so late-arriving resume
// data does not yank focus away.
watch(
() => props.focusEpisode,
episodeFocusTarget,
(ep) => {
if (!ep) return
if (
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
return
}
const seasonIndex =
props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
if (seasonIndex < 0) return
@@ -560,6 +663,7 @@ watch(
const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
suppressNextFocusAudio = true
element.focus()
}
}, 150)
@@ -588,6 +692,20 @@ const episodeReleaseMenuReleases = computed(() => {
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {}))
})
// "Continue" label for the episode release menu: shown when the menu's
// episode is the series' continue point with a real position to resume.
function hasEpisodeResumePosition(_filePath: string | null): boolean {
const point = props.resumePoint
const episode = episodeReleaseMenu.value.episode
if (!point || point.positionSeconds <= 0 || !episode) return false
const seasonIndex = props.series.seasons.findIndex((s) => s.episodes.includes(episode))
if (seasonIndex < 0) return false
return (
props.series.seasons[seasonIndex]?.season_number === point.seasonNumber &&
episode.episode_number === point.episodeNumber
)
}
function normalizeMatchText(value: string | null | undefined): string {
return (value || "")
.toLowerCase()
@@ -806,8 +924,8 @@ function stopEpisodePreviews() {
seasonStartupToken += 1
const token = seasonStartupToken
clearSeasonStartupTimers()
clearEpisodeHoverAudioIdleTimer()
hoveredEpisodeAudioKey = null
clearAudioIdleTimer()
audioOwnerKey = null
for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval)
}
@@ -844,10 +962,9 @@ function syncSeasonVideoPlayback() {
seasonStartupToken += 1
const token = seasonStartupToken
clearSeasonStartupTimers()
for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval)
}
volumeFadeIntervals.clear()
// Do NOT touch volumeFadeIntervals here: hover/focus audio ramps run
// independently of playback sync, and clearing them mid-fade leaves the
// previous tile stuck audible and the newly hovered one stuck silent.
const videosToStart: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = []
const videosToStop: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = []
@@ -948,12 +1065,21 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
el.dataset.previewKey = key
getEpisodeVisibilityObserver().observe(el)
syncSeasonVideoPlayback()
// A hover/focus that arrived before this video mounted still owns the audio.
if (audioOwnerKey === key && !el.paused && !el.ended) {
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
}
} else {
const timeoutId = seasonStartupTimers.get(key)
if (timeoutId) {
clearTimeout(timeoutId)
seasonStartupTimers.delete(key)
}
const fadeInterval = volumeFadeIntervals.get(key)
if (fadeInterval) {
clearInterval(fadeInterval)
volumeFadeIntervals.delete(key)
}
const old = videoRefs.value.get(key)
if (old) {
cleanupVideo(old)
@@ -969,19 +1095,29 @@ const AUDIO_FADE_INTERVAL_MS = 40
const AUDIO_IDLE_FADE_DELAY_MS = 1600
const AUDIO_LEAVE_FADE_DELAY_MS = 350
const AUDIO_HOVER_TARGET_VOLUME = 0.5
let hoveredEpisodeAudioKey: string | null = null
let hoverEpisodeAudioIdleTimer: ReturnType<typeof setTimeout> | null = null
// Single audio owner: the episode tile currently pointed at (mouse hover or
// keyboard/gamepad focus). Only this tile's video is unmuted; every other
// mounted video is ramped to silence whenever the owner changes.
let audioOwnerKey: string | null = null
let audioIdleTimer: ReturnType<typeof setTimeout> | null = null
// Set while a programmatic focus (season open / resume point) is in flight so
// the resulting focusin does not grab audio the user never asked for.
let suppressNextFocusAudio = false
function clearEpisodeHoverAudioIdleTimer() {
if (hoverEpisodeAudioIdleTimer !== null) {
clearTimeout(hoverEpisodeAudioIdleTimer)
hoverEpisodeAudioIdleTimer = null
function clearAudioIdleTimer() {
if (audioIdleTimer !== null) {
clearTimeout(audioIdleTimer)
audioIdleTimer = null
}
}
function rampEpisodeVolume(key: string, targetVolume: number) {
const video = videoRefs.value.get(key)
if (!video) return
// Never touch a paused element: unmuting before play() would turn the
// start into audible autoplay, which browsers may block. The playing
// event applies pending hover audio once playback is running.
if (video.paused) return
const existingInterval = volumeFadeIntervals.get(key)
if (existingInterval) {
@@ -998,6 +1134,13 @@ function rampEpisodeVolume(key: string, targetVolume: number) {
}
const fadeInterval = setInterval(() => {
if (video.paused) {
// Playback stopped mid-fade (e.g. scrolled offscreen or spoiler fade):
// abandon the ramp so it cannot resurrect audio on a stopped video.
clearInterval(fadeInterval)
volumeFadeIntervals.delete(key)
return
}
const delta = clampedTarget - video.volume
if (Math.abs(delta) <= AUDIO_FADE_STEP) {
video.volume = clampedTarget
@@ -1015,23 +1158,60 @@ function rampEpisodeVolume(key: string, targetVolume: number) {
volumeFadeIntervals.set(key, fadeInterval)
}
function scheduleEpisodeHoverAudioIdleFade(
key: string,
delayMs: number = AUDIO_IDLE_FADE_DELAY_MS,
) {
clearEpisodeHoverAudioIdleTimer()
hoverEpisodeAudioIdleTimer = setTimeout(() => {
// Silence the current owner after a delay. If the owner is reassigned before
// the timer fires, clearAudioIdleTimer() keeps the old tile audible — the new
// owner's setAudioOwner() call ramps it down instead.
function scheduleAudioIdleFade(delayMs: number = AUDIO_IDLE_FADE_DELAY_MS) {
const key = audioOwnerKey
clearAudioIdleTimer()
if (!key) return
audioIdleTimer = setTimeout(() => {
audioIdleTimer = null
if (audioOwnerKey !== key) return
audioOwnerKey = null
rampEpisodeVolume(key, 0)
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
}, delayMs)
}
function setAudioOwner(key: string | null) {
if (key === audioOwnerKey) {
// Same tile pointed at again (mousemove, repeat focus): just re-arm idle.
scheduleAudioIdleFade()
return
}
audioOwnerKey = key
videoRefs.value.forEach((_, k) => {
if (k !== key) {
rampEpisodeVolume(k, 0)
}
})
// No-op while the video is not mounted/playing yet (lazy mount); the
// playing event applies the pending audio once playback starts.
if (key) {
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
}
scheduleAudioIdleFade()
}
// Re-acquire audio for the currently pointed tile after an idle fade, and
// re-arm the idle timer on any continued activity.
function rearmAudioFromActivity() {
const pointedKey = episodeCursorIndex.value === null ? null : `${episodeCursorIndex.value}`
if (audioOwnerKey !== pointedKey) {
setAudioOwner(pointedKey)
} else if (pointedKey) {
scheduleAudioIdleFade()
}
}
function handleEpisodeHoverAudioMouseMove() {
if (!document.documentElement.classList.contains("mouse-active")) return
if (!hoveredEpisodeAudioKey) return
scheduleEpisodeHoverAudioIdleFade(hoveredEpisodeAudioKey)
rearmAudioFromActivity()
}
function handleAudioKeyActivity() {
if (document.documentElement.classList.contains("mouse-active")) return
rearmAudioFromActivity()
}
// Handle hover-based audio fade in/out for episode videos
@@ -1047,27 +1227,15 @@ function handleEpisodeHover(
if (!document.documentElement.classList.contains("mouse-active")) return
const video = videoRefs.value.get(key)
if (!video) return
if (isEntering) {
if (event?.currentTarget instanceof HTMLElement) {
event.currentTarget.focus({ preventScroll: true })
}
syncSeasonVideoPlayback()
hoveredEpisodeAudioKey = key
videoRefs.value.forEach((_, k) => {
if (k !== key) {
rampEpisodeVolume(k, 0)
}
})
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
scheduleEpisodeHoverAudioIdleFade(key)
} else {
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
scheduleEpisodeHoverAudioIdleFade(key, AUDIO_LEAVE_FADE_DELAY_MS)
setAudioOwner(key)
} else if (audioOwnerKey === key) {
// Keep the owner until the leave grace expires, so quick re-entry is
// seamless; scheduleAudioIdleFade drops ownership when it fires.
scheduleAudioIdleFade(AUDIO_LEAVE_FADE_DELAY_MS)
}
}
@@ -1190,6 +1358,7 @@ onMounted(() => {
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.addEventListener("resize", handleStageResize, { passive: true })
window.addEventListener("mousemove", handleEpisodeHoverAudioMouseMove, { passive: true })
window.addEventListener("keydown", handleAudioKeyActivity)
nextTick(() => {
measureStageWidth()
scheduleEpisodeMediaReady()
@@ -1201,7 +1370,8 @@ onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.removeEventListener("resize", handleStageResize)
window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove)
clearEpisodeHoverAudioIdleTimer()
window.removeEventListener("keydown", handleAudioKeyActivity)
clearAudioIdleTimer()
clearSeasonStartupTimers()
if (episodeMediaReadyTimer !== null) {
clearTimeout(episodeMediaReadyTimer)
@@ -1236,8 +1406,14 @@ watch(
},
)
// Episodes ahead of the cursor are spoiler-faded; stop their playback too.
watch(episodeCursorIndex, () => {
// Episodes past the spoiler threshold (cursor or continue point) are faded;
// stop their playback too.
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
if (episodeCursorIndex.value === null) {
// Cursor left the episode grid (season selector, season switch): no tile
// is pointed at, so audio must go silent regardless of how we got here.
setAudioOwner(null)
}
syncSeasonVideoPlayback()
})
</script>
@@ -1723,8 +1899,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
opacity: 1;
}
/* Spoiler avoidance: episodes ahead of the cursor fade out completely,
including the synopsis (playback is stopped via the cursor watch). */
/* Spoiler avoidance: episodes past the spoiler threshold (cursor or
continue point) fade out completely, including the synopsis (playback is
stopped via the threshold watch). */
.episode-tile--ahead .tile-media {
opacity: 0;
}
@@ -1776,6 +1953,18 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
pointer-events: none;
}
.ep-watch {
position: absolute;
top: 10px;
right: 10px;
font-size: 0.85rem;
color: white;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.9);
opacity: 0.85;
line-height: 1;
pointer-events: none;
}
/* Play indicator */
.tile-play {
position: absolute;
@@ -1,5 +1,8 @@
import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700
@@ -90,6 +93,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
}
function handleMouseMove(event: MouseEvent) {
reportUserActivity()
showPointerFromMotion()
if (modality === "mouse") {
@@ -112,6 +116,7 @@ function handleMouseOver(event: MouseEvent) {
}
function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
reportUserActivity()
pointerVisible = true
if (isMouseIntentTarget(event.target)) {
activateMouseInput()
@@ -124,6 +129,7 @@ function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
function handleKeyboardActivity(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return
reportUserActivity()
activateNonMouseInput("keyboard")
}
+7
View File
@@ -109,6 +109,13 @@ export interface Series {
seasons: Season[]
}
/** A series' single continue point (last watched position). */
export interface SeriesResumePoint {
seasonNumber: number
episodeNumber: number
positionSeconds: number
}
export interface MovieUi extends Movie {
id: string
root_id: string | null