Add live introdb key reload and recap playback markers
- reload introdb API key on setting updates - support recap/preview markers in playback and next-episode flow - add profile defaults for recap and preview auto-play settings
This commit is contained in:
+19
-1
@@ -47,9 +47,9 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/jellycompat"
|
||||
"github.com/Silo-Server/silo-server/internal/libraryingest"
|
||||
"github.com/Silo-Server/silo-server/internal/logfilter"
|
||||
"github.com/Silo-Server/silo-server/internal/logstream"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/markers/introdb"
|
||||
"github.com/Silo-Server/silo-server/internal/logstream"
|
||||
"github.com/Silo-Server/silo-server/internal/mdblist"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -434,6 +434,24 @@ func main() {
|
||||
if err := markerRegistry.Register(introdb.NewProvider(introdbClient)); err != nil {
|
||||
log.Fatalf("register introdb marker provider: %v", err)
|
||||
}
|
||||
deps.OnServerSettingUpdated = func(_ context.Context, key, value string) {
|
||||
if key == "introdb.api_key" {
|
||||
introdbClient.SetAPIKey(value)
|
||||
}
|
||||
}
|
||||
if eventBus != nil {
|
||||
_ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) {
|
||||
if event.Type != cache.EventSettingsChanged || event.Payload != "introdb.api_key" {
|
||||
return
|
||||
}
|
||||
value, loadErr := settingsRepo.Get(context.Background(), "introdb.api_key")
|
||||
if loadErr != nil {
|
||||
slog.Warn("introdb api key reload failed", "error", loadErr)
|
||||
return
|
||||
}
|
||||
introdbClient.SetAPIKey(value)
|
||||
})
|
||||
}
|
||||
deps.MarkerRegistry = markerRegistry
|
||||
deps.MarkerResolver = markers.NewDBExternalIDResolver(deps.DB)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ type AdminHandler struct {
|
||||
BootstrapSensitiveConfigured map[string]bool
|
||||
BootstrapSensitiveValues map[string]string
|
||||
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
||||
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler backed by the given
|
||||
@@ -1022,6 +1023,7 @@ var sensitiveSettingKeys = map[string]bool{
|
||||
"recommendations.openai_api_key": true,
|
||||
"recommendations.embedding_auth_token": true,
|
||||
"tmdb.api_key": true,
|
||||
"introdb.api_key": true,
|
||||
"mdblist.api_key": true,
|
||||
"watchsync.trakt.client_id": true,
|
||||
"watchsync.trakt.client_secret": true,
|
||||
@@ -1890,7 +1892,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
if h.EventBus != nil {
|
||||
_ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin,
|
||||
cache.Event{Type: cache.EventSettingsChanged})
|
||||
cache.Event{Type: cache.EventSettingsChanged, Payload: key})
|
||||
}
|
||||
if h.OnServerSettingUpdated != nil {
|
||||
h.OnServerSettingUpdated(r.Context(), key, req.Value)
|
||||
}
|
||||
|
||||
if sensitiveSettingKeys[key] {
|
||||
|
||||
@@ -38,9 +38,6 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
if h == nil || session == nil || file == nil || file.ID <= 0 {
|
||||
return
|
||||
}
|
||||
if hasOnlineSourcedMarkers(file) {
|
||||
return
|
||||
}
|
||||
if file.MediaFolderID <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -87,6 +84,9 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
hasOnline := h.hasOnlineMarkerProviders()
|
||||
shouldRunLocal := markers.ShouldRunLocal(mode)
|
||||
shouldRunOnline := (mode == markers.ModeOnline || mode == markers.ModeBoth) && hasOnline
|
||||
if shouldRunOnline && hasOnlineSourcedMarkers(file) {
|
||||
shouldRunOnline = false
|
||||
}
|
||||
|
||||
if shouldRunOnline {
|
||||
// Online providers work for any enabled library (movies and series alike).
|
||||
@@ -184,7 +184,9 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers(
|
||||
if wrote {
|
||||
if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasAnyMarker(refreshed) {
|
||||
h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode)
|
||||
return
|
||||
if !runLocal || hasLocalDetectionMarkers(refreshed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +195,9 @@ func (h *PlaybackHandler) runLazyPlaybackMarkers(
|
||||
// before falling through to the (expensive) local analyzer.
|
||||
if refreshed := h.reloadPlaybackMarkerFile(ctx, file.ID); hasAnyMarker(refreshed) {
|
||||
h.notifyPlaybackMarkers(ctx, sessionID, refreshed, mode)
|
||||
return
|
||||
if !runLocal || hasLocalDetectionMarkers(refreshed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if runLocal {
|
||||
@@ -355,6 +359,14 @@ func hasAnyMarker(file *models.MediaFile) bool {
|
||||
(file.PreviewStart != nil && file.PreviewEnd != nil)
|
||||
}
|
||||
|
||||
func hasLocalDetectionMarkers(file *models.MediaFile) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
return (file.IntroStart != nil && file.IntroEnd != nil) ||
|
||||
(file.CreditsStart != nil && file.CreditsEnd != nil)
|
||||
}
|
||||
|
||||
// markerUpdateFromPayload adapts the generic markers.MarkerUpdatePayload to
|
||||
// the scanner.MarkerUpdate shape consumed by FileRepository.UpsertMarkers.
|
||||
// Kept narrow on purpose — the packages it bridges should not need to import
|
||||
|
||||
@@ -131,6 +131,7 @@ type Dependencies struct {
|
||||
ChapterThumbnailQueuer catalog.ChapterThumbnailQueuer
|
||||
PlaybackRealtimeHub *playback.RealtimeHub
|
||||
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
||||
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
||||
|
||||
// UserCollectionSync handles per-profile imported collections (TMDB /
|
||||
// Trakt / MDBList) — the user-facing analogue of CollectionService.
|
||||
@@ -628,6 +629,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if deps.OnUserSessionsRevoked != nil {
|
||||
adminHandler.OnUserSessionsRevoked = deps.OnUserSessionsRevoked
|
||||
}
|
||||
if deps.OnServerSettingUpdated != nil {
|
||||
adminHandler.OnServerSettingUpdated = deps.OnServerSettingUpdated
|
||||
}
|
||||
}
|
||||
if deps.DB != nil {
|
||||
jobRepo := adminjob.NewRepository(deps.DB)
|
||||
|
||||
@@ -593,10 +593,12 @@ func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
contentID, err := h.codec.DecodeStringID(EncodedIDItem, raw)
|
||||
var requestedFileID int
|
||||
if err != nil {
|
||||
if fileID, fileErr := h.codec.DecodeIntID(EncodedIDMediaSource, raw); fileErr == nil {
|
||||
if owner, ok := h.codec.LookupMediaSourceOwner(fileID); ok {
|
||||
contentID = owner
|
||||
requestedFileID = int(fileID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,9 +623,19 @@ func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Reques
|
||||
// Versions carry the same Intro/Credits/Recap/Preview that the native API
|
||||
// surfaces; the default-playback version owns the segments shown to clients.
|
||||
var version *catalog.FileVersion
|
||||
for i := range detail.Versions {
|
||||
if version == nil || (detail.Versions[i].FileID != 0 && version.FileID == 0) {
|
||||
version = &detail.Versions[i]
|
||||
if requestedFileID > 0 {
|
||||
for i := range detail.Versions {
|
||||
if detail.Versions[i].FileID == requestedFileID {
|
||||
version = &detail.Versions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if requestedFileID == 0 {
|
||||
for i := range detail.Versions {
|
||||
if version == nil || (detail.Versions[i].FileID != 0 && version.FileID == 0) {
|
||||
version = &detail.Versions[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if version == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
@@ -27,6 +28,7 @@ const (
|
||||
// for the same lookup key collapse to a single HTTP round trip via the cache.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
mu sync.RWMutex
|
||||
apiKey string
|
||||
baseURL string
|
||||
limiter *rate.Limiter
|
||||
@@ -51,11 +53,19 @@ func NewClient(apiKey string) *Client {
|
||||
}
|
||||
|
||||
// SetBaseURL overrides the API base URL (used by tests).
|
||||
func (c *Client) SetBaseURL(u string) { c.baseURL = u }
|
||||
func (c *Client) SetBaseURL(u string) {
|
||||
c.mu.Lock()
|
||||
c.baseURL = u
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAPIKey rotates the bearer token in-place. Safe to call concurrently
|
||||
// with in-flight requests; subsequent requests use the new key.
|
||||
func (c *Client) SetAPIKey(apiKey string) { c.apiKey = strings.TrimSpace(apiKey) }
|
||||
func (c *Client) SetAPIKey(apiKey string) {
|
||||
c.mu.Lock()
|
||||
c.apiKey = strings.TrimSpace(apiKey)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close releases the background sweeper goroutine inside the response cache.
|
||||
func (c *Client) Close() {
|
||||
@@ -84,7 +94,7 @@ func (c *Client) FetchEpisode(ctx context.Context, tmdbID, imdbID string, season
|
||||
if durationMS > 0 {
|
||||
q.Set("duration_ms", strconv.FormatInt(durationMS, 10))
|
||||
}
|
||||
return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, imdbID, season, episode))
|
||||
return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, imdbID, season, episode, durationMS))
|
||||
}
|
||||
|
||||
// FetchMovie looks up segment timestamps for a movie.
|
||||
@@ -102,7 +112,7 @@ func (c *Client) FetchMovie(ctx context.Context, tmdbID, imdbID string, duration
|
||||
if durationMS > 0 {
|
||||
q.Set("duration_ms", strconv.FormatInt(durationMS, 10))
|
||||
}
|
||||
return c.fetch(ctx, q, cacheKeyMovie(tmdbID, imdbID))
|
||||
return c.fetch(ctx, q, cacheKeyMovie(tmdbID, imdbID, durationMS))
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaResponse, error) {
|
||||
@@ -114,7 +124,12 @@ func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaRes
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqURL := c.baseURL + "/media?" + q.Encode()
|
||||
c.mu.RLock()
|
||||
baseURL := c.baseURL
|
||||
apiKey := c.apiKey
|
||||
c.mu.RUnlock()
|
||||
|
||||
reqURL := baseURL + "/media?" + q.Encode()
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
@@ -123,8 +138,8 @@ func (c *Client) fetch(ctx context.Context, q url.Values, key string) (*mediaRes
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Silo-Server/markers")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -195,16 +210,16 @@ func retryAfterOrDefault(resp *http.Response, attempt int) time.Duration {
|
||||
return time.Duration(1<<attempt) * time.Second
|
||||
}
|
||||
|
||||
func cacheKeyEpisode(tmdbID, imdbID string, season, episode int) string {
|
||||
func cacheKeyEpisode(tmdbID, imdbID string, season, episode int, durationMS int64) string {
|
||||
if tmdbID != "" {
|
||||
return fmt.Sprintf("tmdb:%s:s%de%d", tmdbID, season, episode)
|
||||
return fmt.Sprintf("tmdb:%s:s%de%d:d%d", tmdbID, season, episode, durationMS)
|
||||
}
|
||||
return fmt.Sprintf("imdb:%s:s%de%d", imdbID, season, episode)
|
||||
return fmt.Sprintf("imdb:%s:s%de%d:d%d", imdbID, season, episode, durationMS)
|
||||
}
|
||||
|
||||
func cacheKeyMovie(tmdbID, imdbID string) string {
|
||||
func cacheKeyMovie(tmdbID, imdbID string, durationMS int64) string {
|
||||
if tmdbID != "" {
|
||||
return "tmdb:movie:" + tmdbID
|
||||
return fmt.Sprintf("tmdb:movie:%s:d%d", tmdbID, durationMS)
|
||||
}
|
||||
return "imdb:movie:" + imdbID
|
||||
return fmt.Sprintf("imdb:movie:%s:d%d", imdbID, durationMS)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func CanWriteMarker(existingSource *string, existingConfidence *float64, newSour
|
||||
if existingConfidence != nil && newConfidence != nil {
|
||||
return *newConfidence > *existingConfidence
|
||||
}
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkerUpdatePayload is the storage-agnostic shape produced from a provider
|
||||
|
||||
@@ -269,12 +269,45 @@ func InitSchema(db *sql.DB) error {
|
||||
if err := ensureDeviceSettingsProfileColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureAutoSkipRecapPreviewColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureWatchHistoryIdentityColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migratePlaybackSettingsToDeviceScope(db)
|
||||
}
|
||||
|
||||
func ensureAutoSkipRecapPreviewColumns(db *sql.DB) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{name: "auto_skip_recap", definition: "BOOLEAN DEFAULT false"},
|
||||
{name: "auto_play_next_preview", definition: "BOOLEAN DEFAULT false"},
|
||||
}
|
||||
|
||||
for _, column := range columns {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
||||
"profiles",
|
||||
column.name,
|
||||
).Scan(&count); err != nil {
|
||||
return fmt.Errorf("checking profiles.%s column: %w", column.name, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
fmt.Sprintf("ALTER TABLE profiles ADD COLUMN %s %s", column.name, column.definition),
|
||||
); err != nil {
|
||||
return fmt.Errorf("adding profiles.%s column: %w", column.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureWatchHistoryIdentityColumn(db *sql.DB) error {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
|
||||
@@ -128,8 +128,8 @@ export interface Profile {
|
||||
show_forced_subtitles?: boolean;
|
||||
auto_skip_intro: boolean;
|
||||
auto_skip_credits: boolean;
|
||||
auto_skip_recap: boolean;
|
||||
auto_play_next_preview: boolean;
|
||||
auto_skip_recap?: boolean;
|
||||
auto_play_next_preview?: boolean;
|
||||
library_restrictions_enabled: boolean;
|
||||
allowed_library_ids: number[] | null;
|
||||
max_playback_quality: string;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
interface IntroSkipButtonProps {
|
||||
onSkip: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Skip Intro" button visible during the intro time range.
|
||||
*/
|
||||
export function IntroSkipButton({ onSkip }: IntroSkipButtonProps) {
|
||||
export function IntroSkipButton({ onSkip, label = "Skip Intro" }: IntroSkipButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
type="button"
|
||||
className="absolute right-6 bottom-24 z-50 rounded border border-white/40 bg-black/70 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
Skip Intro
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ interface VideoPlayerProps {
|
||||
intro: PlayerTimeRange | null;
|
||||
autoSkipIntro?: boolean;
|
||||
credits: PlayerTimeRange | null;
|
||||
recap?: PlayerTimeRange | null;
|
||||
autoSkipRecap?: boolean;
|
||||
preview?: PlayerTimeRange | null;
|
||||
autoPlayNextPreview?: boolean;
|
||||
duration?: number;
|
||||
seriesContext?: SeriesContext;
|
||||
onNavigateEpisode?: (contentId: string) => void;
|
||||
@@ -154,6 +158,10 @@ export function VideoPlayer({
|
||||
intro,
|
||||
autoSkipIntro = false,
|
||||
credits,
|
||||
recap = null,
|
||||
autoSkipRecap = false,
|
||||
preview = null,
|
||||
autoPlayNextPreview = false,
|
||||
duration: propDuration,
|
||||
seriesContext,
|
||||
onNavigateEpisode,
|
||||
@@ -190,6 +198,7 @@ export function VideoPlayer({
|
||||
const backendDurationRef = useRef(propDuration ?? 0);
|
||||
const autoEnterPictureInPictureAttemptedRef = useRef(false);
|
||||
const autoSkippedIntroKeyRef = useRef<string | null>(null);
|
||||
const autoSkippedRecapKeyRef = useRef<string | null>(null);
|
||||
const endedFiredRef = useRef(false);
|
||||
const [hasEnded, setHasEnded] = useState(false);
|
||||
const onEndedRef = useRef(onEnded);
|
||||
@@ -822,7 +831,7 @@ export function VideoPlayer({
|
||||
);
|
||||
|
||||
const nextEpisode = useNextEpisode(
|
||||
roomPlaybackActive ? null : credits,
|
||||
roomPlaybackActive ? null : autoPlayNextPreview && preview ? preview : credits,
|
||||
roomPlaybackActive ? undefined : seriesContext,
|
||||
currentTime,
|
||||
handleNavigate,
|
||||
@@ -886,11 +895,16 @@ export function VideoPlayer({
|
||||
|
||||
// -- Intro skip --
|
||||
const showIntroSkip = intro != null && currentTime >= intro.start && currentTime < intro.end;
|
||||
const showRecapSkip = recap != null && currentTime >= recap.start && currentTime < recap.end;
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
if (intro) handlePlayerSeek(intro.end);
|
||||
}, [intro, handlePlayerSeek]);
|
||||
|
||||
const skipRecap = useCallback(() => {
|
||||
if (recap) handlePlayerSeek(recap.end);
|
||||
}, [recap, handlePlayerSeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSkipIntro || !intro || !isPlayerReady || awaitingFirstFrame) {
|
||||
return;
|
||||
@@ -926,6 +940,41 @@ export function VideoPlayer({
|
||||
watchTogetherSync.attachedSessionId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSkipRecap || !recap || !isPlayerReady || awaitingFirstFrame) {
|
||||
return;
|
||||
}
|
||||
if (currentTime < recap.start || currentTime >= recap.end) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
roomPlaybackActive &&
|
||||
(!watchTogether.room?.self_can_manage_room ||
|
||||
watchTogetherSync.attachedSessionId !== sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recapKey = `${sessionId}:${activeFileId ?? "unknown"}:${recap.start}:${recap.end}`;
|
||||
if (autoSkippedRecapKeyRef.current === recapKey) {
|
||||
return;
|
||||
}
|
||||
autoSkippedRecapKeyRef.current = recapKey;
|
||||
handlePlayerSeek(recap.end);
|
||||
}, [
|
||||
activeFileId,
|
||||
autoSkipRecap,
|
||||
awaitingFirstFrame,
|
||||
currentTime,
|
||||
handlePlayerSeek,
|
||||
isPlayerReady,
|
||||
recap,
|
||||
roomPlaybackActive,
|
||||
sessionId,
|
||||
watchTogether.room?.self_can_manage_room,
|
||||
watchTogetherSync.attachedSessionId,
|
||||
]);
|
||||
|
||||
// Stabilize the dependency – only the bitrate matters for buffer sizing.
|
||||
const selectedVersionBitrate = transcodeQuality.effectiveVersion?.bitrate ?? 0;
|
||||
|
||||
@@ -1972,6 +2021,7 @@ export function VideoPlayer({
|
||||
|
||||
{/* Intro skip button */}
|
||||
{!isDetached && showIntroSkip && <IntroSkipButton onSkip={skipIntro} />}
|
||||
{!isDetached && showRecapSkip && <IntroSkipButton onSkip={skipRecap} label="Skip Recap" />}
|
||||
|
||||
{/* Next episode overlay */}
|
||||
{!isDetached && nextEpisode.showCountdown && nextEpisode.nextEpisode && (
|
||||
|
||||
@@ -77,6 +77,8 @@ export function WatchPage({
|
||||
showForcedSubtitles,
|
||||
profileLanguage,
|
||||
autoSkipIntro,
|
||||
autoSkipRecap,
|
||||
autoPlayNextPreview,
|
||||
seriesContext,
|
||||
onNavigateEpisode,
|
||||
onEnded,
|
||||
@@ -411,6 +413,10 @@ export function WatchPage({
|
||||
intro={activeMarkers.intro}
|
||||
autoSkipIntro={autoSkipIntro}
|
||||
credits={activeMarkers.credits}
|
||||
recap={activeMarkers.recap}
|
||||
autoSkipRecap={autoSkipRecap}
|
||||
preview={activeMarkers.preview}
|
||||
autoPlayNextPreview={autoPlayNextPreview}
|
||||
duration={selectedDuration}
|
||||
qualityPreference={qualityPreference}
|
||||
seriesContext={seriesContext}
|
||||
|
||||
@@ -10,11 +10,11 @@ interface NextEpisodeState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects when the current playback position enters the credits region,
|
||||
* Detects when the current playback position enters the configured trigger region,
|
||||
* starts a 10-second countdown, and provides the next episode reference.
|
||||
*/
|
||||
export function useNextEpisode(
|
||||
credits: PlayerTimeRange | null,
|
||||
triggerRegion: PlayerTimeRange | null,
|
||||
seriesContext: SeriesContext | undefined,
|
||||
currentTime: number,
|
||||
onNavigate: (contentId: string) => void,
|
||||
@@ -41,11 +41,11 @@ export function useNextEpisode(
|
||||
}
|
||||
}, [currentEpisodeKey]);
|
||||
|
||||
// Detect entry into credits region.
|
||||
// Detect entry into the configured trigger region.
|
||||
useEffect(() => {
|
||||
if (!credits || !nextEpisode || cancelledRef.current) return;
|
||||
if (!triggerRegion || !nextEpisode || cancelledRef.current) return;
|
||||
|
||||
if (currentTime >= credits.start && !showCountdown) {
|
||||
if (currentTime >= triggerRegion.start && !showCountdown) {
|
||||
setShowCountdown(true);
|
||||
setSecondsRemaining(10);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useNextEpisode(
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
}, [currentTime, credits, nextEpisode, showCountdown, onNavigate]);
|
||||
}, [currentTime, triggerRegion, nextEpisode, showCountdown, onNavigate]);
|
||||
|
||||
// Clean up interval on unmount.
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user