diff --git a/api/playback/handler.go b/api/playback/handler.go index 670efb8..9a75bb5 100644 --- a/api/playback/handler.go +++ b/api/playback/handler.go @@ -232,11 +232,6 @@ func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) { } func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - user := middleware.GetUser(r.Context()) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) @@ -333,6 +328,106 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// HandleEpisodeData returns JSON for episode data (for in-player transitions) +func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/api/watch/episode/") + path = strings.Trim(path, "/") + if path == "" { + http.Error(w, "Missing anime ID", http.StatusBadRequest) + return + } + + parts := strings.Split(path, "/") + malID, err := strconv.Atoi(parts[0]) + if err != nil || malID <= 0 { + http.Error(w, "Invalid anime ID", http.StatusBadRequest) + return + } + + episode := "1" + if len(parts) >= 2 { + episode = strings.TrimSpace(parts[1]) + } + if episode == "" { + episode = r.URL.Query().Get("ep") + } + if episode == "" { + episode = "1" + } + + mode := strings.TrimSpace(r.URL.Query().Get("mode")) + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + anime, err := h.jikanClient.GetAnimeByID(ctx, malID) + if err != nil { + log.Printf("failed to fetch anime %d: %v", malID, err) + http.Error(w, "Failed to fetch anime details", http.StatusInternalServerError) + return + } + + titleCandidates := playbackTitleCandidates(anime) + userID := watchlistUserIDFromRequest(r) + data, err := h.svc.BuildWatchPageData(ctx, malID, titleCandidates, episode, mode, userID) + if err != nil { + log.Printf("episode data error for mal_id=%d ep=%s: %v", malID, episode, err) + http.Error(w, "Failed to load episode data", http.StatusBadGateway) + return + } + + clientModeSources := convertModeSources(data.ModeSources) + initialMode := data.InitialMode + token := "" + if source, ok := clientModeSources[initialMode]; ok { + token = source.Token + } + +response := struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + CurrentEpisode string `json:"current_episode"` + TotalEpisodes int `json:"total_episodes"` + InitialMode string `json:"initial_mode"` + Token string `json:"token"` + AvailableModes []string `json:"available_modes"` + ModeSources map[string]shared.ModeSource `json:"mode_sources"` + Segments []shared.SkipSegment `json:"segments"` + }{ + MalID: malID, + Title: data.Title, + CurrentEpisode: data.CurrentEpisode, + TotalEpisodes: anime.Episodes, + InitialMode: initialMode, + Token: token, + AvailableModes: data.AvailableModes, + ModeSources: clientModeSources, + Segments: convertToSharedSegments(data.Segments), + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("failed to encode episode data: %v", err) + } +} + +func convertToSharedSegments(segments []SkipSegment) []shared.SkipSegment { + result := make([]shared.SkipSegment, len(segments)) + for i, s := range segments { + result[i] = shared.SkipSegment{ + Type: s.Type, + Start: s.Start, + End: s.End, + } + } + return result +} + func (h *Handler) ensureAnimeSeed(ctx context.Context, malID int) (*database.UpsertAnimeParams, error) { animeID := int64(malID) if _, err := h.svc.db.GetAnime(ctx, animeID); err == nil { diff --git a/internal/server/routes.go b/internal/server/routes.go index 5f00829..9c10dca 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -81,6 +81,7 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxy) mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress) mux.HandleFunc("/api/watch-complete", playbackHandler.HandleCompleteAnime) + mux.HandleFunc("/api/watch/episode/", playbackHandler.HandleEpisodeData) // Auth Endpoints mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { diff --git a/static/player.ts b/static/player.ts index 778e3dc..480e7af 100644 --- a/static/player.ts +++ b/static/player.ts @@ -20,6 +20,18 @@ interface SkipSegment { end: number } +interface EpisodeData { + mal_id: number + title: string + current_episode: string + total_episodes: number + initial_mode: string + token: string + available_modes: string[] + mode_sources: Record + segments: SkipSegment[] +} + let playerInitialized = false const initPlayer = (): void => { @@ -57,9 +69,9 @@ const initPlayer = (): void => { const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream' const initialStreamToken = container.getAttribute('data-stream-token') || '' - const currentEpisode = container.getAttribute('data-current-episode') || '1' + let currentEpisode = container.getAttribute('data-current-episode') || '1' const malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10) - const totalEpisodes = Number.parseInt(container.getAttribute('data-total-episodes') || '0', 10) + let totalEpisodes = Number.parseInt(container.getAttribute('data-total-episodes') || '0', 10) const animeTitle = container.getAttribute('data-anime-title') || '' const animeTitleEnglish = container.getAttribute('data-anime-title-english') || '' const animeTitleJapanese = container.getAttribute('data-anime-title-japanese') || '' @@ -84,7 +96,7 @@ const initPlayer = (): void => { const minSegmentDurationSeconds = 20 const maxSegmentDurationSeconds = 240 - const parsedSegments = segments + let parsedSegments = segments .map((segment: SkipSegment) => { const start = Number(segment.start || 0) const end = Number(segment.end || 0) @@ -765,12 +777,89 @@ const goToNextEpisode = (): void => { const nextEpisode = currentEpisodeNumber + 1 markEpisodeTransition(nextEpisode) - const nextUrl = `/watch/${animeID}/${nextEpisode}` + if (document.fullscreenElement) { + loadNextEpisodeInPlace(Number(animeID), nextEpisode) + return + } + + const nextUrl = `/watch/${animeID}/${nextEpisode}` sessionStorage.setItem('mal:autoplay-next', 'true') window.location.href = nextUrl } +const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Promise => { + if (!Number.isInteger(animeID) || animeID <= 0) return + + const url = `/api/watch/episode/${animeID}/${nextEpisode}` + let data: EpisodeData | null = null + + try { + const resp = await fetch(url) + if (!resp.ok) return + data = await resp.json() as EpisodeData + } catch { + return + } + + if (!data) return + + const container = document.querySelector('[data-video-player]') as HTMLElement | null + if (!container) return + + const video = container.querySelector('video') as HTMLVideoElement | null + if (!video) return + + container.setAttribute('data-current-episode', String(nextEpisode)) + container.setAttribute('data-mal-id', String(animeID)) + container.setAttribute('data-total-episodes', String(data.total_episodes)) + container.setAttribute('data-start-time-seconds', '0') + container.setAttribute('data-initial-mode', data.initial_mode) + container.setAttribute('data-stream-token', data.token) + container.setAttribute('data-available-modes', JSON.stringify(data.available_modes)) + container.setAttribute('data-mode-sources', JSON.stringify(data.mode_sources)) + container.setAttribute('data-segments', JSON.stringify(data.segments)) + + currentEpisode = String(nextEpisode) + totalEpisodes = data.total_episodes + + const newStreamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream' + const streamMode = data.initial_mode + const modeSource = data.mode_sources[streamMode] + + if (modeSource?.token) { + video.src = `${newStreamURL}?mode=${encodeURIComponent(streamMode)}&token=${encodeURIComponent(modeSource.token)}` + } else if (data.token) { + video.src = `${newStreamURL}?mode=${encodeURIComponent(streamMode)}&token=${encodeURIComponent(data.token)}` + } + + video.load() + video.play().catch(() => {}) + + parsedSegments = (data.segments || []) + .map((segment: SkipSegment) => { + const start = Number(segment.start || 0) + const end = Number(segment.end || 0) + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + return null + } + const rawType = String(segment.type || '').toLowerCase() + const type = rawType === 'ed' || rawType === 'outro' ? 'ed' : 'op' + return { type, start: Math.max(0, start), end: Math.max(0, end) } + }) + .filter((s: unknown): s is { type: string, start: number, end: number } => s !== null) + .sort((a: { start: number }, b: { start: number }) => a.start - b.start) + + activeSegments = [] + resolveActiveSegments() + renderSegments() + updateSubtitleOptions() + updateModeButtons(data.initial_mode) + + const nextUrl = `/watch/${animeID}/${nextEpisode}` + window.history.replaceState(null, '', nextUrl) +} + const completeAnime = async (episodeNumber: number): Promise => { if (completionSent) return if (!Number.isInteger(malID) || malID <= 0) return