渐进式生成播放:fMP4 前 4 个分片就绪即返回播放列表开始起播,后台边转边补 ENDLIST,消除整集封装等待的黑屏缓冲

This commit is contained in:
yvan
2026-09-18 16:06:31 +08:00
parent 3f8c29b7e2
commit f3546ab2db
7 changed files with 170 additions and 57 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ func (app *UIApp) prepareEmbyPlayback(parent context.Context, session *playbackS
} else if session.embyProcessing == "audio" { } else if session.embyProcessing == "audio" {
mode = "audio" mode = "audio"
} }
err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, mode, sourceFromDramaID(task.DramaID), origin, base, query) err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, mode, sourceFromDramaID(task.DramaID), origin, base, query, 0)
if err == nil { if err == nil {
duration = media.plan.Duration duration = media.plan.Duration
if media.plan.Player == "legacy" { if media.plan.Player == "legacy" {
+1 -1
View File
@@ -273,7 +273,7 @@ func TestEmbyEvictedRemuxRestoresExistingSegmentURLs(t *testing.T) {
app.playbackMu.Unlock() app.playbackMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
if err := app.generatePlayback(ctx, media, "remux", base, parsed.RawQuery); err != nil { if err := app.generatePlayback(ctx, media, "remux", base, parsed.RawQuery, 0); err != nil {
t.Fatal(err) t.Fatal(err)
} }
segment := media.generated.assetURL(media.generated.segments[0].Name) segment := media.generated.assetURL(media.generated.segments[0].Name)
+1 -1
View File
@@ -161,7 +161,7 @@ func TestPlaybackPlanConvertsOnlyUnsupportedAudio(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer other.Close() defer other.Close()
if err := app.planMedia(context.Background(), other, playbackClientCapabilities{MP4: true, NativeHLS: true, Video: []string{"hevc"}, Audio: []string{"aac"}}, "auto", sourceHongguo, "", "/media/", ""); err != nil || other.plan.Player != "legacy" || other.plan.Reason != "video_codec_unsupported" { if err := app.planMedia(context.Background(), other, playbackClientCapabilities{MP4: true, NativeHLS: true, Video: []string{"hevc"}, Audio: []string{"aac"}}, "auto", sourceHongguo, "", "/media/", "", 0); err != nil || other.plan.Player != "legacy" || other.plan.Reason != "video_codec_unsupported" {
t.Fatal("unsupported video did not choose the final compatibility fallback", other.plan, err) t.Fatal("unsupported video did not choose the final compatibility fallback", other.plan, err)
} }
} }
+154 -48
View File
@@ -11,9 +11,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
const playbackProgressiveSegments = 4
type playbackSegmentIndex struct { type playbackSegmentIndex struct {
Name string Name string
Start float64 Start float64
@@ -28,14 +31,22 @@ type playbackGenerated struct {
query string query string
playlist string playlist string
segments []playbackSegmentIndex segments []playbackSegmentIndex
lastEnd float64
files map[string]bool files map[string]bool
bytes int64 bytes int64
resources *playbackResources resources *playbackResources
done chan struct{} done chan struct{}
once sync.Once doneOnce sync.Once
closeOnce sync.Once
complete atomic.Bool
failed atomic.Bool
mu sync.RWMutex mu sync.RWMutex
} }
func (generated *playbackGenerated) finish() {
generated.doneOnce.Do(func() { close(generated.done) })
}
func playbackGeneratedArgs(media providerMedia, input, directory, processing string) []string { func playbackGeneratedArgs(media providerMedia, input, directory, processing string) []string {
args := playbackInputArgs(media, input, 0) args := playbackInputArgs(media, input, 0)
if processing == "audio" { if processing == "audio" {
@@ -44,11 +55,11 @@ func playbackGeneratedArgs(media providerMedia, input, directory, processing str
args = append(args, "-c", "copy") args = append(args, "-c", "copy")
} }
return append(args, "-avoid_negative_ts", "make_zero", "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", return append(args, "-avoid_negative_ts", "make_zero", "-f", "hls", "-hls_time", "2", "-hls_list_size", "0",
"-hls_playlist_type", "vod", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", "-hls_playlist_type", "event", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4",
"-hls_flags", "temp_file", "-hls_segment_filename", filepath.ToSlash(filepath.Join(directory, "%06d.m4s")), filepath.ToSlash(filepath.Join(directory, "index.m3u8"))) "-hls_flags", "temp_file", "-hls_segment_filename", filepath.ToSlash(filepath.Join(directory, "%06d.m4s")), filepath.ToSlash(filepath.Join(directory, "index.m3u8")))
} }
func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string) error { func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string, startSeconds float64) error {
ffmpeg, err := app.downloader.ensureFFmpeg(ctx) ffmpeg, err := app.downloader.ensureFFmpeg(ctx)
if err != nil { if err != nil {
return err return err
@@ -57,26 +68,25 @@ func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSess
if err != nil { if err != nil {
return err return err
} }
defer release()
directory, err := os.MkdirTemp("", "juku-playback-media-") directory, err := os.MkdirTemp("", "juku-playback-media-")
if err != nil { if err != nil {
release()
return err return err
} }
jobCtx, cancel := context.WithCancel(media.ctx) jobCtx, cancel := context.WithCancel(media.ctx)
stop := context.AfterFunc(ctx, cancel)
defer stop()
generated := &playbackGenerated{ctx: jobCtx, cancel: cancel, directory: directory, base: base, query: query, generated := &playbackGenerated{ctx: jobCtx, cancel: cancel, directory: directory, base: base, query: query,
resources: app.mediaResources(), done: make(chan struct{}), files: make(map[string]bool)} resources: app.mediaResources(), done: make(chan struct{}), files: make(map[string]bool)}
media.generated = generated media.generated = generated
defer close(generated.done)
input := media.local input := media.local
var proxy *hlsProxy var proxy *hlsProxy
if input == "" { if input == "" {
proxy, err = app.downloader.newHLSProxy(jobCtx, media.media, media.key) proxy, err = app.downloader.newHLSProxy(jobCtx, media.media, media.key)
if err != nil { if err != nil {
generated.finish()
cancel()
release()
return err return err
} }
defer proxy.Close()
proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) } proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) }
input = proxy.root input = proxy.root
} }
@@ -84,47 +94,110 @@ func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSess
log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})} log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})}
command.Stderr = log command.Stderr = log
if err := command.Start(); err != nil { if err := command.Start(); err != nil {
generated.finish()
cancel()
release()
return fmt.Errorf("无法启动媒体封装:%w", err) return fmt.Errorf("无法启动媒体封装:%w", err)
} }
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- command.Wait() }() go func() { done <- command.Wait() }()
ticker := time.NewTicker(50 * time.Millisecond) go func() { // 后台:持续刷新渐进式播放列表,等 ffmpeg 收尾后补 ENDLIST 并释放转封装资源
defer generated.finish()
defer release()
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case waitErr := <-done:
if waitErr != nil {
if jobCtx.Err() == nil {
generated.failed.Store(true)
app.downloader.recordDiagnostic(diagnosticEvent{Level: "warn", Event: "playback.generated.failed",
Message: "媒体封装中断:" + fmt.Sprint(waitErr)})
}
return
}
_ = generated.accountCache()
if data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")); err == nil {
_ = generated.index(string(data))
}
return
case <-jobCtx.Done():
<-done
return
case <-ticker.C:
if err := generated.accountCache(); err != nil {
generated.failed.Store(true)
cancel()
<-done
return
}
if data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")); err == nil {
_ = generated.index(string(data))
}
}
}
}()
// 前台:前几个分片就绪(并覆盖起播位置)即返回计划,剩余部分边播边转
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case err := <-done: case <-generated.done:
if err != nil { if ctx.Err() != nil {
return playbackStreamError(jobCtx, proxy, log, media.media, err, nil) return ctx.Err()
} }
if err = generated.accountCache(); err != nil { generated.mu.RLock()
return err complete, failed := generated.complete.Load(), generated.failed.Load()
segmentCount := len(generated.segments)
lastEnd := generated.lastEnd
generated.mu.RUnlock()
if failed || !complete || segmentCount == 0 {
return errors.New("媒体封装未能完成")
} }
body, err := os.ReadFile(filepath.Join(directory, "index.m3u8")) generated.applyPlan(media, processing, lastEnd)
if err != nil {
return err
}
if err := generated.index(string(body)); err != nil {
return err
}
media.plan.URL = generated.assetURL("index.m3u8")
media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing
media.plan.Duration = generated.segments[len(generated.segments)-1].Start + generated.segments[len(generated.segments)-1].Duration
media.plan.SeekEnd = media.plan.Duration
return nil return nil
case <-ctx.Done():
cancel()
<-generated.done
return ctx.Err()
case <-ticker.C: case <-ticker.C:
if err := generated.accountCache(); err != nil { if err := generated.accountCache(); err != nil {
cancel() cancel()
<-done <-generated.done
return err return err
} }
case <-ctx.Done(): data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8"))
cancel() if err != nil {
<-done continue
return ctx.Err() }
body := string(data)
if strings.Count(body, "#EXTINF") < playbackProgressiveSegments {
continue
}
if err := generated.index(body); err != nil {
continue
}
generated.mu.RLock()
lastEnd := generated.lastEnd
generated.mu.RUnlock()
if lastEnd < startSeconds+6 {
continue
}
generated.applyPlan(media, processing, lastEnd)
return nil
} }
} }
} }
func (generated *playbackGenerated) applyPlan(media *playbackMediaSession, processing string, lastEnd float64) {
media.plan.URL = generated.assetURL("index.m3u8")
media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing
if media.plan.Duration <= 0 {
media.plan.Duration, media.plan.SeekEnd = lastEnd, lastEnd
}
}
func (generated *playbackGenerated) accountCache() error { func (generated *playbackGenerated) accountCache() error {
entries, err := os.ReadDir(generated.directory) entries, err := os.ReadDir(generated.directory)
if err != nil { if err != nil {
@@ -164,50 +237,63 @@ func (generated *playbackGenerated) assetURL(name string) string {
} }
func (generated *playbackGenerated) index(playlist string) error { func (generated *playbackGenerated) index(playlist string) error {
if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") || !strings.Contains(playlist, "#EXT-X-ENDLIST") { complete := strings.Contains(playlist, "#EXT-X-ENDLIST")
return errors.New("媒体封装尚未完成,不能生成完整播放索引") if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") {
return errors.New("媒体封装尚未就绪")
} }
lines := strings.Split(playlist, "\n") lines := strings.Split(playlist, "\n")
var start, duration, longest float64 segments := make([]playbackSegmentIndex, 0, 64)
files := make(map[string]bool, 64)
var start, duration float64
validName := func(name string) bool { validName := func(name string) bool {
return name != "" && filepath.Base(name) == name && !strings.ContainsAny(name, "\\?\x00") && (strings.HasSuffix(name, ".mp4") || strings.HasSuffix(name, ".m4s")) return name != "" && filepath.Base(name) == name && !strings.ContainsAny(name, "\\?\x00") && (strings.HasSuffix(name, ".mp4") || strings.HasSuffix(name, ".m4s"))
} }
for i, line := range lines { for i, line := range lines {
if strings.HasPrefix(line, "#EXTINF:") { if strings.HasPrefix(line, "#EXTINF:") {
value, _, _ := strings.Cut(strings.TrimPrefix(line, "#EXTINF:"), ",") value, _, _ := strings.Cut(strings.TrimPrefix(line, "#EXTINF:"), ",")
var err error parsed, err := strconv.ParseFloat(value, 64)
duration, err = strconv.ParseFloat(value, 64) if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 || parsed > 3600 {
if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 || duration > 3600 {
return errors.New("媒体分片时长无效") return errors.New("媒体分片时长无效")
} }
longest = math.Max(longest, duration) duration = parsed
} else if strings.HasPrefix(line, "#EXT-X-MAP:") { } else if strings.HasPrefix(line, "#EXT-X-MAP:") {
matches := hlsURIAttribute.FindStringSubmatch(line) matches := hlsURIAttribute.FindStringSubmatch(line)
if len(matches) != 2 || !validName(matches[1]) { if len(matches) != 2 || !validName(matches[1]) {
return errors.New("媒体初始化片段无效") return errors.New("媒体初始化片段无效")
} }
generated.files[matches[1]] = true files[matches[1]] = true
lines[i] = strings.Replace(line, matches[0], `URI="`+generated.assetURL(matches[1])+`"`, 1) lines[i] = strings.Replace(line, matches[0], `URI="`+generated.assetURL(matches[1])+`"`, 1)
} else if line != "" && !strings.HasPrefix(line, "#") { } else if line != "" && !strings.HasPrefix(line, "#") {
if duration <= 0 || !validName(line) || len(generated.segments) >= 16384 || start+duration > 24*60*60 { if duration <= 0 || !validName(line) || len(segments) >= 16384 || start+duration > 24*60*60 {
return errors.New("媒体分片索引无效") return errors.New("媒体分片索引无效")
} }
generated.files[line] = true files[line] = true
generated.segments = append(generated.segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration}) segments = append(segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration})
start += duration start += duration
duration = 0 duration = 0
lines[i] = generated.assetURL(line) lines[i] = generated.assetURL(line)
} }
} }
if len(generated.segments) == 0 || duration != 0 { if !complete && duration > 0 {
duration = 0
}
if len(segments) == 0 || (complete && duration != 0) {
return errors.New("媒体播放索引没有完整分片") return errors.New("媒体播放索引没有完整分片")
} }
if !complete && len(segments) < len(generated.segments) {
return nil
}
for i, line := range lines { for i, line := range lines {
if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") { if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") {
var longest float64
for _, segment := range segments {
longest = math.Max(longest, segment.Duration)
}
lines[i] = "#EXT-X-TARGETDURATION:" + strconv.Itoa(int(math.Ceil(longest))) lines[i] = "#EXT-X-TARGETDURATION:" + strconv.Itoa(int(math.Ceil(longest)))
break
} }
} }
for name := range generated.files { for name := range files {
info, err := os.Stat(filepath.Join(generated.directory, name)) info, err := os.Stat(filepath.Join(generated.directory, name))
if err != nil { if err != nil {
return fmt.Errorf("媒体分片未完整生成(%s):%w", name, err) return fmt.Errorf("媒体分片未完整生成(%s):%w", name, err)
@@ -219,25 +305,41 @@ func (generated *playbackGenerated) index(playlist string) error {
return fmt.Errorf("媒体分片未完整生成(%s 为空文件)", name) return fmt.Errorf("媒体分片未完整生成(%s 为空文件)", name)
} }
} }
generated.mu.Lock()
generated.segments = segments
generated.files = files
generated.lastEnd = start
generated.playlist = strings.Join(lines, "\n") generated.playlist = strings.Join(lines, "\n")
generated.mu.Unlock()
generated.complete.Store(complete)
return nil return nil
} }
func (generated *playbackGenerated) Close() { func (generated *playbackGenerated) Close() {
generated.once.Do(func() { generated.closeOnce.Do(func() {
generated.cancel() generated.cancel()
go func() { go func() {
<-generated.done <-generated.done
generated.mu.Lock() removeMediaDirectory(generated.directory)
defer generated.mu.Unlock()
_ = os.RemoveAll(generated.directory)
generated.resources.mu.Lock() generated.resources.mu.Lock()
defer generated.resources.mu.Unlock()
generated.resources.cacheBytes -= generated.bytes generated.resources.cacheBytes -= generated.bytes
generated.resources.mu.Unlock()
}() }()
}) })
} }
func removeMediaDirectory(directory string) {
for attempt := 0; attempt < 30; attempt++ {
err := os.RemoveAll(directory)
if err == nil {
if _, statErr := os.Stat(directory); os.IsNotExist(statErr) {
return
}
}
time.Sleep(200 * time.Millisecond)
}
}
func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, request *http.Request) { func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
generated.mu.RLock() generated.mu.RLock()
defer generated.mu.RUnlock() defer generated.mu.RUnlock()
@@ -247,6 +349,10 @@ func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, reques
} }
name := strings.TrimPrefix(request.URL.Path, generated.base) name := strings.TrimPrefix(request.URL.Path, generated.base)
if name == "index.m3u8" { if name == "index.m3u8" {
if generated.failed.Load() && !generated.complete.Load() {
writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "媒体封装中断,请重新播放"})
return
}
writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
http.ServeContent(writer, request, name, time.Time{}, strings.NewReader(generated.playlist)) http.ServeContent(writer, request, name, time.Time{}, strings.NewReader(generated.playlist))
return return
+8 -1
View File
@@ -215,7 +215,7 @@ func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) {
} }
ctx, stop := context.WithTimeout(parent, 30*time.Second) ctx, stop := context.WithTimeout(parent, 30*time.Second)
defer stop() defer stop()
if err := app.generatePlayback(ctx, media, processing, "/media/", ""); err != nil { if err := app.generatePlayback(ctx, media, processing, "/media/", "", 0); err != nil {
media.Close() media.Close()
t.Fatal(err) t.Fatal(err)
} }
@@ -224,6 +224,13 @@ func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) {
if media.generated.ctx.Err() != nil { if media.generated.ctx.Err() != nil {
t.Fatal("completed media inherited its preparation timeout") t.Fatal("completed media inherited its preparation timeout")
} }
completeDeadline := time.Now().Add(20 * time.Second)
for !media.generated.complete.Load() {
if time.Now().After(completeDeadline) {
t.Fatal("background remux did not complete")
}
time.Sleep(20 * time.Millisecond)
}
parts := media.generated.segments parts := media.generated.segments
if len(parts) != 4 || parts[0].Duration < 2.99 || parts[0].Duration > 3.01 || parts[1].Start != parts[0].Duration || !strings.Contains(media.generated.playlist, "#EXT-X-TARGETDURATION:3") { if len(parts) != 4 || parts[0].Duration < 2.99 || parts[0].Duration > 3.01 || parts[1].Start != parts[0].Duration || !strings.Contains(media.generated.playlist, "#EXT-X-TARGETDURATION:3") {
t.Fatalf("invented fixed two-second timeline: %+v", parts) t.Fatalf("invented fixed two-second timeline: %+v", parts)
+4 -4
View File
@@ -84,13 +84,13 @@ func (app *UIApp) probePlaybackCodecs(ctx context.Context, media *playbackMediaS
if proxy != nil && proxy.Err() != nil || media.info.Video == "" && err != nil { if proxy != nil && proxy.Err() != nil || media.info.Video == "" && err != nil {
return playbackStreamError(ctx, proxy, log, media.media, err, nil) return playbackStreamError(ctx, proxy, log, media.media, err, nil)
} }
if media.plan.Duration <= 0 && duration > 0 { if duration > 0 && duration != media.plan.Duration {
media.plan.Duration, media.plan.SeekEnd = duration, duration media.plan.Duration, media.plan.SeekEnd = duration, duration
} }
return nil return nil
} }
func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, client playbackClientCapabilities, mode, source, origin, base, query string) error { func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, client playbackClientCapabilities, mode, source, origin, base, query string, startSeconds float64) error {
app.attachMediaGateway(media, base, query) app.attachMediaGateway(media, base, query)
app.inspectPlaybackMedia(ctx, media) app.inspectPlaybackMedia(ctx, media)
if mode == "legacy" { if mode == "legacy" {
@@ -124,7 +124,7 @@ func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, cl
media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "hls_player_unavailable" media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "hls_player_unavailable"
return nil return nil
} }
if err := app.generatePlayback(ctx, media, processing, base, query); err != nil { if err := app.generatePlayback(ctx, media, processing, base, query, startSeconds); err != nil {
return err return err
} }
} else if media.plan.Player == "hls" && !client.NativeHLS && !client.HlsJS || media.plan.Player == "mp4" && !client.MP4 { } else if media.plan.Player == "hls" && !client.NativeHLS && !client.HlsJS || media.plan.Player == "mp4" && !client.MP4 {
@@ -198,7 +198,7 @@ func (app *UIApp) handlePlaybackPlan(writer http.ResponseWriter, request *http.R
media, err = app.resolveMediaSession(run.ctx, ctx, run.task, run.downloadID, input.Quality, run.cache) media, err = app.resolveMediaSession(run.ctx, ctx, run.task, run.downloadID, input.Quality, run.cache)
if err == nil { if err == nil {
base := "/api/ui/playback/media/" + run.id + "/" + strconv.FormatUint(run.run, 10) + "/" base := "/api/ui/playback/media/" + run.id + "/" + strconv.FormatUint(run.run, 10) + "/"
err = app.planMedia(ctx, media, input.Client, input.Mode, sourceFromDramaID(run.task.DramaID), request.Header.Get("Origin"), base, "") err = app.planMedia(ctx, media, input.Client, input.Mode, sourceFromDramaID(run.task.DramaID), request.Header.Get("Origin"), base, "", input.Start)
} }
if err != nil { if err != nil {
app.finishPlaybackRun(run, err, request.Context().Err() != nil) app.finishPlaybackRun(run, err, request.Context().Err() != nil)
+1 -1
View File
@@ -653,7 +653,7 @@
statusText.textContent = '正在缓冲…'; statusText.textContent = '正在缓冲…';
await mediaLoader.load(result, supportsNativePlayback(), signal, offset); await mediaLoader.load(result, supportsNativePlayback(), signal, offset);
if (signal.aborted || version !== streamVersion) throw abortError(); if (signal.aborted || version !== streamVersion) throw abortError();
if (Number.isFinite(video.duration) && video.duration > 0) playbackDuration = video.duration; if (Number.isFinite(video.duration) && video.duration > playbackDuration) playbackDuration = video.duration;
if (offset > 0) video.currentTime = playbackDuration > 0 ? Math.min(offset, Math.max(0, playbackDuration - 0.05)) : offset; if (offset > 0) video.currentTime = playbackDuration > 0 ? Math.min(offset, Math.max(0, playbackDuration - 0.05)) : offset;
video.playbackRate = Number(node('playbackRate').value) || 1; video.playbackRate = Number(node('playbackRate').value) || 1;
loading = false; loading = false;