feat: stay in fullscreen when transitioning to next episode
This commit is contained in:
@@ -232,11 +232,6 @@ func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) HandleSaveProgress(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())
|
user := middleware.GetUser(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
@@ -333,6 +328,106 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *Handler) ensureAnimeSeed(ctx context.Context, malID int) (*database.UpsertAnimeParams, error) {
|
||||||
animeID := int64(malID)
|
animeID := int64(malID)
|
||||||
if _, err := h.svc.db.GetAnime(ctx, animeID); err == nil {
|
if _, err := h.svc.db.GetAnime(ctx, animeID); err == nil {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxy)
|
mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxy)
|
||||||
mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress)
|
mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress)
|
||||||
mux.HandleFunc("/api/watch-complete", playbackHandler.HandleCompleteAnime)
|
mux.HandleFunc("/api/watch-complete", playbackHandler.HandleCompleteAnime)
|
||||||
|
mux.HandleFunc("/api/watch/episode/", playbackHandler.HandleEpisodeData)
|
||||||
|
|
||||||
// Auth Endpoints
|
// Auth Endpoints
|
||||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ interface SkipSegment {
|
|||||||
end: number
|
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<string, ModeSource>
|
||||||
|
segments: SkipSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
let playerInitialized = false
|
let playerInitialized = false
|
||||||
|
|
||||||
const initPlayer = (): void => {
|
const initPlayer = (): void => {
|
||||||
@@ -57,9 +69,9 @@ const initPlayer = (): void => {
|
|||||||
|
|
||||||
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
||||||
const initialStreamToken = container.getAttribute('data-stream-token') || ''
|
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 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 animeTitle = container.getAttribute('data-anime-title') || ''
|
||||||
const animeTitleEnglish = container.getAttribute('data-anime-title-english') || ''
|
const animeTitleEnglish = container.getAttribute('data-anime-title-english') || ''
|
||||||
const animeTitleJapanese = container.getAttribute('data-anime-title-japanese') || ''
|
const animeTitleJapanese = container.getAttribute('data-anime-title-japanese') || ''
|
||||||
@@ -84,7 +96,7 @@ const initPlayer = (): void => {
|
|||||||
const minSegmentDurationSeconds = 20
|
const minSegmentDurationSeconds = 20
|
||||||
const maxSegmentDurationSeconds = 240
|
const maxSegmentDurationSeconds = 240
|
||||||
|
|
||||||
const parsedSegments = segments
|
let parsedSegments = segments
|
||||||
.map((segment: SkipSegment) => {
|
.map((segment: SkipSegment) => {
|
||||||
const start = Number(segment.start || 0)
|
const start = Number(segment.start || 0)
|
||||||
const end = Number(segment.end || 0)
|
const end = Number(segment.end || 0)
|
||||||
@@ -765,12 +777,89 @@ const goToNextEpisode = (): void => {
|
|||||||
|
|
||||||
const nextEpisode = currentEpisodeNumber + 1
|
const nextEpisode = currentEpisodeNumber + 1
|
||||||
markEpisodeTransition(nextEpisode)
|
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')
|
sessionStorage.setItem('mal:autoplay-next', 'true')
|
||||||
window.location.href = nextUrl
|
window.location.href = nextUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Promise<void> => {
|
||||||
|
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<void> => {
|
const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||||
if (completionSent) return
|
if (completionSent) return
|
||||||
if (!Number.isInteger(malID) || malID <= 0) return
|
if (!Number.isInteger(malID) || malID <= 0) return
|
||||||
|
|||||||
Reference in New Issue
Block a user