From cd55a5221a46c2b4b4d4742e765ca7ace919dbba Mon Sep 17 00:00:00 2001 From: Sergio Fedorini Date: Thu, 24 Sep 2026 15:51:58 +0300 Subject: [PATCH 1/6] Serve one default audio track and drop the HLS debug override. YouTube masters with dozens of languages stalled both players before the first video frame. Signal loading while the first playlist is still fetching so the phone shows the connect gear. Co-authored-by: Cursor --- .../airplay/player/ffmpeg/FFmpegPlayer.java | 6 -- .../handler/control/ControlHandler.java | 12 ++-- .../handler/control/HlsFcupService.java | 66 ------------------- .../handler/control/PlaybackInfoOverride.java | 28 -------- .../handler/session/HlsUriRewrite.java | 31 +++++++-- .../internal/handler/video/VideoHandler.java | 9 ++- .../handler/session/HlsUriRewriteTest.java | 21 ++++++ 7 files changed, 58 insertions(+), 115 deletions(-) delete mode 100644 server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java index c6808c5..9799230 100644 --- a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java +++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java @@ -47,18 +47,12 @@ public synchronized void onVideoFormat(VideoStreamInfo videoStreamInfo) { FfplayPcmSink.applyDisplayEnv(pb); NativeProcessLog.configureProcessLogging(pb, "ffmpeg"); h264Process = pb.start(); - // SDL may abort after start if the display/audio driver is wrong. - Thread.sleep(150); if (!h264Process.isAlive()) { h264Process = null; throw new IllegalStateException("ffplay exited immediately after start"); } } catch (IOException e) { throw new IllegalStateException("Failed to start ffplay. Make sure it is available on PATH.", e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - stopVideoProcess(); - throw new IllegalStateException("Interrupted while starting ffplay", e); } } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java index 9277f6c..b0ce506 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java @@ -430,6 +430,8 @@ private void startMediaPlaylist(ChannelHandlerContext ctx, Session session, Stri hls.setPendingSeekSeconds(startPositionSeconds); } log.info("HLS play from [{}]: prefetching playlists via FCUP, localUri={}", clientProcName, playlistUriLocal); + // Gear on the phone while FCUP + first video playlist are still in flight. + hlsFcupService.sendPlaybackStateEvent(session, "loading"); hlsFcupService.sendFcupRequest(session, remotePlaylistUri); } else { airPlayConsumer.onPlaylist(playlistUriLocal); @@ -568,13 +570,7 @@ private void handlePlaybackInfo(ChannelHandlerContext ctx, FullHttpRequest reque double duration = fromPlayer.duration(); double position = fromPlayer.position(); double rate = fromPlayer.rate(); - var override = PlaybackInfoOverride.get(); - if (override != null) { - duration = override.duration(); - position = override.position(); - rate = override.rate(); - log.info("Playback-info override duration={} position={} rate={}", duration, position, rate); - } else if (hls != null && hls.isWaitingForMasterChange() && hls.isLivePlaylist()) { + if (hls != null && hls.isWaitingForMasterChange() && hls.isLivePlaylist()) { // Live post-EOS gap: duration=0 → buffer-empty / not ready (loading). duration = 0; position = 0; @@ -965,7 +961,7 @@ private record MasterRewrite(String localBody, List remoteMediaUris) { private MasterRewrite rewriteMasterPlaylist(String masterPlaylist, String baseUrl, String sessionId) { String filteredRemote = HlsUriRewrite.preferAvcVariants(masterPlaylist); if (!filteredRemote.equals(masterPlaylist)) { - log.info("Filtered HLS master to AVC-only variants (drop VP9/AV1/subtitles)"); + log.info("Filtered HLS master to AVC variants and the default audio track"); } List remoteMediaUris; try { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java index 9ca08b8..d8b602e 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java @@ -46,7 +46,6 @@ public class HlsFcupService { public HlsFcupService(SessionManager sessionManager, Playback airPlayConsumer) { this.sessionManager = sessionManager; this.airPlayConsumer = airPlayConsumer; - startSignalFileWatcher(); } public void refreshActivePlaylists() { @@ -75,76 +74,11 @@ public void refreshActivePlaylists() { log.info("HLS ended (VOD actionAtItemEnd={}), emit EOS reverse-event burst session={}", hls.getActionAtItemEnd(), session.getId()); hls.setPlaybackRate(1); - PlaybackInfoOverride.clear(); sendVodEosEventBurst(session); } } } - /** Poll {@code /tmp/airplay-hls-signal} for manual reverse/FCUP/playback-info experiments. */ - private void startSignalFileWatcher() { - scheduler.scheduleAtFixedRate(this::drainSignalFile, 500, 250, TimeUnit.MILLISECONDS); - } - - private void drainSignalFile() { - java.nio.file.Path path = java.nio.file.Path.of("/tmp/airplay-hls-signal"); - if (!java.nio.file.Files.isRegularFile(path)) { - return; - } - try { - String raw = java.nio.file.Files.readString(path).trim(); - java.nio.file.Files.deleteIfExists(path); - if (raw.isEmpty()) { - return; - } - for (String line : raw.split("\\R")) { - applySignal(line.trim()); - } - } catch (Exception e) { - log.warn("airplay-hls-signal: {}", e.toString()); - } - } - - private void applySignal(String line) { - if (line.isEmpty() || line.startsWith("#")) { - return; - } - log.warn("HLS debug signal: {}", line); - for (Session session : sessionManager.allSessions()) { - var hls = session.getHlsPlaylistState(); - if (hls == null || !hls.isPlaybackStarted()) { - continue; - } - switch (line) { - case "loading", "playing", "paused", "stopped" -> sendPlaybackStateEvent(session, line); - case "fcup-master" -> { - requestMasterRefresh(session); - scheduleMasterPoll(session); - } - case "rate0" -> hls.setPlaybackRate(0); - case "rate1" -> hls.setPlaybackRate(1); - case "clear-wait" -> { - hls.setWaitingForMasterChange(false); - PlaybackInfoOverride.clear(); - } - case "clear-override" -> PlaybackInfoOverride.clear(); - default -> { - if (line.startsWith("info:")) { - String[] p = line.substring(5).split(","); - if (p.length >= 3) { - PlaybackInfoOverride.set( - Double.parseDouble(p[0].trim()), - Double.parseDouble(p[1].trim()), - Double.parseDouble(p[2].trim())); - } - } else { - log.warn("Unknown HLS debug signal: {}", line); - } - } - } - } - } - public void onMasterRefreshDuringPlayback(Session session, String rewrittenBody, String rawBody, List remoteMediaUris) { var hls = session.getHlsPlaylistState(); diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java deleted file mode 100644 index 6ae7513..0000000 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.serezhka.airplay.server.internal.handler.control; - -/** - * Optional live override for {@code GET /playback-info} while debugging YouTube ad EOS hangs. - * Written by {@link HlsFcupService} signal file / auto-nudge; consumed by {@code ControlHandler}. - */ -public final class PlaybackInfoOverride { - - private static volatile Snapshot snapshot; - - private PlaybackInfoOverride() { - } - - public static void clear() { - snapshot = null; - } - - public static void set(double duration, double position, double rate) { - snapshot = new Snapshot(duration, position, rate, System.nanoTime()); - } - - public static Snapshot get() { - return snapshot; - } - - public record Snapshot(double duration, double position, double rate, long setAtNanos) { - } -} diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java index 9c9de05..858bd07 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java @@ -48,7 +48,11 @@ public static String rewritePlaylist(String playlist, String baseUrl, String ses /** * Keep H.264 ({@code avc1}) variants only. YouTube masters mix AVC + VP9/AV1; a single - * HLS consumer cannot switch video codecs mid-playlist. Also drops subtitle renditions. + * HLS consumer cannot switch video codecs mid-playlist. + *

+ * Drops subtitles and every audio rendition except one default per {@code GROUP-ID} + * referenced by a remaining variant ({@code DEFAULT=YES}, else the first). Extra + * languages make the consumer probe dozens of playlists before the first video frame. */ public static String preferAvcVariants(String masterPlaylist) { String[] lines = masterPlaylist.split("\\R", -1); @@ -93,11 +97,8 @@ public static String preferAvcVariants(String masterPlaylist) { } out.append(header).append('\n'); } - for (String media : audioMedia) { - String group = extractAttr(media, "GROUP-ID").orElse(""); - if (audioGroups.isEmpty() || audioGroups.contains(group)) { - out.append(media).append('\n'); - } + for (String media : defaultAudioOnly(audioMedia, audioGroups)) { + out.append(media).append('\n'); } for (String variantLine : variants) { out.append(variantLine).append('\n'); @@ -105,6 +106,24 @@ public static String preferAvcVariants(String masterPlaylist) { return out.toString(); } + /** One audio rendition per referenced group: {@code DEFAULT=YES}, otherwise the first. */ + private static List defaultAudioOnly(List audioMedia, Set audioGroups) { + java.util.Map chosen = new java.util.LinkedHashMap<>(); + java.util.Map chosenIsDefault = new java.util.LinkedHashMap<>(); + for (String media : audioMedia) { + String group = extractAttr(media, "GROUP-ID").orElse(""); + if (!audioGroups.isEmpty() && !audioGroups.contains(group)) { + continue; + } + boolean isDefault = extractAttr(media, "DEFAULT").orElse("").equalsIgnoreCase("YES"); + if (!chosen.containsKey(group) || (isDefault && !chosenIsDefault.getOrDefault(group, false))) { + chosen.put(group, media); + chosenIsDefault.put(group, isDefault); + } + } + return new ArrayList<>(chosen.values()); + } + private static boolean isAvcStreamInfo(String streamInf) { String codecs = extractAttr(streamInf, "CODECS").orElse("").toLowerCase(); if (codecs.isBlank()) { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java index a254693..c3aef72 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java @@ -37,7 +37,14 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) { dataConsumer.onVideo(spsPps); } } catch (Exception e) { - log.error(e.getMessage(), e); + // Mirror NALs still arrive for a moment after HLS tears the video process down. + // A stack trace per packet stalls the event loop and delays /play. + if (e instanceof IllegalStateException && e.getMessage() != null + && e.getMessage().contains("not running")) { + log.debug("Dropping video packet: {}", e.getMessage()); + } else { + log.error(e.getMessage(), e); + } } } diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java index 28ebce9..d6d4959 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java @@ -59,6 +59,27 @@ void preferAvcVariantsDropsVp9AndSubtitles() { assertFalse(filtered.contains("GROUP-ID=\"233\"")); } + @Test + void preferAvcVariantsKeepsOnlyDefaultAudioPerGroup() { + String master = """ + #EXTM3U + #EXT-X-MEDIA:URI="mlhls://localhost/itag/234/xtags/ar/mediadata.m3u8",TYPE=AUDIO,GROUP-ID="234",DEFAULT=NO,NAME="ar" + #EXT-X-MEDIA:URI="mlhls://localhost/itag/234/xtags/en/mediadata.m3u8",TYPE=AUDIO,GROUP-ID="234",DEFAULT=YES,NAME="en" + #EXT-X-MEDIA:URI="mlhls://localhost/itag/234/xtags/de/mediadata.m3u8",TYPE=AUDIO,GROUP-ID="234",DEFAULT=NO,NAME="de" + #EXT-X-MEDIA:URI="https://example/subs.m3u8",TYPE=SUBTITLES,GROUP-ID="vtt",NAME="en" + #EXT-X-STREAM-INF:BANDWIDTH=1000,CODECS="avc1.4D401E,mp4a.40.2",AUDIO="234" + mlhls://localhost/itag/232/mediadata.m3u8 + """; + + String filtered = HlsUriRewrite.preferAvcVariants(master); + + assertTrue(filtered.contains("xtags/en/")); + assertFalse(filtered.contains("xtags/ar/")); + assertFalse(filtered.contains("xtags/de/")); + assertFalse(filtered.contains("TYPE=SUBTITLES")); + assertEquals(1, filtered.split("#EXT-X-MEDIA:", -1).length - 1); + } + @Test void extractMediaUrisFromSampleMasterPlaylist() throws Exception { var listBase64 = "I0VYVE0zVQojRVhULVgtSU5ERVBFTkRFTlQtU0VHTUVOVFMKI0VYVC1YLU1FRElBOlVSST0ibWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMzMvbWVkaWFkYXRhLm0zdTgiLFRZUEU9QVVESU8sR1JPVVAtSUQ9IjIzMyIsREVGQVVMVD1ZRVMsQVVUT1NFTEVDVD1ZRVMsTkFNRT0iRGVmYXVsdCIKI0VYVC1YLU1FRElBOlVSST0ibWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMzQvbWVkaWFkYXRhLm0zdTgiLFRZUEU9QVVESU8sR1JPVVAtSUQ9IjIzNCIsREVGQVVMVD1ZRVMsQVVUT1NFTEVDVD1ZRVMsTkFNRT0iRGVmYXVsdCIKI0VYVC1YLVNUUkVBTS1JTkY6QkFORFdJRFRIPTEyMDk4NjIsQ09ERUNTPSJhdmMxLjRENDAxRSxtcDRhLjQwLjIiLFJFU09MVVRJT049NjQweDM2MCxBVURJTz0iMjM0IixGUkFNRS1SQVRFPTMwLFZJREVPLVJBTkdFPVNEUixDTE9TRUQtQ0FQVElPTlM9Tk9ORQptbGhsczovL2xvY2FsaG9zdC9pdGFnLzIzMC9tZWRpYWRhdGEubTN1OAojRVhULVgtU1RSRUFNLUlORjpCQU5EV0lEVEg9NTQ2MjM5LENPREVDUz0iYXZjMS40RDQwMTUsbXA0YS40MC41IixSRVNPTFVUSU9OPTQyNngyNDAsQVVESU89IjIzMyIsRlJBTUUtUkFURT0zMCxWSURFTy1SQU5HRT1TRFIsQ0xPU0VELUNBUFRJT05TPU5PTkUKbWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMjkvbWVkaWFkYXRhLm0zdTgKI0VYVC1YLVNUUkVBTS1JTkY6QkFORFdJRFRIPTYzMDIzOSxDT0RFQ1M9ImF2YzEuNEQ0MDE1LG1wNGEuNDAuMiIsUkVTT0xVVElPTj00MjZ4MjQwLEFVRElPPSIyMzQiLEZSQU1FLVJBVEU9MzAsVklERU8tUkFOR0U9U0RSLENMT1NFRC1DQVBUSU9OUz1OT05FCm1saGxzOi8vbG9jYWxob3N0L2l0YWcvMjI5L21lZGlhZGF0YS5tM3U4CiNFWFQtWC1TVFJFQU0tSU5GOkJBTkRXSURUSD0xNTY4NzI2LENPREVDUz0iYXZjMS40RDQwMUYsbXA0YS40MC4yIixSRVNPTFVUSU9OPTE5MjB4MTA4MCxBVURJTy0iMjM0IixGUkFNRS1SQVRFPTYwLFZJREVPLVJBTkdFPVNEUixDTE9TRUQtQ0FQVElPTlM9Tk9ORQptbGhsczovL2xvY2FsaG9zdC9pdGFnLzMxMS9tZWRpYWRhdGEubTN1OAo="; From 0061fb81231907dc7873375707237b754e9c2a41 Mon Sep 17 00:00:00 2001 From: Sergio Fedorini Date: Mon, 28 Sep 2026 15:52:07 +0300 Subject: [PATCH 2/6] Keep YouTube scrub and first picture in sync on the phone. Reverse playing events now put the clock under params, /playback-info reports a full buffer while the timeline advances, and both players wait for a real frame before announcing play. FFmpeg also latches its clock to ffplay, holds the window across seek, and opens a single AVC variant so the first picture is not several seconds late. Co-authored-by: Cursor --- .../airplay/player/ffmpeg/FFmpegPlayer.java | 2 + .../player/ffmpeg/FfmpegHlsPipeline.java | 431 ++++++++++++++++-- .../player/ffmpeg/Ipv4Getaddrinfo.java | 85 ++++ .../resources/native/force_ipv4_getaddrinfo.c | 41 ++ .../ffmpeg/FfmpegHlsPipelineSmokeTest.java | 73 ++- .../player/ffmpeg/FfmpegHlsPipelineTest.java | 24 +- .../fixtures/scrub-hold-20260926-144311.json | 9 + .../player/gstreamer/GstHlsPipeline.java | 63 ++- .../airplay/player/gstreamer/GstPlayer.java | 15 + .../airplay/player/gstreamer/ScrubClock.java | 57 +++ .../player/gstreamer/ScrubClockTest.java | 36 ++ .../serezhka/airplay/server/Playback.java | 14 +- .../server/internal/ControlServer.java | 17 +- .../handler/control/ControlHandler.java | 92 +++- .../handler/control/HlsFcupService.java | 95 +++- .../handler/session/HlsPlaylistState.java | 18 + .../handler/session/HlsUriRewrite.java | 33 +- .../handler/util/PropertyListUtil.java | 61 ++- .../handler/control/HlsFcupServiceTest.java | 21 + .../handler/session/HlsUriRewriteTest.java | 21 + .../PropertyListUtilPlaybackInfoTest.java | 26 +- 21 files changed, 1142 insertions(+), 92 deletions(-) create mode 100644 player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/Ipv4Getaddrinfo.java create mode 100644 player/ffmpeg/src/main/resources/native/force_ipv4_getaddrinfo.c create mode 100644 player/ffmpeg/src/test/resources/fixtures/scrub-hold-20260926-144311.json create mode 100644 player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java create mode 100644 player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/ScrubClockTest.java diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java index 9799230..57540c4 100644 --- a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java +++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlayer.java @@ -30,6 +30,8 @@ public FFmpegPlayer() { public FFmpegPlayer(int fps) { this.fps = Math.max(1, fps); hls.setOnEnded(() -> observer.onEnded()); + hls.setOnPresented(() -> observer.onPresented()); + hls.setOnSeekDisplayed(() -> observer.onSeekDisplayed()); log.info("FFmpeg debug log: {}", NativeProcessLog.playerLogFile("ffmpeg")); } diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java index b822ec9..de07896 100644 --- a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java +++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -30,6 +31,10 @@ final class FfmpegHlsPipeline { private final AtomicLong epoch = new AtomicLong(); private final AtomicReference pendingSeekSeconds = new AtomicReference<>(); private volatile Runnable onEnded = () -> {}; + private volatile Runnable onPresented = () -> {}; + private volatile Runnable onSeekDisplayed = () -> {}; + private volatile boolean replacementClockPending; + private volatile boolean pictureAnnounced; private final Object processLock = new Object(); private volatile String uri; @@ -40,6 +45,9 @@ final class FfmpegHlsPipeline { private volatile long lastEndedNotifyNanos; private volatile Thread playbackThread; private volatile Process ffplayProcess; + /** Frozen window kept on screen until the seek replacement has a picture. */ + private volatile Process heldFrame; + private volatile java.util.concurrent.atomic.AtomicBoolean incomingPicture = new java.util.concurrent.atomic.AtomicBoolean(); void start(String playlistUri, double volume) { start(playlistUri, volume, 0); @@ -59,8 +67,12 @@ void start(String playlistUri, double volume, double initialSeekSeconds) { double seek = initialSeekSeconds > 0.05 && initialSeekSeconds < ABSURD_SEEK_SECONDS ? initialSeekSeconds : 0; positionBaseSeconds = seek; - positionAnchorNanos = System.nanoTime(); + // Do not run the sender clock until ffplay prints a real timestamp. + // Starting it here put the phone several seconds ahead of the first frame. + positionAnchorNanos = 0; lastEndedNotifyNanos = 0; + pictureAnnounced = false; + replacementClockPending = false; pendingSeekSeconds.set(seek > 0.05 ? seek : null); long myEpoch = epoch.get(); @@ -83,6 +95,8 @@ void stop() { ended.set(false); pendingSeekSeconds.set(null); destroyFfplay(); + destroyLoose(heldFrame); + heldFrame = null; Thread thread = playbackThread; playbackThread = null; uri = null; @@ -108,8 +122,12 @@ void pause() { } positionBaseSeconds = currentPositionSeconds(); paused.set(true); - // Stop ffplay so Pulse does not keep playing; resume reopens with -ss. - destroyFfplay(); + // Freeze ffplay in place. Destroying it closes the SDL window (desktop shows through) + // and a following /scrub has to reopen from scratch. SIGSTOP keeps the last frame and + // stops Pulse. Windows has no SIGSTOP, so fall back to closing the process there. + if (!signalFfplay("STOP")) { + destroyFfplay(); + } log.info("HLS paused at {}s", positionBaseSeconds); } @@ -123,9 +141,12 @@ void resume() { } paused.set(false); positionAnchorNanos = System.nanoTime(); - // Reopen at the frozen position. - pendingSeekSeconds.set(positionBaseSeconds); - log.info("HLS resumed from {}s", positionBaseSeconds); + if (!signalFfplay("CONT")) { + pendingSeekSeconds.set(positionBaseSeconds); + log.info("HLS resume reopening from {}s", positionBaseSeconds); + } else { + log.info("HLS resumed from {}s", positionBaseSeconds); + } } void seek(double positionSeconds) { @@ -140,18 +161,20 @@ void seek(double positionSeconds) { paused.set(false); positionBaseSeconds = positionSeconds; positionAnchorNanos = System.nanoTime(); + replacementClockPending = true; pendingSeekSeconds.set(positionSeconds); + // Leave the current ffplay stopped so its window stays up. The playback thread + // opens the replacement and closes this one only after the new picture is up. + signalFfplay("STOP"); Process running; synchronized (processLock) { running = ffplayProcess; } - if (running == null || !running.isAlive()) { - // First open (or between destroy and reopen) — runPlayback picks up pendingSeek. + if ((running == null || !running.isAlive()) && (heldFrame == null || !heldFrame.isAlive())) { log.info("HLS seek queued to {}s", positionSeconds); return; } - destroyFfplay(); // wake waitFor so the loop reopens with -ss - log.info("HLS seek requested to {}s", positionSeconds); + log.info("HLS seek requested to {}s, holding the current frame", positionSeconds); } void noteMediaDuration(double seconds) { @@ -193,11 +216,8 @@ boolean isEnded() { if (paused.get() || positionAnchorNanos == 0) { return positionBaseSeconds; } - Process p = ffplayProcess; - if (p == null || !p.isAlive()) { - // Freeze while ffplay is down (seek/reopen/retry) — do not free-run the scrubber. - return positionBaseSeconds; - } + // Keep the scrub clock moving while ffplay is stopped or reopening. Freezing + // here left /playback-info stuck and the sender showed pause. double elapsed = (System.nanoTime() - positionAnchorNanos) / 1_000_000_000.0; double wall = Math.max(0, positionBaseSeconds + elapsed); double playlist = playlistDurationSeconds; @@ -213,19 +233,64 @@ boolean isEnded() { /** Smoke tests: ffplay child still alive (A/V in one process). */ boolean audioSinkAlive() { + return playerPid() > 0; + } + + /** Smoke tests: same pid across a hold means the window was not closed. */ + long playerPid() { Process p = ffplayProcess; - return p != null && p.isAlive(); + return p != null && p.isAlive() ? p.pid() : -1; } - /** Kept for unit tests that simulate demux PTS resets — reported position is wall-clock only. */ - void noteDemuxTimestampMicros(long timestampMicros) { - // no-op + /** Smoke tests: pid of the frozen window kept across a seek, or -1. */ + long heldFramePid() { + Process p = heldFrame; + return p != null && p.isAlive() ? p.pid() : -1; + } + + /** ffplay prints {@code M-A:} / {@code M-V:} once a clock is running. {@code nan} is not a picture. */ + static boolean ffplayStatusShowsPicture(String statusLine) { + return statusClockSeconds(statusLine) != null; + } + + /** Leading clock on an ffplay status line, or null when it is missing or {@code nan}. */ + static Double statusClockSeconds(String statusLine) { + if (statusLine == null) { + return null; + } + int mark = markerIndex(statusLine); + if (mark <= 0) { + return null; + } + String head = statusLine.substring(0, mark).trim(); + if (head.isEmpty() || head.equalsIgnoreCase("nan")) { + return null; + } + try { + double value = Double.parseDouble(head); + if (Double.isNaN(value) || value < 0 || value > ABSURD_SEEK_SECONDS) { + return null; + } + return value; + } catch (NumberFormatException e) { + return null; + } + } + + private static int markerIndex(String statusLine) { + for (String marker : new String[]{" M-A:", " M-V:", " A-V:"}) { + int at = statusLine.indexOf(marker); + if (at >= 0) { + return at; + } + } + return -1; } private void runPlayback(long myEpoch) { int earlyExitRetries = 0; while (epoch.get() == myEpoch && !stopRequested.get() && !userStopped.get()) { - if (paused.get()) { + if (paused.get() && pendingSeekSeconds.get() == null) { sleepQuiet(40); continue; } @@ -233,36 +298,99 @@ private void runPlayback(long myEpoch) { if (playlistUri == null) { break; } - Double seek = pendingSeekSeconds.getAndSet(null); - double startAt = seek != null && seek > 0.05 ? seek : positionBaseSeconds; - if (startAt > 0.05) { - positionBaseSeconds = startAt; - positionAnchorNanos = System.nanoTime(); - } else { - positionBaseSeconds = 0; - positionAnchorNanos = System.nanoTime(); - } + Process existing; + synchronized (processLock) { + existing = ffplayProcess; + } + boolean reuseFrozen = existing != null && existing.isAlive() && pendingSeekSeconds.get() == null; Process process; long startedAtNanos; - try { - process = startFfplay(playlistUri, startAt, volumeLinear); + double startAt; + if (reuseFrozen) { + process = existing; + startAt = positionBaseSeconds; startedAtNanos = System.nanoTime(); - } catch (Exception e) { - log.warn("HLS ffplay start failed, retrying: {}", e.toString()); - sleepQuiet(300); - continue; - } - synchronized (processLock) { - if (epoch.get() != myEpoch || stopRequested.get() || userStopped.get() || paused.get()) { - process.destroyForcibly(); + positionAnchorNanos = startedAtNanos; + } else { + Double seek = pendingSeekSeconds.getAndSet(null); + startAt = seek != null && seek > 0.05 ? seek : positionBaseSeconds; + if (startAt > 0.05) { + positionBaseSeconds = startAt; + } else if (positionAnchorNanos == 0) { + positionBaseSeconds = 0; + } + if (existing != null && existing.isAlive()) { + Process alreadyHeld = heldFrame; + if (alreadyHeld != null && alreadyHeld.isAlive() && alreadyHeld != existing) { + // A newer seek arrived before the previous replacement had a picture. + destroyLoose(existing); + } else { + heldFrame = existing; + synchronized (processLock) { + if (ffplayProcess == existing) { + ffplayProcess = null; + } + } + } + } + java.util.concurrent.atomic.AtomicBoolean picture; + try { + process = startFfplay(playlistUri, startAt, volumeLinear); + picture = incomingPicture; + startedAtNanos = System.nanoTime(); + } catch (Exception e) { + log.warn("HLS ffplay start failed, retrying: {}", e.toString()); + if (seek != null && seek > 0.05) { + pendingSeekSeconds.compareAndSet(null, seek); + } + sleepQuiet(300); continue; } - ffplayProcess = process; + synchronized (processLock) { + if (epoch.get() != myEpoch || stopRequested.get() || userStopped.get() || paused.get()) { + process.destroyForcibly(); + continue; + } + ffplayProcess = process; + } + log.info("HLS ffplay started pid={} ss={} volume={}", process.pid(), + startAt > 0.05 ? String.format(Locale.US, "%.3f", startAt) : "0", + Math.round(volumeLinear * 100)); + Process held = heldFrame; + if (held != null && held.isAlive() && held != process) { + boolean shown = waitForPicture(picture, process, myEpoch); + if (epoch.get() != myEpoch || stopRequested.get() || userStopped.get()) { + destroyLoose(process); + destroyLoose(held); + heldFrame = null; + continue; + } + if (!shown && !process.isAlive()) { + log.warn("HLS replacement exited before a picture; keeping the held frame"); + synchronized (processLock) { + if (ffplayProcess == process) { + ffplayProcess = null; + } + } + continue; + } + if (pendingSeekSeconds.get() != null && !shown) { + destroyLoose(process); + synchronized (processLock) { + if (ffplayProcess == process) { + ffplayProcess = null; + } + } + continue; + } + destroyLoose(held); + if (heldFrame == held) { + heldFrame = null; + } + log.info("HLS previous frame dropped (replacement picture={})", shown); + } } - log.info("HLS ffplay started pid={} ss={} volume={}", process.pid(), - startAt > 0.05 ? String.format(Locale.US, "%.3f", startAt) : "0", - Math.round(volumeLinear * 100)); try { while (epoch.get() == myEpoch && !stopRequested.get() && !userStopped.get() @@ -282,9 +410,27 @@ private void runPlayback(long myEpoch) { } sleepQuiet(50); } - if (pendingSeekSeconds.get() != null || paused.get() - || stopRequested.get() || userStopped.get() || epoch.get() != myEpoch) { + if (stopRequested.get() || userStopped.get() || epoch.get() != myEpoch) { destroyFfplay(); + destroyLoose(heldFrame); + heldFrame = null; + continue; + } + if (pendingSeekSeconds.get() != null) { + Process held = heldFrame; + if (held != null && held.isAlive() && held != process) { + destroyLoose(process); + synchronized (processLock) { + if (ffplayProcess == process) { + ffplayProcess = null; + } + } + } + // Otherwise keep this process; the next iteration freezes it as the held frame. + continue; + } + if (paused.get()) { + // Keep the frozen window. The next loop iteration waits until resume or seek. continue; } // Process exited on its own. @@ -327,8 +473,9 @@ private Process startFfplay(String playlistUri, double startAt, double volume) t cmd.add("-fs"); } cmd.add("-autoexit"); + // info (not error) so the status line is visible; the log pump keeps only real messages. cmd.add("-loglevel"); - cmd.add("error"); + cmd.add("info"); // FFmpeg 6+/8 default extension_picky rejects YouTube googlevideo URLs (no .ts/.m4s). // Only for HLS — these options can break plain mpegts/.ts opens used in smoke tests. if (isHlsPlaylistUri(playlistUri)) { @@ -343,20 +490,37 @@ private Process startFfplay(String playlistUri, double startAt, double volume) t cmd.add("-flags"); cmd.add("low_delay"); cmd.add("-framedrop"); + // Sync to the video clock. "audio" holds the first frame until the audio + // clock exists, which on HLS is the multi-second probe. cmd.add("-sync"); - cmd.add("audio"); + cmd.add("video"); cmd.add("-volume"); cmd.add(String.valueOf(Math.max(0, Math.min(100, (int) Math.round(volume * 100))))); if (startAt > 0.05) { cmd.add("-ss"); cmd.add(String.format(Locale.US, "%.3f", startAt)); } + // Default analyze window is 5s. A short window is enough for H.264 HLS and + // is what was sitting between "ffplay started" and the first picture. + cmd.add("-probesize"); + cmd.add("32768"); + cmd.add("-analyzeduration"); + cmd.add("200000"); cmd.add(playlistUri); ProcessBuilder pb = new ProcessBuilder(cmd); FfplayPcmSink.forcePulseAudioEnv(pb); - NativeProcessLog.configureProcessLogging(pb, "ffmpeg"); + if (isHlsPlaylistUri(playlistUri)) { + Ipv4Getaddrinfo.apply(pb); + } + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.PIPE); + java.util.concurrent.atomic.AtomicBoolean picture = new java.util.concurrent.atomic.AtomicBoolean(); + incomingPicture = picture; Process process = pb.start(); + Thread pump = new Thread(() -> pumpFfplayLog(process.getInputStream(), picture), "ffmpeg-hls-log"); + pump.setDaemon(true); + pump.start(); // Give SDL a moment; if it dies immediately the URL/env is wrong. sleepQuiet(150); if (!process.isAlive()) { @@ -378,18 +542,141 @@ private static boolean isHlsPlaylistUri(String playlistUri) { return lower.endsWith(".m3u8") || lower.endsWith(".m3u"); } + /** + * @return true when the signal was delivered. False on Windows or when ffplay is not running. + */ + private boolean signalFfplay(String signal) { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return false; + } + Process process; + synchronized (processLock) { + process = ffplayProcess; + } + if (process == null || !process.isAlive()) { + return false; + } + try { + Process kill = new ProcessBuilder("kill", "-" + signal, Long.toString(process.pid())) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start(); + if (!kill.waitFor(2, TimeUnit.SECONDS) || kill.exitValue() != 0) { + log.warn("kill -{} pid={} failed", signal, process.pid()); + return false; + } + return true; + } catch (Exception e) { + log.warn("kill -{} failed: {}", signal, e.toString()); + return false; + } + } + + private static final long HELD_FRAME_TIMEOUT_MS = 12_000; + + private boolean waitForPicture(java.util.concurrent.atomic.AtomicBoolean picture, Process process, long myEpoch) { + long deadline = System.nanoTime() + HELD_FRAME_TIMEOUT_MS * 1_000_000L; + while (System.nanoTime() < deadline) { + if (picture != null && picture.get()) { + return true; + } + if (process == null || !process.isAlive()) { + return false; + } + if (epoch.get() != myEpoch || stopRequested.get() || userStopped.get()) { + return false; + } + if (pendingSeekSeconds.get() != null) { + return false; + } + sleepQuiet(40); + } + return picture != null && picture.get(); + } + + private void pumpFfplayLog(java.io.InputStream in, java.util.concurrent.atomic.AtomicBoolean picture) { + java.nio.file.Path logFile = NativeProcessLog.playerLogFile("ffmpeg"); + try { + java.nio.file.Path parent = logFile.getParent(); + if (parent != null) { + java.nio.file.Files.createDirectories(parent); + } + } catch (java.io.IOException e) { + log.debug("HLS log dir: {}", e.toString()); + } + try (java.io.InputStream input = in; + java.io.OutputStream out = java.nio.file.Files.newOutputStream( + logFile, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.APPEND)) { + byte[] buf = new byte[2048]; + java.io.ByteArrayOutputStream line = new java.io.ByteArrayOutputStream(); + int n; + while ((n = input.read(buf)) >= 0) { + for (int i = 0; i < n; i++) { + byte b = buf[i]; + if (b == '\r' || b == '\n') { + String text = line.toString(java.nio.charset.StandardCharsets.UTF_8).replace("\u001b[2K", ""); + line.reset(); + if (text.isBlank()) { + continue; + } + if (text.contains("fd=") && text.contains("vq=")) { + Double clock = statusClockSeconds(text); + if (clock != null) { + picture.set(true); + noteClock(clock); + } + continue; + } + out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + out.write('\n'); + } else { + line.write(b); + } + } + } + } catch (java.io.IOException e) { + log.debug("HLS ffplay log pump ended: {}", e.toString()); + } + } + private void destroyFfplay() { Process process; synchronized (processLock) { process = ffplayProcess; ffplayProcess = null; } + destroyLoose(process); + } + + private void destroyLoose(Process process) { if (process == null) { return; } + synchronized (processLock) { + if (ffplayProcess == process) { + ffplayProcess = null; + } + if (heldFrame == process) { + heldFrame = null; + } + } + if (process.isAlive()) { + try { + new ProcessBuilder("kill", "-CONT", Long.toString(process.pid())) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor(1, TimeUnit.SECONDS); + } catch (Exception ignored) { + // SIGKILL below still reaps a stopped process. + } + } process.destroy(); try { - if (!process.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (!process.waitFor(500, TimeUnit.MILLISECONDS)) { process.destroyForcibly(); } } catch (InterruptedException e) { @@ -421,6 +708,52 @@ void setOnEnded(Runnable onEnded) { this.onEnded = onEnded == null ? () -> {} : onEnded; } + void setOnPresented(Runnable onPresented) { + this.onPresented = onPresented == null ? () -> {} : onPresented; + } + + void setOnSeekDisplayed(Runnable onSeekDisplayed) { + this.onSeekDisplayed = onSeekDisplayed == null ? () -> {} : onSeekDisplayed; + } + + /** First real ffplay clock. Until then the reported position does not advance. */ + private void noteClock(double clockSeconds) { + if (positionAnchorNanos == 0) { + positionBaseSeconds = clockSeconds; + positionAnchorNanos = System.nanoTime(); + log.info("HLS clock latched at {}s", String.format(Locale.US, "%.3f", clockSeconds)); + } + if (replacementClockPending) { + replacementClockPending = false; + // The status clock is the real picture. Do not walk the sender backward + // onto an earlier keyframe; only adopt it when it is at or ahead of us. + if (clockSeconds + 0.05 >= positionBaseSeconds) { + positionBaseSeconds = clockSeconds; + positionAnchorNanos = System.nanoTime(); + } + log.info("HLS seek picture at {}s", String.format(Locale.US, "%.3f", currentPositionSeconds())); + try { + onSeekDisplayed.run(); + } catch (RuntimeException e) { + log.warn("HLS seek picture callback failed: {}", e.toString()); + } + } + notePicture(); + } + + private void notePicture() { + if (pictureAnnounced) { + return; + } + pictureAnnounced = true; + log.info("HLS first picture"); + try { + onPresented.run(); + } catch (RuntimeException e) { + log.warn("HLS picture callback failed: {}", e.toString()); + } + } + private static void sleepQuiet(long ms) { try { Thread.sleep(ms); diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/Ipv4Getaddrinfo.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/Ipv4Getaddrinfo.java new file mode 100644 index 0000000..dca3405 --- /dev/null +++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/Ipv4Getaddrinfo.java @@ -0,0 +1,85 @@ +package com.github.serezhka.airplay.player.ffmpeg; + +import lombok.extern.slf4j.Slf4j; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Some CDN edges reset IPv6 connections from this host. ffplay has no IPv4 switch, + * so the HLS process loads a small helper that resolves names as IPv4 only. + */ +@Slf4j +final class Ipv4Getaddrinfo { + + private static final Object LOCK = new Object(); + private static volatile Path library; + private static volatile boolean unavailable; + + private Ipv4Getaddrinfo() { + } + + static void apply(ProcessBuilder processBuilder) { + Path so = library(); + if (so == null) { + return; + } + Map env = processBuilder.environment(); + String existing = env.get("LD_PRELOAD"); + env.put("LD_PRELOAD", existing == null || existing.isBlank() ? so.toString() : so + ":" + existing); + } + + private static Path library() { + if (!isLinux() || unavailable) { + return library; + } + Path cached = library; + if (cached != null) { + return cached; + } + synchronized (LOCK) { + if (library != null || unavailable) { + return library; + } + try { + library = compile(); + log.info("HLS segment DNS forced to IPv4 ({})", library); + } catch (Exception e) { + unavailable = true; + log.warn("IPv4 DNS helper unavailable, segment fetches stay dual-stack: {}", e.toString()); + } + return library; + } + } + + private static Path compile() throws Exception { + Path dir = Path.of(System.getProperty("java.io.tmpdir"), "airplay-ipv4"); + Files.createDirectories(dir); + Path source = dir.resolve("force_ipv4_getaddrinfo.c"); + Path so = dir.resolve("libforce_ipv4_getaddrinfo.so"); + try (InputStream in = Ipv4Getaddrinfo.class.getResourceAsStream("/native/force_ipv4_getaddrinfo.c")) { + if (in == null) { + throw new IllegalStateException("missing /native/force_ipv4_getaddrinfo.c"); + } + Files.copy(in, source, StandardCopyOption.REPLACE_EXISTING); + } + Process cc = new ProcessBuilder( + "cc", "-shared", "-fPIC", "-o", so.toString(), source.toString(), "-ldl") + .redirectErrorStream(true) + .start(); + String output = new String(cc.getInputStream().readAllBytes()); + if (!cc.waitFor(20, TimeUnit.SECONDS) || cc.exitValue() != 0 || !Files.isRegularFile(so)) { + throw new IllegalStateException("cc failed: " + output.trim()); + } + return so; + } + + private static boolean isLinux() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux"); + } +} diff --git a/player/ffmpeg/src/main/resources/native/force_ipv4_getaddrinfo.c b/player/ffmpeg/src/main/resources/native/force_ipv4_getaddrinfo.c new file mode 100644 index 0000000..409fb7e --- /dev/null +++ b/player/ffmpeg/src/main/resources/native/force_ipv4_getaddrinfo.c @@ -0,0 +1,41 @@ +/* + * LD_PRELOAD helper: force getaddrinfo to request AF_INET only. + * + * Some CDN edges reset IPv6 (AAAA) connections from this host. Segment URLs then + * fail while the playback clock keeps moving. Resolving those hosts as IPv4 + * reaches an edge that answers. + * + * Build: cc -shared -fPIC -o libforce_ipv4_getaddrinfo.so force_ipv4_getaddrinfo.c -ldl + */ +#define _GNU_SOURCE +#include +#include +#include +#include + +typedef int (*getaddrinfo_fn)(const char *, const char *, const struct addrinfo *, struct addrinfo **); + +int getaddrinfo(const char *node, const char *service, + const struct addrinfo *hints, struct addrinfo **res) { + static getaddrinfo_fn real_getaddrinfo = NULL; + if (!real_getaddrinfo) { + real_getaddrinfo = (getaddrinfo_fn) dlsym(RTLD_NEXT, "getaddrinfo"); + } + if (!real_getaddrinfo) { + return EAI_SYSTEM; + } + + /* Opt out: AIRPLAY_ALLOW_IPV6=1 keeps default dual-stack resolution. */ + const char *allow = getenv("AIRPLAY_ALLOW_IPV6"); + if (allow != NULL && allow[0] != '\0' && strcmp(allow, "0") != 0) { + return real_getaddrinfo(node, service, hints, res); + } + + struct addrinfo forced; + memset(&forced, 0, sizeof(forced)); + if (hints != NULL) { + forced = *hints; + } + forced.ai_family = AF_INET; + return real_getaddrinfo(node, service, &forced, res); +} diff --git a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java index 1b29401..7b3f2db 100644 --- a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java +++ b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -40,8 +41,12 @@ void pauseResumeSeekAndAudioSinkStayAlive() throws Exception { assertTrue(hls.audioSinkAlive(), "ffplay PCM sink died early (check -ch_layout / FFmpeg 8)"); double beforePause = hls.currentPositionSeconds(); + long pid = hls.playerPid(); hls.pause(); Thread.sleep(800); + if (!System.getProperty("os.name", "").toLowerCase().contains("win")) { + assertEquals(pid, hls.playerPid(), "pause must freeze ffplay, not close the window"); + } double duringPause = hls.currentPositionSeconds(); assertTrue(Math.abs(duringPause - beforePause) < 0.5, "position should stay put while paused: " + beforePause + " -> " + duringPause); @@ -63,6 +68,66 @@ void pauseResumeSeekAndAudioSinkStayAlive() throws Exception { } } + /** + * YouTube scrub (session 20260926-144311): {@code rate=0} then {@code /scrub} 1.84s later. + * The hold must keep the same ffplay pid; the seek then reopens at the new position. + */ + @Test + @Timeout(90) + void scrubAfterLongHoldKeepsPictureThenSeeks() throws Exception { + Assumptions.assumeTrue(ffmpegAvailable(), "ffmpeg not available"); + Assumptions.assumeTrue(ffplayAvailable(), "ffplay not available"); + Assumptions.assumeFalse(System.getProperty("os.name", "").toLowerCase().contains("win"), + "SIGSTOP hold is Unix-only"); + + Path media = temp.resolve("scrub.ts"); + generateSmokeTs(media, 12); + Assumptions.assumeTrue(Files.size(media) > 1000, "failed to generate scrub.ts"); + + System.setProperty("airplay.ffmpeg.hls.headless", "true"); + FfmpegHlsPipeline hls = new FfmpegHlsPipeline(); + try { + hls.start(media.toUri().toString(), 1.0); + hls.noteMediaDuration(12); + awaitPosition(hls, 0.4, 12_000); + long pid = hls.playerPid(); + assertTrue(pid > 0); + + hls.pause(); + Thread.sleep(2_000); + assertEquals(pid, hls.playerPid(), "ffplay window closed during the scrub hold"); + double held = hls.currentPositionSeconds(); + assertTrue(held < 3.0, "position ran during hold: " + held); + + java.util.concurrent.atomic.AtomicBoolean sawHeldFrame = new java.util.concurrent.atomic.AtomicBoolean(); + Thread watch = new Thread(() -> { + long end = System.currentTimeMillis() + 4_000; + while (System.currentTimeMillis() < end) { + if (hls.heldFramePid() > 0) { + sawHeldFrame.set(true); + return; + } + try { + Thread.sleep(5); + } catch (InterruptedException e) { + return; + } + } + }); + watch.start(); + hls.seek(8.0); + watch.join(4_000); + assertTrue(sawHeldFrame.get(), "seek closed the previous ffplay before the replacement had a picture"); + awaitNear(hls, 8.0, 1.5, 12_000); + awaitAudioAlive(hls, 8_000); + assertTrue(hls.audioSinkAlive(), "ffplay died after seek"); + awaitAdvance(hls, 8.2, 8_000); + } finally { + hls.stop(); + System.clearProperty("airplay.ffmpeg.hls.headless"); + } + } + @Test @Timeout(90) void extensionlessSegmentUrlsPlayWithPickyOff() throws Exception { @@ -114,10 +179,14 @@ private static boolean toolAvailable(String tool) { } private static void generateSmokeTs(Path out) throws Exception { + generateSmokeTs(out, 6); + } + + private static void generateSmokeTs(Path out, int seconds) throws Exception { Process p = new ProcessBuilder( "ffmpeg", "-y", - "-f", "lavfi", "-i", "testsrc=duration=6:size=320x240:rate=30", - "-f", "lavfi", "-i", "sine=frequency=440:duration=6", + "-f", "lavfi", "-i", "testsrc=duration=" + seconds + ":size=320x240:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440:duration=" + seconds, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-tune", "zerolatency", "-c:a", "aac", "-ac", "2", "-ar", "44100", "-shortest", "-f", "mpegts", out.toAbsolutePath().toString() diff --git a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java index 2b35005..a20a253 100644 --- a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java +++ b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java @@ -18,6 +18,16 @@ void tearDown() { hls.stop(); } + @Test + void statusLineWithClockCountsAsPicture() { + assertFalse(FfmpegHlsPipeline.ffplayStatusShowsPicture(" nan M-V: nan fd= 0 aq= 0KB vq= 28KB")); + assertFalse(FfmpegHlsPipeline.ffplayStatusShowsPicture(" nan : 0.000 fd= 0 aq= 0KB vq= 0KB")); + assertTrue(FfmpegHlsPipeline.ffplayStatusShowsPicture(" 1.56 M-A: -0.000 fd= 0 aq= 9KB vq= 0KB")); + assertEquals(1.56, FfmpegHlsPipeline.statusClockSeconds(" 1.56 M-A: -0.000 fd= 0 aq= 9KB vq= 0KB"), 0.001); + assertEquals(null, FfmpegHlsPipeline.statusClockSeconds(" nan M-V: nan fd= 0 aq= 0KB vq= 28KB")); + assertTrue(FfmpegHlsPipeline.ffplayStatusShowsPicture(" 0.40 M-V: 0.000 fd= 0 aq= 0KB vq= 12KB")); + } + @Test void notesEndListDuration() { hls.noteMediaDuration(6.0); @@ -64,14 +74,11 @@ void demuxPtsResetDoesNotJumpReportedPosition() throws Exception { hls.start("http://127.0.0.1:9/missing.m3u8", 1.0); hls.noteMediaDuration(60); hls.seek(5.0); - // Position is frozen while ffplay is down; seek still updates the base. - assertEquals(5.0, hls.currentPositionSeconds(), 0.1); - - // Simulate HLS/TS segment PTS restart near zero (the phone scrubber bug). - hls.noteDemuxTimestampMicros(64_944L); + assertEquals(5.0, hls.currentPositionSeconds(), 0.15); + Thread.sleep(250); double after = hls.currentPositionSeconds(); - assertEquals(5.0, after, 0.1, - "reported position must not jump on demux PTS reset: " + after); + assertTrue(after > 5.05, "scrub clock must keep moving while ffplay is down: " + after); + assertTrue(after < 5.8, "scrub clock ran away: " + after); } finally { System.clearProperty("airplay.ffmpeg.hls.headless"); } @@ -99,6 +106,9 @@ void startWithInitialSeekDoesNotRequireFollowUpSeek() throws Exception { hls.start("http://127.0.0.1:9/missing.m3u8", 1.0, 42.5); hls.noteMediaDuration(120); assertEquals(42.5, hls.currentPositionSeconds(), 0.2); + Thread.sleep(200); + assertEquals(42.5, hls.currentPositionSeconds(), 0.05, + "clock must stay put until ffplay reports a timestamp"); } finally { System.clearProperty("airplay.ffmpeg.hls.headless"); } diff --git a/player/ffmpeg/src/test/resources/fixtures/scrub-hold-20260926-144311.json b/player/ffmpeg/src/test/resources/fixtures/scrub-hold-20260926-144311.json new file mode 100644 index 0000000..169be44 --- /dev/null +++ b/player/ffmpeg/src/test/resources/fixtures/scrub-hold-20260926-144311.json @@ -0,0 +1,9 @@ +{ + "session": "session-20260926-144311-stable-ffmpeg", + "note": "YouTube scrub sends rate=0, then /scrub on release. A 400ms pause defer fired on the long hold and closed ffplay.", + "gestures": [ + {"rate0": "14:46:15.363", "scrub": "14:46:15.548", "gapMs": 185, "position": 215.978, "deferredPauseApplied": false}, + {"rate0": "14:46:24.691", "scrub": "14:46:26.533", "gapMs": 1842, "position": 371.089, "deferredPauseApplied": true}, + {"rate0": "14:46:32.555", "scrub": null, "gapMs": null, "deferredPauseApplied": true} + ] +} diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java index 9713c77..218e3ea 100644 --- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java @@ -8,6 +8,9 @@ import org.freedesktop.gstreamer.ElementFactory; import org.freedesktop.gstreamer.Format; import org.freedesktop.gstreamer.Gst; +import org.freedesktop.gstreamer.Pad; +import org.freedesktop.gstreamer.PadProbeReturn; +import org.freedesktop.gstreamer.PadProbeType; import org.freedesktop.gstreamer.Pipeline; import org.freedesktop.gstreamer.State; import org.freedesktop.gstreamer.event.SeekFlags; @@ -20,6 +23,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; /** @@ -38,6 +42,8 @@ final class GstHlsPipeline { }); private volatile Runnable onEnded = () -> {}; + private volatile Runnable onPresented = () -> {}; + private final AtomicBoolean presented = new AtomicBoolean(); private Pipeline pipeline; private Element videoSink; private JFrame window; @@ -55,6 +61,7 @@ final class GstHlsPipeline { private volatile long startedAtNanos; /** Wall-clock position must not run until GST reports a real media clock (avoids black-screen EOS). */ private volatile boolean mediaClockTrusted; + private final ScrubClock scrubClock = new ScrubClock(); void start(String playlistUri, double volumeLinear) { stop(); @@ -85,6 +92,7 @@ void start(String playlistUri, double volumeLinear) { pipeline.set("uri", playlistUri); pipeline.set("volume", clampVolume(volumeLinear)); pipeline.set("video-sink", videoSink); + watchFirstPicture(videoSink); log.info("HLS pipeline using {} headless={}", launch.split(" ")[0], headless); if (!headless && display != null && display.overlay() && display.canvas() != null) { @@ -150,6 +158,8 @@ void stop() { lastEndedNotifyNanos = 0; startedAtNanos = 0; mediaClockTrusted = false; + scrubClock.clear(); + presented.set(false); uri = null; if (pipeline != null) { try { @@ -175,8 +185,10 @@ void pause() { if (pipeline == null || ended) { return; } - double gstPos = querySeconds(false); - positionBaseSeconds = gstPos > 0 ? gstPos : currentPositionSeconds(); + // Anchor on the position we already told the sender. A raw demux query + // here is the earlier keyframe and would walk the timeline backward. + double reported = currentPositionSeconds(); + positionBaseSeconds = reported > 0 ? reported : positionBaseSeconds; paused = true; pipeline.pause(); log.info("HLS paused at {}s", positionBaseSeconds); @@ -214,6 +226,9 @@ void seek(double positionSeconds) { ended = false; positionBaseSeconds = positionSeconds; positionAnchorNanos = System.nanoTime(); + // Demux often answers with the previous picture or an earlier keyframe. + // Hold the scrub clock so /playback-info does not step backward. + scrubClock.arm(positionSeconds); paused = false; if (pipeline != null) { pipeline.play(); @@ -244,7 +259,7 @@ boolean isActive() { double dur = durationSeconds(); return dur > 0 ? dur : positionBaseSeconds; } - double pos = rawPositionSeconds(); + double pos = scrubClock.report(rawPositionSeconds(), wallClockSeconds()); checkPositionAtEnd(pos); return ended ? durationSeconds() : pos; } @@ -300,6 +315,16 @@ private void checkPositionAtEnd(double pos) { } } + private double wallClockSeconds() { + if (paused || positionAnchorNanos == 0) { + return positionBaseSeconds; + } + double elapsed = (System.nanoTime() - positionAnchorNanos) / 1_000_000_000.0; + double wall = Math.max(0, positionBaseSeconds + elapsed); + double playlist = playlistDurationSeconds; + return playlist > 0 ? Math.min(wall, playlist) : wall; + } + private double rawPositionSeconds() { double playlist = playlistDurationSeconds; double gst = querySeconds(false); @@ -353,6 +378,38 @@ void setOnEnded(Runnable onEnded) { this.onEnded = onEnded == null ? () -> {} : onEnded; } + void setOnPresented(Runnable onPresented) { + this.onPresented = onPresented == null ? () -> {} : onPresented; + } + + /** First buffer on the sink is the frame that is about to be shown. */ + private void watchFirstPicture(Element sink) { + if (sink == null) { + return; + } + Pad pad = sink.getStaticPad("sink"); + if (pad == null) { + log.warn("HLS sink has no sink pad; picture announcement will use the fallback"); + return; + } + pad.addProbe(PadProbeType.BUFFER, (probed, info) -> { + notePresented(); + return PadProbeReturn.REMOVE; + }); + } + + private void notePresented() { + if (uri == null || !presented.compareAndSet(false, true)) { + return; + } + log.info("HLS first picture"); + try { + onPresented.run(); + } catch (RuntimeException e) { + log.warn("HLS picture callback failed: {}", e.toString()); + } + } + private void cancelEndWatch() { ScheduledFuture future = endWatch; endWatch = null; diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java index b9c2035..58c8e17 100644 --- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java @@ -33,9 +33,23 @@ public class GstPlayer implements Playback { GLib.setEnv("GST_DEBUG_NO_COLOR", "1", true); GLib.setEnv("GST_DEBUG_FILE", NativeProcessLog.playerLogFile("gstreamer").toString(), true); GLib.setEnv("GST_DEBUG", System.getProperty("airplay.gst.debug", "3"), true); + preferSoftwareVideoDecode(); Gst.init(Version.of(1, 10), "BasicPipeline"); } + /** + * The device H.264 decoder drops frames and rewinds timestamps when it has no usable + * context, which shows up as stutter a short way into playback. Keep it out of autoplug. + */ + private static void preferSoftwareVideoDecode() { + String drop = "nvh264dec:NONE,nvh265dec:NONE,nvvp8dec:NONE,nvvp9dec:NONE,nvav1dec:NONE"; + String existing = System.getenv("GST_PLUGIN_FEATURE_RANK"); + String rank = existing == null || existing.isBlank() || existing.contains("nvh264dec:") + ? (existing == null || existing.isBlank() ? drop : existing) + : existing + "," + drop; + GLib.setEnv("GST_PLUGIN_FEATURE_RANK", rank, true); + } + private final Pipeline h264Pipeline; private final Pipeline alacPipeline; private final Pipeline aacEldPipeline; @@ -63,6 +77,7 @@ public GstPlayer() { public GstPlayer(int fps) { log.info("GStreamer debug log: {}", NativeProcessLog.playerLogFile("gstreamer")); hls.setOnEnded(() -> observer.onEnded()); + hls.setOnPresented(() -> observer.onPresented()); int framerate = Math.max(1, fps); useD3d11 = GstVideoSinkFactory.hasD3d11(); boolean useXimage = GstVideoSinkFactory.hasXimage(); diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java new file mode 100644 index 0000000..ffd9b26 --- /dev/null +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java @@ -0,0 +1,57 @@ +package com.github.serezhka.airplay.player.gstreamer; + +/** + * After a seek the demux clock can snap to an earlier keyframe. Publishing that + * walks /playback-info backward, and the sender freezes its timeline. + * + *

The first sample after a seek often equals the scrub target (the wall clock + * we already know). That must not drop the guard: the next sample is the keyframe + * a couple of seconds earlier. While the guard is up the reported position stays + * on the scrub clock and keeps advancing. A new {@link #arm(double)} allows a + * backward scrub. + */ +final class ScrubClock { + + private volatile boolean hold; + private volatile double floor; + private volatile double armedAt; + + void arm(double targetSeconds) { + armedAt = targetSeconds; + floor = targetSeconds; + hold = true; + } + + void clear() { + hold = false; + floor = 0; + armedAt = 0; + } + + /** + * @param clockSeconds demux position, which right after a seek may still be the wall clock + * @param wallSeconds scrub target plus time since the seek + */ + double report(double clockSeconds, double wallSeconds) { + if (hold) { + // Demux has moved past the scrub point and sits beside the scrub clock. + boolean caughtUp = clockSeconds >= armedAt + 0.5 + && Math.abs(clockSeconds - wallSeconds) <= 0.25; + if (caughtUp) { + hold = false; + floor = clockSeconds; + return clockSeconds; + } + double chosen = Math.max(floor, wallSeconds); + floor = chosen; + return chosen; + } + if (clockSeconds + 0.05 < floor) { + return floor; + } + if (clockSeconds > floor) { + floor = clockSeconds; + } + return clockSeconds; + } +} diff --git a/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/ScrubClockTest.java b/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/ScrubClockTest.java new file mode 100644 index 0000000..1f536bb --- /dev/null +++ b/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/ScrubClockTest.java @@ -0,0 +1,36 @@ +package com.github.serezhka.airplay.player.gstreamer; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ScrubClockTest { + + @Test + void doesNotStepBackwardOntoEarlierKeyframe() { + ScrubClock clock = new ScrubClock(); + clock.arm(103.10); + // First sample matches the scrub clock and must not release the guard. + assertEquals(103.10, clock.report(103.10, 103.10), 0.001); + // Session 20260926-154407: next demux answer was 101.76. + assertEquals(104.6, clock.report(101.76, 104.6), 0.001); + assertEquals(105.6, clock.report(102.72, 105.6), 0.001); + } + + @Test + void ignoresStaleClockAfterBackwardScrub() { + ScrubClock clock = new ScrubClock(); + clock.arm(186.4); + assertEquals(186.5, clock.report(289.0, 186.5), 0.001); + assertEquals(188.0, clock.report(183.4, 188.0), 0.001); + } + + @Test + void followsDemuxOnceItCatchesTheScrubClock() { + ScrubClock clock = new ScrubClock(); + clock.arm(100); + assertEquals(103.0, clock.report(103.0, 103.2), 0.001); + // Guard released; later playback follows the demux, including a small lead. + assertEquals(110.0, clock.report(110.0, 109.0), 0.001); + } +} diff --git a/server/src/main/java/com/github/serezhka/airplay/server/Playback.java b/server/src/main/java/com/github/serezhka/airplay/server/Playback.java index 9e60706..d82e365 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/Playback.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/Playback.java @@ -57,7 +57,19 @@ default void setObserver(Observer observer) { interface Observer { void onEnded(); - Observer NONE = () -> {}; + /** First picture is on the display. Until then the sender stays on {@code loading}. */ + default void onPresented() { + } + + /** A seek has a picture. Tell the sender {@code playing} at the real position. */ + default void onSeekDisplayed() { + } + + Observer NONE = new Observer() { + @Override + public void onEnded() { + } + }; } /** {@code rate}: {@code 0} paused, {@code 1} playing ({@code /playback-info}, {@code /rate}). */ diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java index b03dabb..b688270 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java @@ -45,7 +45,22 @@ public ControlServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer) { this.airPlayConfig = airPlayConfig; this.airPlayConsumer = airPlayConsumer; this.hlsFcupService = new HlsFcupService(sessionManager, airPlayConsumer); - airPlayConsumer.setObserver(hlsFcupService::refreshActivePlaylists); + airPlayConsumer.setObserver(new Playback.Observer() { + @Override + public void onEnded() { + hlsFcupService.refreshActivePlaylists(); + } + + @Override + public void onPresented() { + hlsFcupService.onPlaybackPresented(); + } + + @Override + public void onSeekDisplayed() { + hlsFcupService.onSeekDisplayed(); + } + }); } public void start() throws InterruptedException { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java index b0ce506..fd73b0f 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java @@ -62,7 +62,19 @@ public class ControlHandler extends ChannelInboundHandlerAdapter { t.setDaemon(true); return t; }); + /** YouTube sometimes sends another rate=0 just after the rate=1 that ends a scrub. */ + private static final long POST_SCRUB_RATE0_GRACE_NS = 2_000_000_000L; + /** + * rate=0 is also how a scrub starts (finger down). /scrub can follow several + * seconds later (seen at 5.4s). Publishing rate=0 or pausing the picture before + * that lights the phone pause UI. Wait this long, and cancel if /scrub arrives. + */ + private static final long RATE_ZERO_DELAY_MS = 8_000; + private final ConcurrentHashMap> pendingPlaybackStarts = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> pendingRateZeros = new ConcurrentHashMap<>(); + private final ConcurrentHashMap rateZeroTokens = new ConcurrentHashMap<>(); + private final ConcurrentHashMap suppressPauseUntilNanos = new ConcurrentHashMap<>(); public ControlHandler(SessionManager sessionManager, HlsFcupService hlsFcupService, @@ -90,6 +102,7 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { private void stopHlsPlayback(Session session) { cancelPlaybackStartFallback(session.getId()); + cancelReportedPause(session.getId()); hlsFcupService.cancelAllMasterPolls(); hlsFcupService.clearReverseQueue(session); session.setHlsPlaylistState(null); @@ -497,27 +510,40 @@ private void handleRate(ChannelHandlerContext ctx, FullHttpRequest request) { } if (value == 0) { - if (hls != null && hls.shouldIgnorePause()) { + Long suppressUntil = suppressPauseUntilNanos.get(session.getId()); + if (suppressUntil != null && System.nanoTime() < suppressUntil) { + log.info("Ignoring rate=0 shortly after scrub resume session={}", session.getId()); + } else if (hls != null && hls.shouldIgnorePause()) { // /scrub arms a latch: drop rate=0 until the matching rate=1 (no timer). log.info("Ignoring rate=0 until rate=1 after scrub session={}", session.getId()); } else { - if (hls != null) { - hls.setPlaybackRate(0); - } - airPlayConsumer.onPause(); - hlsFcupService.sendPlaybackStateEvent(session, "paused"); + // Keep playing and keep reporting rate=1 until this is clearly a pause + // and not the start of a scrub. Do not emit reverse "paused" yet. + log.info("Holding rate=1 for rate=0 session={}", session.getId()); + scheduleReportedPause(session); } } else { - boolean wasPaused = hls != null && hls.getPlaybackRate() <= 0; + boolean heldForScrub = cancelReportedPause(session.getId()); + boolean scrubResume = hls != null && hls.shouldIgnorePause(); + boolean wasPaused = heldForScrub || (hls != null && hls.getPlaybackRate() <= 0); if (hls != null) { hls.clearScrubIgnorePause(); hls.setPlaybackRate(1); } + if (scrubResume) { + suppressPauseUntilNanos.put(session.getId(), System.nanoTime() + POST_SCRUB_RATE0_GRACE_NS); + } // Only resume when leaving pause — rate=1 while already playing must not re-seek. if (wasPaused) { airPlayConsumer.onResume(); } - hlsFcupService.sendPlaybackStateEvent(session, "playing"); + // rate=1 arrives before the first frame. Announcing playing here starts the + // sender clock while the display is still black (ffplay probe is several seconds). + if (hls == null || !hls.isAwaitingPresentation()) { + hlsFcupService.sendPlaybackStateEvent(session, "playing"); + } else { + log.info("Holding playing until the picture session={}", session.getId()); + } } var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); @@ -534,6 +560,7 @@ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) { var session = resolveSession(request); var hls = session.getHlsPlaylistState(); if (hls != null) { + cancelReportedPause(session.getId()); // Latch: post-scrub rate=0 is bracket noise until rate=1. hls.markScrubIgnorePauseUntilPlay(); hls.setPlaybackRate(1); @@ -543,6 +570,9 @@ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) { log.info("Deferring /scrub to pending seek until HLS starts"); } else { airPlayConsumer.onSeek(position); + // Seek acknowledgement, then playing with the new position in params. + // A lone playing event left the sender on the pause UI. + hlsFcupService.sendPlaybackStateEvent(session, "loading"); hlsFcupService.sendPlaybackStateEvent(session, "playing"); } } catch (NumberFormatException e) { @@ -769,8 +799,7 @@ private void handleUnhandledUrlResponse(ChannelHandlerContext ctx, FullHttpReque } private void startHlsPlayback(Session session, HlsPlaylistState hls) { - airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal()); - hlsFcupService.sendPlaybackStateEvent(session, "playing"); + hlsFcupService.beginDisplayedPlayback(session); Double seek = hls.takePendingSeekSeconds(); if (seek != null && seek > 0) { airPlayConsumer.onSeek(seek); @@ -806,6 +835,49 @@ private void schedulePlaybackStartFallback(Session session, HlsPlaylistState hls pendingPlaybackStarts.put(sessionId, future); } + private void scheduleReportedPause(Session session) { + String sessionId = session.getId(); + long token = System.nanoTime(); + rateZeroTokens.put(sessionId, token); + ScheduledFuture previous = pendingRateZeros.remove(sessionId); + if (previous != null) { + previous.cancel(false); + } + ScheduledFuture future = hlsScheduler.schedule(() -> { + pendingRateZeros.remove(sessionId); + if (!Long.valueOf(token).equals(rateZeroTokens.get(sessionId))) { + return; + } + Session current = sessionManager.getSession(sessionId); + if (current == null) { + return; + } + var hls = current.getHlsPlaylistState(); + if (hls == null || hls.shouldIgnorePause() || hls.isWaitingForMasterChange()) { + return; + } + if (!Long.valueOf(token).equals(rateZeroTokens.get(sessionId))) { + return; + } + hls.setPlaybackRate(0); + airPlayConsumer.onPause(); + hlsFcupService.sendPlaybackStateEvent(current, "paused"); + log.info("Reporting pause after rate=0 with no scrub session={}", sessionId); + }, RATE_ZERO_DELAY_MS, TimeUnit.MILLISECONDS); + pendingRateZeros.put(sessionId, future); + } + + /** @return true when a not-yet-published pause was cancelled */ + private boolean cancelReportedPause(String sessionId) { + rateZeroTokens.remove(sessionId); + ScheduledFuture future = pendingRateZeros.remove(sessionId); + if (future == null) { + return false; + } + future.cancel(false); + return true; + } + private void cancelPlaybackStartFallback(String sessionId) { ScheduledFuture future = pendingPlaybackStarts.remove(sessionId); if (future != null) { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java index d8b602e..9f5e44f 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java @@ -31,6 +31,8 @@ public class HlsFcupService { * N=3 (~6s) balances freshness vs reverse /event channel load (playlistRemove / next /play). */ private static final int MEDIA_RESWEEP_EVERY_MASTER_POLLS = 3; + /** If no frame arrives, announce anyway so the sender is not stuck on loading. */ + private static final long PRESENTATION_FALLBACK_MS = 8_000; private final SessionManager sessionManager; private final Playback airPlayConsumer; @@ -40,6 +42,7 @@ public class HlsFcupService { return thread; }); private final Map> masterPollTasks = new ConcurrentHashMap<>(); + private final Map> presentationFallbacks = new ConcurrentHashMap<>(); /** One in-flight reverse {@code POST /event} per session (no HTTP pipelining). */ private final Map reverseQueues = new ConcurrentHashMap<>(); @@ -104,8 +107,7 @@ public void onMasterRefreshDuringPlayback(Session session, String rewrittenBody, } else { hls.updateMasterPlaylist(rewrittenBody); } - airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal()); - sendPlaybackStateEvent(session, "playing"); + beginDisplayedPlayback(session); Double seek = hls.takePendingSeekSeconds(); if (seek != null && seek > 0) { airPlayConsumer.onSeek(seek); @@ -153,8 +155,7 @@ public void onPostEosMediaRefreshComplete(Session session) { cancelMasterPoll(session.getId()); hls.setWaitingForMasterChange(false); hls.setPlaybackRate(1); - airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal()); - sendPlaybackStateEvent(session, "playing"); + beginDisplayedPlayback(session); } else { log.info("HLS media unchanged after EOS, polling master session {}", session.getId()); scheduleMasterPoll(session); @@ -164,6 +165,7 @@ public void onPostEosMediaRefreshComplete(Session session) { public void cancelAllMasterPolls() { for (Session session : sessionManager.allSessions()) { cancelMasterPoll(session.getId()); + cancelPresentationFallback(session.getId()); var hls = session.getHlsPlaylistState(); if (hls != null) { hls.setWaitingForMasterChange(false); @@ -172,6 +174,7 @@ public void cancelAllMasterPolls() { } public void clearReverseQueue(Session session) { + cancelPresentationFallback(session.getId()); ReverseQueue queue = reverseQueues.remove(session.getId()); if (queue != null) { synchronized (queue) { @@ -225,16 +228,96 @@ private void cancelMasterPoll(String sessionId) { } } + /** + * Start the player but keep the sender on {@code loading} until a frame is visible. + * Announcing {@code playing} at {@code pipeline.play()} makes the phone run ahead of the display. + */ + public void beginDisplayedPlayback(Session session) { + var hls = session.getHlsPlaylistState(); + if (hls == null) { + return; + } + hls.awaitPresentation(); + airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal()); + schedulePresentationFallback(session); + } + + /** Replacement picture after a seek. {@code playing} here carries the real clock. */ + public void onSeekDisplayed() { + for (Session session : sessionManager.allSessions()) { + log.info("Announcing playing (seek picture) session={}", session.getId()); + sendPlaybackStateEvent(session, "playing"); + } + } + + /** Player reported the first picture. Announce {@code playing} once. */ + public void onPlaybackPresented() { + for (Session session : sessionManager.allSessions()) { + announcePlayingIfPending(session, "picture"); + } + } + + private void schedulePresentationFallback(Session session) { + cancelPresentationFallback(session.getId()); + String sessionId = session.getId(); + ScheduledFuture future = scheduler.schedule(() -> { + presentationFallbacks.remove(sessionId); + Session current = sessionManager.getSession(sessionId); + if (current == null) { + return; + } + announcePlayingIfPending(current, "fallback"); + }, PRESENTATION_FALLBACK_MS, TimeUnit.MILLISECONDS); + presentationFallbacks.put(sessionId, future); + } + + private void announcePlayingIfPending(Session session, String reason) { + var hls = session.getHlsPlaylistState(); + if (hls == null || !hls.claimPresentation()) { + return; + } + cancelPresentationFallback(session.getId()); + log.info("Announcing playing ({}) session={}", reason, session.getId()); + sendPlaybackStateEvent(session, "playing"); + } + + private void cancelPresentationFallback(String sessionId) { + ScheduledFuture future = presentationFallbacks.remove(sessionId); + if (future != null) { + future.cancel(false); + } + } + public void sendPlaybackStateEvent(Session session, String state) { var hls = session.getHlsPlaylistState(); + Playback.Info playback = null; + if ("playing".equals(state)) { + Playback.Info info = airPlayConsumer.info(); + double duration = info.duration(); + double position = info.position(); + if (hls != null && hls.getMediaDurationSeconds() > 0) { + duration = hls.getMediaDurationSeconds(); + } + if (duration > 0) { + position = Math.min(Math.max(0, position), duration); + // The event says playing, so the clock rate is 1 even if the player + // was still paused when this sample was taken. + playback = new Playback.Info(duration, position, 1); + } + } byte[] body; if (hls != null) { body = PropertyListUtil.preparePlaybackStateEvent( - state, hls.getReverseEventSessionId(), hls.getItemUuid(), null); + state, hls.getReverseEventSessionId(), hls.getItemUuid(), null, playback); } else { body = PropertyListUtil.preparePlaybackStateEvent(state); } - log.info("Playback state event: {} session={}", state, session.getId()); + if (playback != null) { + log.info("Playback state event: {} position={} rate={} session={}", + state, playback.position(), playback.rate(), session.getId()); + } else { + log.info("Playback state event: {} session={}", state, session.getId()); + } enqueueReverseEvent(session, body); } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java index 3955f9e..e6e73fb 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @Getter @@ -67,6 +68,8 @@ public class HlsPlaylistState { * Event latch (not a timer): scrub brackets with rate=0 then rate=1. */ private volatile boolean ignorePauseUntilRateOne; + /** True from pipeline start until the first displayed frame is announced. */ + private final AtomicBoolean presentationPending = new AtomicBoolean(); public HlsPlaylistState(String remoteMasterUri, String playlistUriLocal) { this.remoteMasterUri = remoteMasterUri; @@ -75,6 +78,21 @@ public HlsPlaylistState(String remoteMasterUri, String playlistUriLocal) { this.reverseEventSessionId = NEXT_REVERSE_SESSION_ID.getAndAdd(2); } + /** Pipeline is up; do not tell the sender {@code playing} until a frame is visible. */ + public void awaitPresentation() { + presentationPending.set(true); + } + + /** @return true the first time a visible frame (or the fallback) may be announced */ + public boolean claimPresentation() { + return presentationPending.compareAndSet(true, false); + } + + /** Sender must stay on {@code loading} until {@link #claimPresentation()} succeeds. */ + public boolean isAwaitingPresentation() { + return presentationPending.get(); + } + /** Arm after {@code /scrub}: drop rate=0 until the client sends rate=1. */ public void markScrubIgnorePauseUntilPlay() { ignorePauseUntilRateOne = true; diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java index 858bd07..778114f 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java @@ -51,8 +51,9 @@ public static String rewritePlaylist(String playlist, String baseUrl, String ses * HLS consumer cannot switch video codecs mid-playlist. *

* Drops subtitles and every audio rendition except one default per {@code GROUP-ID} - * referenced by a remaining variant ({@code DEFAULT=YES}, else the first). Extra - * languages make the consumer probe dozens of playlists before the first video frame. + * referenced by the remaining variant ({@code DEFAULT=YES}, else the first). Keeps a + * single AVC video variant (highest bandwidth). Extra renditions make ffplay probe + * every playlist before the first frame — about five seconds on a full YouTube master. */ public static String preferAvcVariants(String masterPlaylist) { String[] lines = masterPlaylist.split("\\R", -1); @@ -89,6 +90,21 @@ public static String preferAvcVariants(String masterPlaylist) { if (variants.isEmpty()) { return masterPlaylist; } + int best = 0; + long bestBandwidth = -1; + for (int i = 0; i < variants.size(); i += 2) { + long bandwidth = extractAttr(variants.get(i), "BANDWIDTH") + .map(HlsUriRewrite::parseLong) + .orElse(0L); + if (bandwidth >= bestBandwidth) { + bestBandwidth = bandwidth; + best = i; + } + } + String chosenInfo = variants.get(best); + String chosenUri = variants.get(best + 1); + audioGroups.clear(); + extractAttr(chosenInfo, "AUDIO").ifPresent(audioGroups::add); StringBuilder out = new StringBuilder(); for (String header : headers) { @@ -100,12 +116,19 @@ public static String preferAvcVariants(String masterPlaylist) { for (String media : defaultAudioOnly(audioMedia, audioGroups)) { out.append(media).append('\n'); } - for (String variantLine : variants) { - out.append(variantLine).append('\n'); - } + out.append(chosenInfo).append('\n'); + out.append(chosenUri).append('\n'); return out.toString(); } + private static long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return 0L; + } + } + /** One audio rendition per referenced group: {@code DEFAULT=YES}, otherwise the first. */ private static List defaultAudioOnly(List audioMedia, Set audioGroups) { java.util.Map chosen = new java.util.LinkedHashMap<>(); diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java index 3defd51..b6dac55 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java @@ -126,11 +126,18 @@ public static byte[] preparePlaybackInfoResponse(Playback.Info playbackInfo) { // user pause; YouTube then stuck until Skip (ControlHandler pins rate=1 while waiting // for playlistRemove — dump 20260916-164913 kept rate=1 through the gap). // - // Buffer flags match known receiver /playback-info templates (and - // reverse_engineering/get_playback_info_response.txt): empty=true, full=false, - // keepUp=true while readyToPlay. Flipping empty/full with hasDuration diverged from - // that and is a candidate cause of VOD ad EOS hangs. - if (hasDuration) { + // While the clock is moving, the buffer is full. A poll that says empty=true after + // the sender's own rate=0 freezes its timeline even when position and rate advance + // (session 20260928-135155: position 40→273, rate=1, UI stayed paused). + // At the end of the item keep empty=true / full=false: that is the end pin, and + // reporting a full buffer there hung VOD ad removal. + boolean atEnd = hasDuration && position + 0.25 >= duration; + if (hasDuration && rate > 0 && !atEnd) { + response.put("playbackBufferEmpty", false); + response.put("playbackBufferFull", true); + response.put("playbackLikelyToKeepUp", true); + response.put("readyToPlay", true); + } else if (hasDuration) { response.put("playbackBufferEmpty", true); response.put("playbackBufferFull", false); response.put("playbackLikelyToKeepUp", true); @@ -167,6 +174,21 @@ public static byte[] preparePlaybackStateEvent(String state) { */ public static byte[] preparePlaybackStateEvent(String state, int reverseSessionId, String itemUuid, String reason) { + return preparePlaybackStateEvent(state, reverseSessionId, itemUuid, reason, null); + } + + /** + * Same as {@link #preparePlaybackStateEvent(String, int, String, String)} plus the + * clock fields when {@code playback} has a known duration. + * + *

Those fields belong under {@code params}, next to the item uuid. A bare + * {@code state=playing}, or the same keys at the top of the event, leaves the sender + * on the pause UI it entered with {@code rate=0}. The reverse playing event reports + * the buffer as full; {@code GET /playback-info} keeps its own flag template. + */ + public static byte[] preparePlaybackStateEvent(String state, int reverseSessionId, + String itemUuid, String reason, + Playback.Info playback) { NSDictionary event = new NSDictionary(); event.put("category", "video"); event.put("sessionID", reverseSessionId); @@ -174,14 +196,41 @@ public static byte[] preparePlaybackStateEvent(String state, int reverseSessionI if (reason != null) { event.put("reason", reason); } + NSDictionary params = new NSDictionary(); + boolean hasParams = false; if (itemUuid != null) { - NSDictionary params = new NSDictionary(); params.put("uuid", itemUuid); + hasParams = true; + } + if (playback != null && playback.duration() > 0 && "playing".equals(state)) { + double duration = playback.duration(); + double position = Math.max(0, Math.min(playback.position(), duration)); + params.put("duration", duration); + params.put("position", position); + // state=playing is the un-pause. rate=0 here keeps the sender paused. + params.put("rate", 1.0); + params.put("readyToPlay", true); + params.put("stallCount", 0); + params.put("playbackBufferEmpty", false); + params.put("playbackBufferFull", true); + params.put("playbackLikelyToKeepUp", true); + params.put("loadedTimeRanges", new NSArray(timeRange(duration, 0))); + params.put("seekableTimeRanges", new NSArray(timeRange(duration, 0))); + hasParams = true; + } + if (hasParams) { event.put("params", params); } return event.toXMLPropertyList().getBytes(StandardCharsets.UTF_8); } + private static NSDictionary timeRange(double duration, double start) { + NSDictionary range = new NSDictionary(); + range.put("duration", duration); + range.put("start", start); + return range; + } + /** * Typed video event ({@code itemPlayedToEnd}, etc.) with top-level {@code uuid}. */ diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java index ff29ec6..b59d530 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java @@ -180,4 +180,25 @@ public void sendVodEosEventBurst( assertTrue(xmls.get(4).contains("currentItemChanged")); recording.cancelAllMasterPolls(); } + + @Test + void playingIsAnnouncedOnlyAfterThePicture() { + var session = sessions.getSession("show"); + var hls = new HlsPlaylistState( + "mlhls://localhost/master.m3u8", + "http://127.0.0.1/playlist/master.m3u8?session=show"); + session.setHlsPlaylistState(hls); + + service.beginDisplayedPlayback(session); + + assertEquals(List.of("http://127.0.0.1/playlist/master.m3u8?session=show"), playlists); + assertTrue(reverseBodies.stream().noneMatch(body -> body.contains("playing"))); + + service.onPlaybackPresented(); + assertTrue(reverseBodies.stream().anyMatch(body -> body.contains("state:playing"))); + + reverseBodies.clear(); + service.onPlaybackPresented(); + assertTrue(reverseBodies.isEmpty()); + } } diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java index d6d4959..8f28e71 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java @@ -80,6 +80,27 @@ void preferAvcVariantsKeepsOnlyDefaultAudioPerGroup() { assertEquals(1, filtered.split("#EXT-X-MEDIA:", -1).length - 1); } + @Test + void preferAvcVariantsKeepsOnlyTheHighestBandwidthVideo() { + String master = """ + #EXTM3U + #EXT-X-MEDIA:URI="http://localhost/a.m3u8",TYPE=AUDIO,GROUP-ID="234",DEFAULT=YES + #EXT-X-STREAM-INF:BANDWIDTH=500000,CODECS="avc1.4D401E,mp4a.40.2",AUDIO="234" + http://localhost/low.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="avc1.640028,mp4a.40.2",AUDIO="234" + http://localhost/high.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=1200000,CODECS="avc1.4D401F,mp4a.40.2",AUDIO="234" + http://localhost/mid.m3u8 + """; + + String filtered = HlsUriRewrite.preferAvcVariants(master); + + assertTrue(filtered.contains("high.m3u8")); + assertFalse(filtered.contains("low.m3u8")); + assertFalse(filtered.contains("mid.m3u8")); + assertEquals(1, filtered.split("#EXT-X-STREAM-INF:", -1).length - 1); + } + @Test void extractMediaUrisFromSampleMasterPlaylist() throws Exception { var listBase64 = "I0VYVE0zVQojRVhULVgtSU5ERVBFTkRFTlQtU0VHTUVOVFMKI0VYVC1YLU1FRElBOlVSST0ibWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMzMvbWVkaWFkYXRhLm0zdTgiLFRZUEU9QVVESU8sR1JPVVAtSUQ9IjIzMyIsREVGQVVMVD1ZRVMsQVVUT1NFTEVDVD1ZRVMsTkFNRT0iRGVmYXVsdCIKI0VYVC1YLU1FRElBOlVSST0ibWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMzQvbWVkaWFkYXRhLm0zdTgiLFRZUEU9QVVESU8sR1JPVVAtSUQ9IjIzNCIsREVGQVVMVD1ZRVMsQVVUT1NFTEVDVD1ZRVMsTkFNRT0iRGVmYXVsdCIKI0VYVC1YLVNUUkVBTS1JTkY6QkFORFdJRFRIPTEyMDk4NjIsQ09ERUNTPSJhdmMxLjRENDAxRSxtcDRhLjQwLjIiLFJFU09MVVRJT049NjQweDM2MCxBVURJTz0iMjM0IixGUkFNRS1SQVRFPTMwLFZJREVPLVJBTkdFPVNEUixDTE9TRUQtQ0FQVElPTlM9Tk9ORQptbGhsczovL2xvY2FsaG9zdC9pdGFnLzIzMC9tZWRpYWRhdGEubTN1OAojRVhULVgtU1RSRUFNLUlORjpCQU5EV0lEVEg9NTQ2MjM5LENPREVDUz0iYXZjMS40RDQwMTUsbXA0YS40MC41IixSRVNPTFVUSU9OPTQyNngyNDAsQVVESU89IjIzMyIsRlJBTUUtUkFURT0zMCxWSURFTy1SQU5HRT1TRFIsQ0xPU0VELUNBUFRJT05TPU5PTkUKbWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMjkvbWVkaWFkYXRhLm0zdTgKI0VYVC1YLVNUUkVBTS1JTkY6QkFORFdJRFRIPTYzMDIzOSxDT0RFQ1M9ImF2YzEuNEQ0MDE1LG1wNGEuNDAuMiIsUkVTT0xVVElPTj00MjZ4MjQwLEFVRElPPSIyMzQiLEZSQU1FLVJBVEU9MzAsVklERU8tUkFOR0U9U0RSLENMT1NFRC1DQVBUSU9OUz1OT05FCm1saGxzOi8vbG9jYWxob3N0L2l0YWcvMjI5L21lZGlhZGF0YS5tM3U4CiNFWFQtWC1TVFJFQU0tSU5GOkJBTkRXSURUSD0xNTY4NzI2LENPREVDUz0iYXZjMS40RDQwMUYsbXA0YS40MC4yIixSRVNPTFVUSU9OPTE5MjB4MTA4MCxBVURJTy0iMjM0IixGUkFNRS1SQVRFPTYwLFZJREVPLVJBTkdFPVNEUixDTE9TRUQtQ0FQVElPTlM9Tk9ORQptbGhsczovL2xvY2FsaG9zdC9pdGFnLzMxMS9tZWRpYWRhdGEubTN1OAo="; diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java index 35e2d4f..dc88a89 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java @@ -11,6 +11,26 @@ class PropertyListUtilPlaybackInfoTest { + @Test + void playingEventCarriesScrubPositionAndRate() throws Exception { + byte[] xml = PropertyListUtil.preparePlaybackStateEvent( + "playing", 4, "item-1", null, new Playback.Info(1434, 333.22, 1)); + NSDictionary dict = (NSDictionary) PropertyListParser.parse(xml); + assertEquals("playing", dict.get("state").toJavaObject()); + // Top-level clock fields are ignored; the sender reads params. + assertEquals(null, dict.get("position")); + assertEquals(null, dict.get("rate")); + NSDictionary params = (NSDictionary) dict.get("params"); + assertEquals("item-1", params.get("uuid").toJavaObject()); + assertEquals(333.22, ((Number) params.get("position").toJavaObject()).doubleValue(), 0.001); + assertEquals(1434.0, ((Number) params.get("duration").toJavaObject()).doubleValue(), 0.001); + assertEquals(1.0, ((Number) params.get("rate").toJavaObject()).doubleValue(), 0.001); + assertTrue((Boolean) params.get("readyToPlay").toJavaObject()); + assertFalse((Boolean) params.get("playbackBufferEmpty").toJavaObject()); + assertTrue((Boolean) params.get("playbackBufferFull").toJavaObject()); + assertTrue((Boolean) params.get("playbackLikelyToKeepUp").toJavaObject()); + } + @Test void readyToPlayWhenDurationKnown() throws Exception { byte[] xml = PropertyListUtil.preparePlaybackInfoResponse(new Playback.Info(120, 15, 1)); @@ -19,8 +39,8 @@ void readyToPlayWhenDurationKnown() throws Exception { assertEquals(15.0, ((Number) dict.get("position").toJavaObject()).doubleValue(), 0.001); assertEquals(1, ((Number) dict.get("rate").toJavaObject()).intValue()); assertTrue((Boolean) dict.get("readyToPlay").toJavaObject()); - assertTrue((Boolean) dict.get("playbackBufferEmpty").toJavaObject()); - assertFalse((Boolean) dict.get("playbackBufferFull").toJavaObject()); + assertFalse((Boolean) dict.get("playbackBufferEmpty").toJavaObject()); + assertTrue((Boolean) dict.get("playbackBufferFull").toJavaObject()); assertTrue((Boolean) dict.get("playbackLikelyToKeepUp").toJavaObject()); } @@ -31,6 +51,8 @@ void keepsCallerRateAtEnd() throws Exception { NSDictionary dict = (NSDictionary) PropertyListParser.parse(xml); assertEquals(1, ((Number) dict.get("rate").toJavaObject()).intValue()); assertEquals(6.0, ((Number) dict.get("position").toJavaObject()).doubleValue(), 0.001); + assertTrue((Boolean) dict.get("playbackBufferEmpty").toJavaObject()); + assertFalse((Boolean) dict.get("playbackBufferFull").toJavaObject()); } @Test From d48e3d054a368ae860c2fe4a1b8399c292726f74 Mon Sep 17 00:00:00 2001 From: Sergio Fedorini Date: Fri, 2 Oct 2026 13:16:19 +0300 Subject: [PATCH 3/6] Commit a pause from the playback-info after rate=0, and send playing again when the seek picture is up. Scrub no longer waits on a timer, reverse events use the item uuid, and OPTIONS/ANNOUNCE keeps a speaker session open. --- .../player/ffmpeg/FfmpegHlsPipeline.java | 42 +++-- .../player/gstreamer/GstHlsPipeline.java | 26 +++ .../airplay/player/gstreamer/GstPlayer.java | 1 + .../airplay/player/gstreamer/ScrubClock.java | 15 +- .../server/internal/ControlServer.java | 2 +- .../handler/control/ControlHandler.java | 177 ++++++++---------- .../handler/control/HlsFcupService.java | 99 +++++++++- .../handler/session/HlsPlaylistState.java | 53 ++++-- .../handler/util/PropertyListUtil.java | 23 +-- .../handler/control/HlsFcupServiceTest.java | 21 +++ .../handler/session/HlsPlaylistStateTest.java | 34 +++- 11 files changed, 327 insertions(+), 166 deletions(-) diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java index de07896..83ee6f6 100644 --- a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java +++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipeline.java @@ -3,6 +3,14 @@ import com.github.serezhka.airplay.player.support.NativeProcessLog; import lombok.extern.slf4j.Slf4j; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -47,7 +55,7 @@ final class FfmpegHlsPipeline { private volatile Process ffplayProcess; /** Frozen window kept on screen until the seek replacement has a picture. */ private volatile Process heldFrame; - private volatile java.util.concurrent.atomic.AtomicBoolean incomingPicture = new java.util.concurrent.atomic.AtomicBoolean(); + private volatile AtomicBoolean incomingPicture = new AtomicBoolean(); void start(String playlistUri, double volume) { start(playlistUri, volume, 0); @@ -334,7 +342,7 @@ private void runPlayback(long myEpoch) { } } } - java.util.concurrent.atomic.AtomicBoolean picture; + AtomicBoolean picture; try { process = startFfplay(playlistUri, startAt, volumeLinear); picture = incomingPicture; @@ -515,7 +523,7 @@ private Process startFfplay(String playlistUri, double startAt, double volume) t } pb.redirectErrorStream(true); pb.redirectOutput(ProcessBuilder.Redirect.PIPE); - java.util.concurrent.atomic.AtomicBoolean picture = new java.util.concurrent.atomic.AtomicBoolean(); + AtomicBoolean picture = new AtomicBoolean(); incomingPicture = picture; Process process = pb.start(); Thread pump = new Thread(() -> pumpFfplayLog(process.getInputStream(), picture), "ffmpeg-hls-log"); @@ -575,7 +583,7 @@ private boolean signalFfplay(String signal) { private static final long HELD_FRAME_TIMEOUT_MS = 12_000; - private boolean waitForPicture(java.util.concurrent.atomic.AtomicBoolean picture, Process process, long myEpoch) { + private boolean waitForPicture(AtomicBoolean picture, Process process, long myEpoch) { long deadline = System.nanoTime() + HELD_FRAME_TIMEOUT_MS * 1_000_000L; while (System.nanoTime() < deadline) { if (picture != null && picture.get()) { @@ -595,29 +603,29 @@ private boolean waitForPicture(java.util.concurrent.atomic.AtomicBoolean picture return picture != null && picture.get(); } - private void pumpFfplayLog(java.io.InputStream in, java.util.concurrent.atomic.AtomicBoolean picture) { - java.nio.file.Path logFile = NativeProcessLog.playerLogFile("ffmpeg"); + private void pumpFfplayLog(InputStream in, AtomicBoolean picture) { + Path logFile = NativeProcessLog.playerLogFile("ffmpeg"); try { - java.nio.file.Path parent = logFile.getParent(); + Path parent = logFile.getParent(); if (parent != null) { - java.nio.file.Files.createDirectories(parent); + Files.createDirectories(parent); } - } catch (java.io.IOException e) { + } catch (IOException e) { log.debug("HLS log dir: {}", e.toString()); } - try (java.io.InputStream input = in; - java.io.OutputStream out = java.nio.file.Files.newOutputStream( + try (InputStream input = in; + OutputStream out = Files.newOutputStream( logFile, - java.nio.file.StandardOpenOption.CREATE, - java.nio.file.StandardOpenOption.APPEND)) { + StandardOpenOption.CREATE, + StandardOpenOption.APPEND)) { byte[] buf = new byte[2048]; - java.io.ByteArrayOutputStream line = new java.io.ByteArrayOutputStream(); + ByteArrayOutputStream line = new ByteArrayOutputStream(); int n; while ((n = input.read(buf)) >= 0) { for (int i = 0; i < n; i++) { byte b = buf[i]; if (b == '\r' || b == '\n') { - String text = line.toString(java.nio.charset.StandardCharsets.UTF_8).replace("\u001b[2K", ""); + String text = line.toString(StandardCharsets.UTF_8).replace("\u001b[2K", ""); line.reset(); if (text.isBlank()) { continue; @@ -630,14 +638,14 @@ private void pumpFfplayLog(java.io.InputStream in, java.util.concurrent.atomic.A } continue; } - out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + out.write(text.getBytes(StandardCharsets.UTF_8)); out.write('\n'); } else { line.write(b); } } } - } catch (java.io.IOException e) { + } catch (IOException e) { log.debug("HLS ffplay log pump ended: {}", e.toString()); } } diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java index 218e3ea..019e94f 100644 --- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java @@ -43,6 +43,9 @@ final class GstHlsPipeline { private volatile Runnable onEnded = () -> {}; private volatile Runnable onPresented = () -> {}; + private volatile Runnable onSeekDisplayed = () -> {}; + /** Set when a user seek is accepted; cleared on the following ASYNC_DONE. */ + private volatile boolean seekPicturePending; private final AtomicBoolean presented = new AtomicBoolean(); private Pipeline pipeline; private Element videoSink; @@ -129,6 +132,7 @@ void start(String playlistUri, double volumeLinear) { pipeline.getBus().connect((Bus.ASYNC_DONE) source -> { tryPendingSeek("async-done"); ensurePlaying("async-done"); + noteSeekPicture(); }); pipeline.getBus().connect((Bus.DURATION_CHANGED) source -> tryPendingSeek("duration")); pipeline.getBus().connect((Bus.STATE_CHANGED) (source, old, current, pending) -> { @@ -160,6 +164,7 @@ void stop() { mediaClockTrusted = false; scrubClock.clear(); presented.set(false); + seekPicturePending = false; uri = null; if (pipeline != null) { try { @@ -382,6 +387,10 @@ void setOnPresented(Runnable onPresented) { this.onPresented = onPresented == null ? () -> {} : onPresented; } + void setOnSeekDisplayed(Runnable onSeekDisplayed) { + this.onSeekDisplayed = onSeekDisplayed == null ? () -> {} : onSeekDisplayed; + } + /** First buffer on the sink is the frame that is about to be shown. */ private void watchFirstPicture(Element sink) { if (sink == null) { @@ -410,6 +419,20 @@ private void notePresented() { } } + /** New picture after a user seek. Sends {@code playing} with the scrub clock. */ + private void noteSeekPicture() { + if (!seekPicturePending || uri == null) { + return; + } + seekPicturePending = false; + log.info("HLS seek picture at {}s", currentPositionSeconds()); + try { + onSeekDisplayed.run(); + } catch (RuntimeException e) { + log.warn("HLS seek picture callback failed: {}", e.toString()); + } + } + private void cancelEndWatch() { ScheduledFuture future = endWatch; endWatch = null; @@ -460,6 +483,9 @@ private void tryPendingSeek(String reason) { if (!paused) { pipe.play(); } + if ("request".equals(reason) || reason.startsWith("retry-")) { + seekPicturePending = true; + } return; } int attempt = seekAttempts.incrementAndGet(); diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java index 58c8e17..f201cad 100644 --- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java @@ -78,6 +78,7 @@ public GstPlayer(int fps) { log.info("GStreamer debug log: {}", NativeProcessLog.playerLogFile("gstreamer")); hls.setOnEnded(() -> observer.onEnded()); hls.setOnPresented(() -> observer.onPresented()); + hls.setOnSeekDisplayed(() -> observer.onSeekDisplayed()); int framerate = Math.max(1, fps); useD3d11 = GstVideoSinkFactory.hasD3d11(); boolean useXimage = GstVideoSinkFactory.hasXimage(); diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java index ffd9b26..5e4c8f2 100644 --- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java @@ -1,14 +1,8 @@ package com.github.serezhka.airplay.player.gstreamer; /** - * After a seek the demux clock can snap to an earlier keyframe. Publishing that - * walks /playback-info backward, and the sender freezes its timeline. - * - *

The first sample after a seek often equals the scrub target (the wall clock - * we already know). That must not drop the guard: the next sample is the keyframe - * a couple of seconds earlier. While the guard is up the reported position stays - * on the scrub clock and keeps advancing. A new {@link #arm(double)} allows a - * backward scrub. + * After a seek the demux clock can snap to an earlier keyframe. Hold the scrub + * target (and keep advancing) until the demux clock catches up beside it. */ final class ScrubClock { @@ -28,13 +22,8 @@ void clear() { armedAt = 0; } - /** - * @param clockSeconds demux position, which right after a seek may still be the wall clock - * @param wallSeconds scrub target plus time since the seek - */ double report(double clockSeconds, double wallSeconds) { if (hold) { - // Demux has moved past the scrub point and sits beside the scrub clock. boolean caughtUp = clockSeconds >= armedAt + 0.5 && Math.abs(clockSeconds - wallSeconds) <= 0.25; if (caughtUp) { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java index b688270..1e8cbd4 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java @@ -87,7 +87,7 @@ public void run() { serverBootstrap .group(bossGroup, workerGroup) .channel(serverSocketChannelClass()) - .localAddress(new InetSocketAddress(0)) // bind random port + .localAddress(new InetSocketAddress(0)) .childHandler(new ChannelInitializer() { @Override public void initChannel(final SocketChannel ch) { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java index fd73b0f..3c3dd4e 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java @@ -62,19 +62,7 @@ public class ControlHandler extends ChannelInboundHandlerAdapter { t.setDaemon(true); return t; }); - /** YouTube sometimes sends another rate=0 just after the rate=1 that ends a scrub. */ - private static final long POST_SCRUB_RATE0_GRACE_NS = 2_000_000_000L; - /** - * rate=0 is also how a scrub starts (finger down). /scrub can follow several - * seconds later (seen at 5.4s). Publishing rate=0 or pausing the picture before - * that lights the phone pause UI. Wait this long, and cancel if /scrub arrives. - */ - private static final long RATE_ZERO_DELAY_MS = 8_000; - private final ConcurrentHashMap> pendingPlaybackStarts = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> pendingRateZeros = new ConcurrentHashMap<>(); - private final ConcurrentHashMap rateZeroTokens = new ConcurrentHashMap<>(); - private final ConcurrentHashMap suppressPauseUntilNanos = new ConcurrentHashMap<>(); public ControlHandler(SessionManager sessionManager, HlsFcupService hlsFcupService, @@ -102,7 +90,6 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { private void stopHlsPlayback(Session session) { cancelPlaybackStartFallback(session.getId()); - cancelReportedPause(session.getId()); hlsFcupService.cancelAllMasterPolls(); hlsFcupService.clearReverseQueue(session); session.setHlsPlaylistState(null); @@ -158,6 +145,10 @@ private void dispatchControlRequest(ChannelHandlerContext ctx, FullHttpRequest r handleRtspTeardown(ctx, request); } else if (HttpMethod.POST.equals(request.method()) && request.uri().equals("/audioMode")) { handleRtspAudioMode(ctx, request); + } else if (RtspMethods.OPTIONS.equals(request.method())) { + handleRtspOptions(ctx, request); + } else if (RtspMethods.ANNOUNCE.equals(request.method())) { + handleRtspAnnounce(ctx, request); } else { log.error("Unknown control request: {} {} {}", request.protocolVersion(), request.method(), request.uri()); var response = createRtspResponse(request); @@ -281,6 +272,27 @@ private void handleRtspFeedback(ChannelHandlerContext ctx, FullHttpRequest reque sendResponse(ctx, request, response); } + /** + * First probe when the sender treats the receiver as a speaker ({@code OPTIONS *}). + * {@code Public} lists the methods this connection accepts. + */ + private void handleRtspOptions(ChannelHandlerContext ctx, FullHttpRequest request) { + log.info("RTSP OPTIONS {}", request.uri()); + var response = createRtspResponse(request); + response.headers().add(RtspHeaderNames.PUBLIC, + "ANNOUNCE, SETUP, RECORD, FLUSH, TEARDOWN, OPTIONS, GET_PARAMETER, SET_PARAMETER, POST, GET"); + response.headers().add("Audio-Jack-Status", "connected; type=analog"); + sendResponse(ctx, request, response); + } + + /** Speaker setup follows OPTIONS with an SDP body. Accept it and keep the session open. */ + private void handleRtspAnnounce(ChannelHandlerContext ctx, FullHttpRequest request) { + String body = request.content().toString(StandardCharsets.US_ASCII).replaceAll("\\s+", " ").trim(); + log.info("RTSP ANNOUNCE {}", body.length() > 400 ? body.substring(0, 400) : body); + var response = createRtspResponse(request); + sendResponse(ctx, request, response); + } + private void handleRtspGetParameter(ChannelHandlerContext ctx, FullHttpRequest request) { byte[] content = AirPlayVolume.formatRtspParameter(airPlayConsumer.volume()).getBytes(StandardCharsets.US_ASCII); var response = createRtspResponse(request); @@ -413,7 +425,8 @@ private void handlePlay(ChannelHandlerContext ctx, FullHttpRequest request) thro if (playlistUri != null && !playlistUri.isBlank()) { var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); sendResponse(ctx, request, response); - startMediaPlaylist(ctx, resolveSession(request), playlistUri, clientProcName, startPositionSeconds); + String itemUuid = play.get("uuid") != null ? play.get("uuid").toJavaObject(String.class) : null; + startMediaPlaylist(ctx, resolveSession(request), playlistUri, clientProcName, startPositionSeconds, itemUuid); } else { log.error("Client proc name [{}] has no Content-Location playlist", clientProcName); var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_IMPLEMENTED); @@ -426,7 +439,7 @@ private void handlePlay(ChannelHandlerContext ctx, FullHttpRequest request) thro * Used by POST /play and by playlistInsert (e.g. quality switch). */ private void startMediaPlaylist(ChannelHandlerContext ctx, Session session, String playlistUri, - String clientProcName, Double startPositionSeconds) { + String clientProcName, Double startPositionSeconds, String clientItemUuid) { // Clients often keep the mirror stream up when starting YouTube HLS; drop it so we // do not keep rendering a stale mirrored UI beside the media player. stopMirrorVideoIfRunning(session); @@ -436,9 +449,10 @@ private void startMediaPlaylist(ChannelHandlerContext ctx, Session session, Stri if (remotePlaylistUri.contains("master.m3u8")) { hlsFcupService.cancelAllMasterPolls(); - var hls = new HlsPlaylistState(remotePlaylistUri, playlistUriLocal); + var hls = new HlsPlaylistState(remotePlaylistUri, playlistUriLocal, clientItemUuid); hls.setPlaybackRate(1); session.setHlsPlaylistState(hls); + log.info("HLS item uuid={} session={}", hls.getItemUuid(), session.getId()); if (startPositionSeconds != null && startPositionSeconds > 0) { hls.setPendingSeekSeconds(startPositionSeconds); } @@ -455,12 +469,18 @@ private void startMediaPlaylist(ChannelHandlerContext ctx, Session session, Stri } private void stopMirrorVideoIfRunning(Session session) { + log.info("Stopping screen-mirror video before HLS session {}", session.getId()); + for (Session other : sessionManager.allSessions()) { + try { + other.getVideoServer().stop(); + } catch (Exception e) { + log.debug("Mirror video stop before HLS ignored: {}", e.toString()); + } + } try { - log.info("Stopping screen-mirror video before HLS session {}", session.getId()); airPlayConsumer.onVideoSrcDisconnect(); - session.getVideoServer().stop(); } catch (Exception e) { - log.debug("Mirror video stop before HLS ignored: {}", e.toString()); + log.debug("Mirror video source disconnect ignored: {}", e.toString()); } } @@ -510,35 +530,28 @@ private void handleRate(ChannelHandlerContext ctx, FullHttpRequest request) { } if (value == 0) { - Long suppressUntil = suppressPauseUntilNanos.get(session.getId()); - if (suppressUntil != null && System.nanoTime() < suppressUntil) { - log.info("Ignoring rate=0 shortly after scrub resume session={}", session.getId()); - } else if (hls != null && hls.shouldIgnorePause()) { - // /scrub arms a latch: drop rate=0 until the matching rate=1 (no timer). - log.info("Ignoring rate=0 until rate=1 after scrub session={}", session.getId()); - } else { - // Keep playing and keep reporting rate=1 until this is clearly a pause - // and not the start of a scrub. Do not emit reverse "paused" yet. - log.info("Holding rate=1 for rate=0 session={}", session.getId()); - scheduleReportedPause(session); + if (hls != null && hls.isScrubGesture()) { + // Tail of the scrub, before the seek-picture callback. The phone applies + // this rate=0 locally; playing has to follow it. + log.info("rate=0 during scrub gesture session={}", session.getId()); + reannounceAfterScrubRateZero(session); + } else if (hls != null) { + hls.armRateZero(); + log.info("rate=0 armed; pause waits for the following playback-info session={}", + session.getId()); } } else { - boolean heldForScrub = cancelReportedPause(session.getId()); - boolean scrubResume = hls != null && hls.shouldIgnorePause(); - boolean wasPaused = heldForScrub || (hls != null && hls.getPlaybackRate() <= 0); + boolean wasPaused = hls != null && hls.getPlaybackRate() <= 0; if (hls != null) { - hls.clearScrubIgnorePause(); + hls.clearRateZero(); + hls.endScrubGesture(); hls.setPlaybackRate(1); } - if (scrubResume) { - suppressPauseUntilNanos.put(session.getId(), System.nanoTime() + POST_SCRUB_RATE0_GRACE_NS); - } - // Only resume when leaving pause — rate=1 while already playing must not re-seek. + // Only resume when leaving a real pause — rate=1 while already playing must not re-seek. if (wasPaused) { airPlayConsumer.onResume(); } - // rate=1 arrives before the first frame. Announcing playing here starts the - // sender clock while the display is still black (ffplay probe is several seconds). + // rate=1 can arrive before the first frame; wait for the picture callback. if (hls == null || !hls.isAwaitingPresentation()) { hlsFcupService.sendPlaybackStateEvent(session, "playing"); } else { @@ -560,9 +573,8 @@ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) { var session = resolveSession(request); var hls = session.getHlsPlaylistState(); if (hls != null) { - cancelReportedPause(session.getId()); - // Latch: post-scrub rate=0 is bracket noise until rate=1. - hls.markScrubIgnorePauseUntilPlay(); + hls.clearRateZero(); + hls.beginScrubGesture(); hls.setPlaybackRate(1); } if (hls != null && !hls.isPlaybackStarted()) { @@ -570,8 +582,7 @@ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) { log.info("Deferring /scrub to pending seek until HLS starts"); } else { airPlayConsumer.onSeek(position); - // Seek acknowledgement, then playing with the new position in params. - // A lone playing event left the sender on the pause UI. + // loading then playing (clock under params) unsticks the pause UI. hlsFcupService.sendPlaybackStateEvent(session, "loading"); hlsFcupService.sendPlaybackStateEvent(session, "playing"); } @@ -595,6 +606,7 @@ private void handleStop(ChannelHandlerContext ctx, FullHttpRequest request) { private void handlePlaybackInfo(ChannelHandlerContext ctx, FullHttpRequest request) { var session = resolveSession(request); + commitPauseOnPlaybackInfo(session); var hls = session.getHlsPlaylistState(); var fromPlayer = airPlayConsumer.info(); double duration = fromPlayer.duration(); @@ -606,8 +618,6 @@ private void handlePlaybackInfo(ChannelHandlerContext ctx, FullHttpRequest reque position = 0; rate = 0; } else if (hls != null) { - // Live / sliding windows: duration unknown. Finite ENDLIST VOD is authoritative. - // Consumer clocks alone can report multi-hour values for short ads. if (hls.isLivePlaylist()) { duration = 0; } else { @@ -615,15 +625,13 @@ private void handlePlaybackInfo(ChannelHandlerContext ctx, FullHttpRequest reque if (playlistDur > 0) { duration = playlistDur; } else if (duration > 600) { + // Demux sometimes reports multi-hour clocks for short items. duration = 0; } } if (hls.isWaitingForMasterChange() && duration > 0) { - // VOD ad finished: pin clock at end. Keep rate=1 (dump 20260916-164913 - // advanced with duration>0 rate=1). rate=0 here looks like a user pause and - // blocks playlistRemove until Skip. Never emit "paused" / duration=0. - // Prefer the player/EOS length when session mediaDuration was inflated by a - // later FCUP body (seen: 7.04s ad reported as 15.6 → no playlistRemove). + // VOD end pin: stay at D/D/rate=1. Prefer the shorter player length if + // a later FCUP body inflated mediaDurationSeconds. double playerDur = fromPlayer.duration(); if (playerDur > 0.5 && playerDur + 0.25 < duration) { duration = playerDur; @@ -634,9 +642,6 @@ private void handlePlaybackInfo(ChannelHandlerContext ctx, FullHttpRequest reque if (duration > 0) { position = Math.min(position, duration); } - // Prefer session rate from POST /rate. Player rate stays 0 while MSE/hls.js - // buffers — OR-ing it forced rate=0 with duration=6 at t≈0 (dump - // 20260918-080456) → phone pause UI → rate 1/0 fight → debounced "paused". rate = hls.getPlaybackRate() <= 0 ? 0 : 1; } } else if (duration > 600) { @@ -680,8 +685,9 @@ private void handlePlaylistInsert(ChannelHandlerContext ctx, FullHttpRequest req var clientProcName = item.get("clientProcName") != null ? item.get("clientProcName").toJavaObject(String.class) : ""; + String itemUuid = item.get("uuid") != null ? item.get("uuid").toJavaObject(String.class) : null; log.info("playlistInsert Content-Location={} from [{}]", playlistUri, clientProcName); - startMediaPlaylist(ctx, resolveSession(request), playlistUri, clientProcName, null); + startMediaPlaylist(ctx, resolveSession(request), playlistUri, clientProcName, null, itemUuid); } private void handleGetProperty(ChannelHandlerContext ctx, FullHttpRequest request) { @@ -835,47 +841,30 @@ private void schedulePlaybackStartFallback(Session session, HlsPlaylistState hls pendingPlaybackStarts.put(sessionId, future); } - private void scheduleReportedPause(Session session) { - String sessionId = session.getId(); - long token = System.nanoTime(); - rateZeroTokens.put(sessionId, token); - ScheduledFuture previous = pendingRateZeros.remove(sessionId); - if (previous != null) { - previous.cancel(false); - } - ScheduledFuture future = hlsScheduler.schedule(() -> { - pendingRateZeros.remove(sessionId); - if (!Long.valueOf(token).equals(rateZeroTokens.get(sessionId))) { - return; - } - Session current = sessionManager.getSession(sessionId); - if (current == null) { - return; - } - var hls = current.getHlsPlaylistState(); - if (hls == null || hls.shouldIgnorePause() || hls.isWaitingForMasterChange()) { - return; - } - if (!Long.valueOf(token).equals(rateZeroTokens.get(sessionId))) { - return; - } - hls.setPlaybackRate(0); - airPlayConsumer.onPause(); - hlsFcupService.sendPlaybackStateEvent(current, "paused"); - log.info("Reporting pause after rate=0 with no scrub session={}", sessionId); - }, RATE_ZERO_DELAY_MS, TimeUnit.MILLISECONDS); - pendingRateZeros.put(sessionId, future); + /** Follow a scrub's trailing {@code rate=0} so the phone does not stay on pause. */ + private void reannounceAfterScrubRateZero(Session session) { + hlsFcupService.sendPlaybackStateEvent(session, "loading"); + hlsFcupService.sendPlaybackStateEvent(session, "playing"); } - /** @return true when a not-yet-published pause was cancelled */ - private boolean cancelReportedPause(String sessionId) { - rateZeroTokens.remove(sessionId); - ScheduledFuture future = pendingRateZeros.remove(sessionId); - if (future == null) { - return false; + /** + * The playback-info already in flight with {@code rate=0} also arrives before {@code /scrub}. + * The following poll is the pause: a scrub would have cleared the flag by then. + */ + private void commitPauseOnPlaybackInfo(Session session) { + var hls = session.getHlsPlaylistState(); + if (hls == null || !hls.notePlaybackInfoForPause()) { + return; + } + if (hls.isWaitingForMasterChange()) { + hls.clearRateZero(); + return; } - future.cancel(false); - return true; + hls.clearRateZero(); + hls.setPlaybackRate(0); + airPlayConsumer.onPause(); + hlsFcupService.sendPlaybackStateEvent(session, "paused"); + log.info("Pause committed on playback-info session={}", session.getId()); } private void cancelPlaybackStartFallback(String sessionId) { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java index 9f5e44f..8224395 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java @@ -11,6 +11,7 @@ import io.netty.handler.codec.http.HttpVersion; import lombok.extern.slf4j.Slf4j; +import java.nio.charset.StandardCharsets; import java.util.ArrayDeque; import java.util.List; import java.util.Map; @@ -33,6 +34,12 @@ public class HlsFcupService { private static final int MEDIA_RESWEEP_EVERY_MASTER_POLLS = 3; /** If no frame arrives, announce anyway so the sender is not stuck on loading. */ private static final long PRESENTATION_FALLBACK_MS = 8_000; + /** + * Re-assert {@code playing} or {@code paused} on the reverse channel. The sender's + * play/pause icon follows these events; {@code /playback-info} only moves the timeline. + * Skipped while the channel is busy so FCUP and the VOD end burst are not queued behind it. + */ + private static final long STATE_HEARTBEAT_MS = 2_000; private final SessionManager sessionManager; private final Playback airPlayConsumer; @@ -45,10 +52,18 @@ public class HlsFcupService { private final Map> presentationFallbacks = new ConcurrentHashMap<>(); /** One in-flight reverse {@code POST /event} per session (no HTTP pipelining). */ private final Map reverseQueues = new ConcurrentHashMap<>(); + private final ScheduledFuture stateHeartbeat; public HlsFcupService(SessionManager sessionManager, Playback airPlayConsumer) { this.sessionManager = sessionManager; this.airPlayConsumer = airPlayConsumer; + this.stateHeartbeat = scheduler.scheduleAtFixedRate( + this::pushPlaybackStates, STATE_HEARTBEAT_MS, STATE_HEARTBEAT_MS, TimeUnit.MILLISECONDS); + } + + /** Stop the state re-assert. Master polls use the same executor and keep running. */ + public void stopStateHeartbeat() { + stateHeartbeat.cancel(false); } public void refreshActivePlaylists() { @@ -194,6 +209,8 @@ public void onReverseEventResponse(ChannelHandlerContext ctx) { } synchronized (queue) { queue.inFlight = false; + log.info("Reverse /event accepted, {} still queued session={}", + queue.pending.size(), session.getId()); flushReverseQueue(session, queue); } return; @@ -242,15 +259,20 @@ public void beginDisplayedPlayback(Session session) { schedulePresentationFallback(session); } - /** Replacement picture after a seek. {@code playing} here carries the real clock. */ + /** Replacement picture after a seek — refresh {@code playing} with the real clock. */ public void onSeekDisplayed() { for (Session session : sessionManager.allSessions()) { + var hls = session.getHlsPlaylistState(); + if (hls == null) { + continue; + } + hls.endScrubGesture(); log.info("Announcing playing (seek picture) session={}", session.getId()); sendPlaybackStateEvent(session, "playing"); } } - /** Player reported the first picture. Announce {@code playing} once. */ + /** First picture is on the display. Announce {@code playing} once. */ public void onPlaybackPresented() { for (Session session : sessionManager.allSessions()) { announcePlayingIfPending(session, "picture"); @@ -288,7 +310,71 @@ private void cancelPresentationFallback(String sessionId) { } } + /** + * State to re-assert, or {@code null} when the sender must not hear one + * (still loading the first frame, waiting for the next item, or at VOD end). + */ + String playbackStateToAnnounce(Session session) { + var hls = session.getHlsPlaylistState(); + if (hls == null || !hls.isPlaybackStarted() || hls.isAwaitingPresentation() + || hls.isWaitingForMasterChange()) { + return null; + } + if (hls.getPlaybackRate() <= 0) { + return "paused"; + } + if (!hls.isLivePlaylist()) { + Playback.Info info = airPlayConsumer.info(); + double duration = hls.getMediaDurationSeconds() > 0 + ? hls.getMediaDurationSeconds() + : info.duration(); + if (duration > 0 && info.position() + 0.25 >= duration) { + return null; + } + } + return "playing"; + } + + private void pushPlaybackStates() { + for (Session session : sessionManager.allSessions()) { + try { + String state = playbackStateToAnnounce(session); + if (state == null) { + continue; + } + offerPlaybackState(session, state); + } catch (RuntimeException e) { + log.debug("Playback state heartbeat failed session={}: {}", session.getId(), e.toString()); + } + } + } + + /** + * Enqueue only when the reverse channel is idle. Checked under the queue lock so a + * pause or end burst that arrived in the same moment is not followed by a stale + * {@code playing}. + */ + private void offerPlaybackState(Session session, String state) { + var eventContext = session.getReverseContexts().get("event"); + if (eventContext == null || !eventContext.channel().isActive()) { + return; + } + byte[] body = playbackStateBody(session, state); + ReverseQueue queue = reverseQueues.computeIfAbsent(session.getId(), id -> new ReverseQueue()); + synchronized (queue) { + if (queue.inFlight || !queue.pending.isEmpty()) { + return; + } + queue.pending.add(body); + flushReverseQueue(session, queue); + } + } + public void sendPlaybackStateEvent(Session session, String state) { + enqueueReverseEvent(session, playbackStateBody(session, state)); + } + + private byte[] playbackStateBody(Session session, String state) { var hls = session.getHlsPlaylistState(); Playback.Info playback = null; if ("playing".equals(state)) { @@ -312,13 +398,14 @@ public void sendPlaybackStateEvent(Session session, String state) { } else { body = PropertyListUtil.preparePlaybackStateEvent(state); } + String xml = new String(body, StandardCharsets.UTF_8).replaceAll("\\s+", " ").trim(); if (playback != null) { - log.info("Playback state event: {} position={} rate={} session={}", - state, playback.position(), playback.rate(), session.getId()); + log.info("Playback state event: {} position={} rate={} session={} xml={}", + state, playback.position(), playback.rate(), session.getId(), xml); } else { - log.info("Playback state event: {} session={}", state, session.getId()); + log.info("Playback state event: {} session={} xml={}", state, session.getId(), xml); } - enqueueReverseEvent(session, body); + return body; } /** diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java index e6e73fb..f83f05a 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java @@ -63,18 +63,31 @@ public class HlsPlaylistState { * YouTube sets pause (1) — on EOS we must pause and wait for the next {@code /play}. */ private volatile int actionAtItemEnd = 1; + /** {@code /scrub} until the seek-picture callback or the closing {@code rate=1}. */ + private volatile boolean scrubGesture; /** - * After {@code /scrub}, ignore {@code rate=0} until the next {@code rate=1}. - * Event latch (not a timer): scrub brackets with rate=0 then rate=1. + * {@code rate=0} not yet classified. The playback-info already queued with it does not + * decide; the next one does, unless {@code /scrub} cleared this first. */ - private volatile boolean ignorePauseUntilRateOne; + private volatile boolean rateZeroPending; + private int playbackInfosSinceRateZero; /** True from pipeline start until the first displayed frame is announced. */ private final AtomicBoolean presentationPending = new AtomicBoolean(); public HlsPlaylistState(String remoteMasterUri, String playlistUriLocal) { + this(remoteMasterUri, playlistUriLocal, null); + } + + /** + * @param clientItemUuid item {@code uuid} from {@code POST /play}; reverse {@code playing} + * must carry this value or the sender keeps the pause UI after a scrub + */ + public HlsPlaylistState(String remoteMasterUri, String playlistUriLocal, String clientItemUuid) { this.remoteMasterUri = remoteMasterUri; this.playlistUriLocal = playlistUriLocal; - this.itemUuid = UUID.randomUUID().toString().toUpperCase(); + this.itemUuid = clientItemUuid != null && !clientItemUuid.isBlank() + ? clientItemUuid + : UUID.randomUUID().toString().toUpperCase(); this.reverseEventSessionId = NEXT_REVERSE_SESSION_ID.getAndAdd(2); } @@ -93,17 +106,35 @@ public boolean isAwaitingPresentation() { return presentationPending.get(); } - /** Arm after {@code /scrub}: drop rate=0 until the client sends rate=1. */ - public void markScrubIgnorePauseUntilPlay() { - ignorePauseUntilRateOne = true; + public void beginScrubGesture() { + scrubGesture = true; + } + + public boolean isScrubGesture() { + return scrubGesture; } - public boolean shouldIgnorePause() { - return ignorePauseUntilRateOne; + public void endScrubGesture() { + scrubGesture = false; } - public void clearScrubIgnorePause() { - ignorePauseUntilRateOne = false; + public void armRateZero() { + rateZeroPending = true; + playbackInfosSinceRateZero = 0; + } + + public void clearRateZero() { + rateZeroPending = false; + playbackInfosSinceRateZero = 0; + } + + /** @return true when this poll is the one that commits a pause */ + public boolean notePlaybackInfoForPause() { + if (!rateZeroPending) { + return false; + } + playbackInfosSinceRateZero++; + return playbackInfosSinceRateZero >= 2; } public void setPendingSeekSeconds(Double pendingSeekSeconds) { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java index b6dac55..78e8942 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java @@ -122,15 +122,7 @@ public static byte[] preparePlaybackInfoResponse(Playback.Info playbackInfo) { loadedTimeRanges.put("start", 0.0); response.put("loadedTimeRanges", new NSArray(loadedTimeRanges)); boolean hasDuration = duration > 0; - // Do NOT force rate=0 when position≈duration. That made short VOD ads look like a - // user pause; YouTube then stuck until Skip (ControlHandler pins rate=1 while waiting - // for playlistRemove — dump 20260916-164913 kept rate=1 through the gap). - // - // While the clock is moving, the buffer is full. A poll that says empty=true after - // the sender's own rate=0 freezes its timeline even when position and rate advance - // (session 20260928-135155: position 40→273, rate=1, UI stayed paused). - // At the end of the item keep empty=true / full=false: that is the end pin, and - // reporting a full buffer there hung VOD ad removal. + // Moving clock → full buffer. At end keep empty so VOD ad removal can proceed. boolean atEnd = hasDuration && position + 0.25 >= duration; if (hasDuration && rate > 0 && !atEnd) { response.put("playbackBufferEmpty", false); @@ -154,7 +146,7 @@ public static byte[] preparePlaybackInfoResponse(Playback.Info playbackInfo) { seekableTimeRanges.put("duration", duration); seekableTimeRanges.put("start", 0.0); response.put("seekableTimeRanges", new NSArray(seekableTimeRanges)); - log.debug("Playback info: duration={}, position={}, rate={}", duration, position, rate); + log.info("Playback info: duration={} position={} rate={}", duration, position, rate); return response.toXMLPropertyList().getBytes(StandardCharsets.UTF_8); } @@ -178,13 +170,9 @@ public static byte[] preparePlaybackStateEvent(String state, int reverseSessionI } /** - * Same as {@link #preparePlaybackStateEvent(String, int, String, String)} plus the - * clock fields when {@code playback} has a known duration. - * - *

Those fields belong under {@code params}, next to the item uuid. A bare - * {@code state=playing}, or the same keys at the top of the event, leaves the sender - * on the pause UI it entered with {@code rate=0}. The reverse playing event reports - * the buffer as full; {@code GET /playback-info} keeps its own flag template. + * Reverse {@code POST /event} with {@code category=video} and a {@code state}. + * When {@code playback} is set for {@code playing}, clock fields go under {@code params} + * (top-level copies are ignored by the sender and leave the pause UI stuck). */ public static byte[] preparePlaybackStateEvent(String state, int reverseSessionId, String itemUuid, String reason, @@ -207,7 +195,6 @@ public static byte[] preparePlaybackStateEvent(String state, int reverseSessionI double position = Math.max(0, Math.min(playback.position(), duration)); params.put("duration", duration); params.put("position", position); - // state=playing is the un-pause. rate=0 here keeps the sender paused. params.put("rate", 1.0); params.put("readyToPlay", true); params.put("stallCount", 0); diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java index b59d530..f806d62 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java @@ -87,6 +87,7 @@ public void sendVodEosEventBurst( @AfterEach void tearDown() { service.cancelAllMasterPolls(); + service.stopStateHeartbeat(); } @Test @@ -179,6 +180,26 @@ public void sendVodEosEventBurst( assertTrue(xmls.get(3).contains("shape")); assertTrue(xmls.get(4).contains("currentItemChanged")); recording.cancelAllMasterPolls(); + recording.stopStateHeartbeat(); + } + + @Test + void heartbeatAnnouncesPlayingAndPausedButNotTheEndPin() { + var session = sessions.getSession("beat"); + var hls = new HlsPlaylistState( + "mlhls://localhost/master.m3u8", + "http://127.0.0.1/playlist/master.m3u8?session=beat"); + hls.markPlaybackStarted(); + session.setHlsPlaylistState(hls); + + assertEquals("playing", service.playbackStateToAnnounce(session)); + + hls.setPlaybackRate(0); + assertEquals("paused", service.playbackStateToAnnounce(session)); + + hls.setPlaybackRate(1); + hls.setWaitingForMasterChange(true); + assertEquals(null, service.playbackStateToAnnounce(session)); } @Test diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java index 95c0619..66a1b9d 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java @@ -34,6 +34,15 @@ void recordMasterIfChangedDetectsIdenticalAndChangedBodies() { assertTrue(hls.recordMasterIfChanged(masterV2)); } + @Test + void keepsClientItemUuid() { + var hls = new HlsPlaylistState( + "mlhls://localhost/master.m3u8", + "http://localhost/playlist/master.m3u8?session=x", + "65356B1D-FB60-41B8-AF29-5EB862925894"); + assertEquals("65356B1D-FB60-41B8-AF29-5EB862925894", hls.getItemUuid()); + } + @Test void storeMasterPlaylistUsesFilteredAvcOnlyUris() throws Exception { String mixed = """ @@ -64,13 +73,26 @@ void storeMasterPlaylistUsesFilteredAvcOnlyUris() throws Exception { } @Test - void scrubLatchIgnoresPauseUntilRateOne() { + void secondPlaybackInfoAfterRateZeroCommitsPause() { + var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x"); + hls.armRateZero(); + assertFalse(hls.notePlaybackInfoForPause()); + assertTrue(hls.notePlaybackInfoForPause()); + hls.clearRateZero(); + assertFalse(hls.notePlaybackInfoForPause()); + } + + @Test + void scrubClearsArmedRateZero() { var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x"); - assertFalse(hls.shouldIgnorePause()); - hls.markScrubIgnorePauseUntilPlay(); - assertTrue(hls.shouldIgnorePause()); - hls.clearScrubIgnorePause(); - assertFalse(hls.shouldIgnorePause()); + hls.armRateZero(); + hls.notePlaybackInfoForPause(); + hls.clearRateZero(); + hls.beginScrubGesture(); + assertTrue(hls.isScrubGesture()); + assertFalse(hls.notePlaybackInfoForPause()); + hls.endScrubGesture(); + assertFalse(hls.isScrubGesture()); } @Test From 18cdfe8e44716a248212a19004eb01c23d738dd7 Mon Sep 17 00:00:00 2001 From: Sergio Fedorini Date: Fri, 2 Oct 2026 14:17:02 +0300 Subject: [PATCH 4/6] Add airplay.hls.enabled so mirroring can be advertised without video. When the flag is off, discovery keeps screen mirroring and audio only. Also stop using the HLS demuxer that aborts the process if the pipeline is torn down off its own thread. --- README.md | 5 +++ .../src/main/resources/application.properties | 1 + .../airplay/player/gstreamer/GstPlayer.java | 31 ++++++++++++++----- .../airplay/server/AirPlayConfig.java | 1 + .../airplay/server/AirPlayServer.java | 2 +- .../server/discovery/AdvertisedReceiver.java | 14 +++++++++ .../server/discovery/MdnsAdvertiser.java | 6 ++-- .../handler/util/PropertyListUtil.java | 2 +- .../discovery/AdvertisedReceiverTest.java | 8 +++++ 9 files changed, 59 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d7b50ae..312047d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ airplay.serverName=srzhka airplay.width=1280 airplay.height=720 airplay.fps=24 +airplay.hls.enabled=true # player (gstreamer, ffmpeg) player.implementation=gstreamer player.tray.enabled=true @@ -96,6 +97,10 @@ Set `dump.enabled=true` to record beside the live player under `dumps/ + * The adaptive demuxer with a built-in HTTP client completes those requests on + * whatever thread stops the pipeline. Stopping it from the control thread + * aborts the process ({@code playlistRemove}). The other demuxer closes its + * HTTP source on that source's own thread. */ private static void preferSoftwareVideoDecode() { - String drop = "nvh264dec:NONE,nvh265dec:NONE,nvvp8dec:NONE,nvvp9dec:NONE,nvav1dec:NONE"; - String existing = System.getenv("GST_PLUGIN_FEATURE_RANK"); - String rank = existing == null || existing.isBlank() || existing.contains("nvh264dec:") - ? (existing == null || existing.isBlank() ? drop : existing) - : existing + "," + drop; + String[] demote = { + "nvh264dec", "nvh265dec", "nvvp8dec", "nvvp9dec", "nvav1dec", + "hlsdemux2" + }; + String rank = System.getenv("GST_PLUGIN_FEATURE_RANK"); + if (rank == null) { + rank = ""; + } + for (String feature : demote) { + if (rank.contains(feature + ":")) { + continue; + } + if (!rank.isBlank()) { + rank = rank + ","; + } + rank = rank + feature + ":NONE"; + } GLib.setEnv("GST_PLUGIN_FEATURE_RANK", rank, true); + log.info("GStreamer feature rank: {}", rank); } private final Pipeline h264Pipeline; diff --git a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConfig.java b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConfig.java index e047318..e563e6a 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConfig.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConfig.java @@ -8,4 +8,5 @@ public class AirPlayConfig { private int width; private int height; private int fps; + private boolean hlsEnabled = true; } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java index ed52f03..58d18cc 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java @@ -9,7 +9,7 @@ public class AirPlayServer { private final ControlServer controlServer; public AirPlayServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer) { - airPlayBonjour = new MdnsAdvertiser(airPlayConfig.getServerName()); + airPlayBonjour = new MdnsAdvertiser(airPlayConfig.getServerName(), airPlayConfig.isHlsEnabled()); controlServer = new ControlServer(airPlayConfig, airPlayConsumer); } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java index 6802966..cc891d4 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java @@ -17,6 +17,8 @@ public final class AdvertisedReceiver { public static final String FEATURES_TXT = String.format("0x%X,0x%X", FEATURES_LO, FEATURES_HI); + private static final int HLS_FEATURE_BITS = 0x1 | 0x4 | 0x10; + public static final String SOURCE_VERSION = "220.68"; public static final String MODEL = "AppleTV3,2"; public static final int STATUS_FLAGS = 0x44; @@ -29,4 +31,16 @@ private AdvertisedReceiver() { public static String airTunesServerHeader() { return "AirTunes/" + SOURCE_VERSION; } + + public static int featuresLo(boolean hlsEnabled) { + return hlsEnabled ? FEATURES_LO : (FEATURES_LO & ~HLS_FEATURE_BITS); + } + + public static long features(boolean hlsEnabled) { + return ((long) FEATURES_HI << 32) | (featuresLo(hlsEnabled) & 0xFFFFFFFFL); + } + + public static String featuresTxt(boolean hlsEnabled) { + return String.format("0x%X,0x%X", featuresLo(hlsEnabled), FEATURES_HI); + } } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java index 576ac9b..830f2a7 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java @@ -27,10 +27,12 @@ public class MdnsAdvertiser { private static final String AIRTUNES_SERVICE_TYPE = "._raop._tcp.local"; private final String serverName; + private final boolean hlsEnabled; private final List jmDNSList = new ArrayList<>(); public void start(int airTunesPort) throws Exception { + log.info("HLS {}", hlsEnabled ? "enabled" : "disabled"); NetworkInterface.networkInterfaces() .filter(networkInterfaceFilter()) .flatMap(NetworkInterface::inetAddresses) @@ -69,7 +71,7 @@ private Map airPlayMDNSProps(String deviceId) { HashMap airPlayMDNSProps = new HashMap<>(); airPlayMDNSProps.put("deviceid", deviceId); // Bit 27 off (legacy pairing); LO bits 0/4/8 restored — see AdvertisedReceiver. - airPlayMDNSProps.put("features", AdvertisedReceiver.FEATURES_TXT); + airPlayMDNSProps.put("features", AdvertisedReceiver.featuresTxt(hlsEnabled)); airPlayMDNSProps.put("srcvers", AdvertisedReceiver.SOURCE_VERSION); airPlayMDNSProps.put("flags", AdvertisedReceiver.MDNS_FLAGS); airPlayMDNSProps.put("vv", Integer.toString(AdvertisedReceiver.VV)); @@ -87,7 +89,7 @@ private Map airTunesMDNSProps() { airTunesMDNSProps.put("da", "true"); airTunesMDNSProps.put("et", "0,3,5"); airTunesMDNSProps.put("ek", "1"); - airTunesMDNSProps.put("ft", AdvertisedReceiver.FEATURES_TXT); + airTunesMDNSProps.put("ft", AdvertisedReceiver.featuresTxt(hlsEnabled)); airTunesMDNSProps.put("am", AdvertisedReceiver.MODEL); airTunesMDNSProps.put("md", "0,1,2"); airTunesMDNSProps.put("sr", "44100"); diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java index 78e8942..8ed1830 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java @@ -58,7 +58,7 @@ public static byte[] prepareInfoResponse(AirPlayConfig airPlayConfig) throws Exc response.put("audioFormats", audioFormats); response.put("audioLatencies", audioLatencies); response.put("displays", displays); - response.put("features", AdvertisedReceiver.FEATURES); + response.put("features", AdvertisedReceiver.features(airPlayConfig.isHlsEnabled())); response.put("keepAliveSendStatsAsBody", 1); response.put("model", AdvertisedReceiver.MODEL); response.put("name", "Apple TV"); diff --git a/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java index 746b379..3932c63 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java @@ -17,4 +17,12 @@ void matchesHistoricHlsEraMask() { void txtMatchesLoHi() { assertEquals("0x5A7FFFF7,0x1E", AdvertisedReceiver.FEATURES_TXT); } + + @Test + void hlsDisabledDropsVideoBits() { + assertEquals(AdvertisedReceiver.FEATURES_LO, AdvertisedReceiver.featuresLo(true)); + assertEquals(0x5A7FFFE2, AdvertisedReceiver.featuresLo(false)); + assertEquals(0x80, AdvertisedReceiver.featuresLo(false) & 0x80); + assertEquals(0, AdvertisedReceiver.featuresLo(false) & 0x15); + } } From 019feb557ce833c68f1a4a83f8ef0542f59d9bc7 Mon Sep 17 00:00:00 2001 From: Sergio Fedorini Date: Fri, 2 Oct 2026 18:02:38 +0300 Subject: [PATCH 5/6] Advertise implemented features and the pairing key the receiver actually uses. The old mask included video bits this server does not implement, and the constant pk did not match the key returned by pair-setup. --- .../airplay/app/config/PlayerConfig.java | 6 +- .../airplay/protocol/FairPlaySession.java | 11 +- .../protocol/pairing/PairingHandshake.java | 5 +- .../protocol/pairing/PairingIdentity.java | 36 ++++++ .../protocol/FairPlaySessionPairingTest.java | 19 +++ .../airplay/server/AirPlayServer.java | 7 +- .../server/discovery/AdvertisedReceiver.java | 59 +++++++--- .../server/discovery/AirPlayFeature.java | 111 ++++++++++++++++++ .../server/discovery/MdnsAdvertiser.java | 9 +- .../server/internal/ControlServer.java | 6 +- .../handler/control/ControlHandler.java | 2 +- .../internal/handler/session/Session.java | 5 +- .../handler/session/SessionManager.java | 18 ++- .../handler/util/PropertyListUtil.java | 5 +- .../discovery/AdvertisedReceiverTest.java | 27 +++-- 15 files changed, 279 insertions(+), 47 deletions(-) create mode 100644 protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingIdentity.java create mode 100644 server/src/main/java/com/github/serezhka/airplay/server/discovery/AirPlayFeature.java diff --git a/player/app/src/main/java/com/github/serezhka/airplay/app/config/PlayerConfig.java b/player/app/src/main/java/com/github/serezhka/airplay/app/config/PlayerConfig.java index fcb8a8d..452ec05 100644 --- a/player/app/src/main/java/com/github/serezhka/airplay/app/config/PlayerConfig.java +++ b/player/app/src/main/java/com/github/serezhka/airplay/app/config/PlayerConfig.java @@ -21,8 +21,10 @@ public class PlayerConfig { @Bean @ConfigurationProperties(prefix = "airplay") - public AirPlayConfig airPlayConfig() { - return new AirPlayConfig(); + public AirPlayConfig airPlayConfig(@Value("${airplay.hls.enabled:true}") boolean hlsEnabled) { + AirPlayConfig config = new AirPlayConfig(); + config.setHlsEnabled(hlsEnabled); + return config; } @Bean diff --git a/protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java index 4a3e813..d863d23 100644 --- a/protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java +++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java @@ -6,6 +6,7 @@ import com.github.serezhka.airplay.protocol.media.MediaStreamInfo; import com.github.serezhka.airplay.protocol.media.VideoStreamInfo; import com.github.serezhka.airplay.protocol.pairing.PairingHandshake; +import com.github.serezhka.airplay.protocol.pairing.PairingIdentity; import com.github.serezhka.airplay.protocol.rtsp.RtspMediaSetup; import java.io.InputStream; @@ -17,13 +18,21 @@ */ public final class FairPlaySession { - private final PairingHandshake pairing = new PairingHandshake(); + private final PairingHandshake pairing; private final FairPlayHandshake fairPlay = new FairPlayHandshake(); private final RtspMediaSetup rtsp = new RtspMediaSetup(); private FairPlayVideoDecryptor fairPlayVideoDecryptor; private FairPlayAudioDecryptor fairPlayAudioDecryptor; + public FairPlaySession() { + this(PairingIdentity.generate()); + } + + public FairPlaySession(PairingIdentity identity) { + this.pairing = new PairingHandshake(identity); + } + public PairingHandshake pairing() { return pairing; } diff --git a/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java index eb4ab91..c878df6 100644 --- a/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java +++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java @@ -3,7 +3,6 @@ import lombok.extern.slf4j.Slf4j; import net.i2p.crypto.eddsa.EdDSAEngine; import net.i2p.crypto.eddsa.EdDSAPublicKey; -import net.i2p.crypto.eddsa.KeyPairGenerator; import net.i2p.crypto.eddsa.Utils; import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable; import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec; @@ -35,8 +34,8 @@ public class PairingHandshake { private boolean pairVerified; - public PairingHandshake() { - this.keyPair = new KeyPairGenerator().generateKeyPair(); + public PairingHandshake(PairingIdentity identity) { + this.keyPair = identity.keyPair(); } public void pairSetup(OutputStream out) throws IOException { diff --git a/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingIdentity.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingIdentity.java new file mode 100644 index 0000000..9e0ce91 --- /dev/null +++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingIdentity.java @@ -0,0 +1,36 @@ +package com.github.serezhka.airplay.protocol.pairing; + +import net.i2p.crypto.eddsa.EdDSAPublicKey; +import net.i2p.crypto.eddsa.KeyPairGenerator; + +import java.security.KeyPair; +import java.util.HexFormat; + +/** + * Ed25519 identity for one receiver process. + * The public key is what mDNS advertises as {@code pk} and what {@code /pair-setup} returns. + */ +public final class PairingIdentity { + + private final KeyPair keyPair; + + private PairingIdentity(KeyPair keyPair) { + this.keyPair = keyPair; + } + + public static PairingIdentity generate() { + return new PairingIdentity(new KeyPairGenerator().generateKeyPair()); + } + + public byte[] publicKey() { + return ((EdDSAPublicKey) keyPair.getPublic()).getAbyte(); + } + + public String publicKeyHex() { + return HexFormat.of().formatHex(publicKey()); + } + + KeyPair keyPair() { + return keyPair; + } +} diff --git a/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java index dfa967d..b7d4cb4 100644 --- a/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java +++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java @@ -1,6 +1,7 @@ package com.github.serezhka.airplay.protocol; import com.github.serezhka.airplay.protocol.FairPlaySession; +import com.github.serezhka.airplay.protocol.pairing.PairingIdentity; import net.i2p.crypto.eddsa.EdDSAEngine; import net.i2p.crypto.eddsa.EdDSAPublicKey; import net.i2p.crypto.eddsa.KeyPairGenerator; @@ -18,6 +19,7 @@ import java.security.MessageDigest; import java.util.Arrays; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -25,6 +27,23 @@ class AirPlayPairingTest { private final FairPlaySession airPlay = new FairPlaySession(); + @Test + void advertisedKeyMatchesPairSetup() throws Exception { + PairingIdentity identity = PairingIdentity.generate(); + FairPlaySession first = new FairPlaySession(identity); + FairPlaySession second = new FairPlaySession(identity); + + ByteArrayOutputStream firstKey = new ByteArrayOutputStream(32); + first.pairSetup(firstKey); + ByteArrayOutputStream secondKey = new ByteArrayOutputStream(32); + second.pairSetup(secondKey); + + assertArrayEquals(identity.publicKey(), firstKey.toByteArray()); + assertArrayEquals(firstKey.toByteArray(), secondKey.toByteArray()); + assertEquals(identity.publicKeyHex(), identity.publicKeyHex().toLowerCase()); + assertEquals(64, identity.publicKeyHex().length()); + } + @Test void pairingTest() throws Exception { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java index 58d18cc..2aa5b54 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java @@ -1,5 +1,6 @@ package com.github.serezhka.airplay.server; +import com.github.serezhka.airplay.protocol.pairing.PairingIdentity; import com.github.serezhka.airplay.server.discovery.MdnsAdvertiser; import com.github.serezhka.airplay.server.internal.ControlServer; @@ -9,8 +10,10 @@ public class AirPlayServer { private final ControlServer controlServer; public AirPlayServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer) { - airPlayBonjour = new MdnsAdvertiser(airPlayConfig.getServerName(), airPlayConfig.isHlsEnabled()); - controlServer = new ControlServer(airPlayConfig, airPlayConsumer); + PairingIdentity identity = PairingIdentity.generate(); + airPlayBonjour = new MdnsAdvertiser(airPlayConfig.getServerName(), airPlayConfig.isHlsEnabled(), + identity.publicKeyHex()); + controlServer = new ControlServer(airPlayConfig, airPlayConsumer, identity); } public void start() throws Exception { diff --git a/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java index cc891d4..cf3ae8e 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java @@ -1,23 +1,42 @@ package com.github.serezhka.airplay.server.discovery; +import java.util.EnumSet; +import java.util.Set; + /** * Receiver advertisement profile (mDNS TXT + {@code GET /info}). *

- * Historic receiver mask used when YouTube HLS support was first wired: - * {@code 0x5A7FFFF7,0x1E} (same value as {@code GET /info} features - * {@code 130367356919}). + * {@link #ADVERTISED} is only what this server implements. + * With HLS off, {@link AirPlayFeature#VIDEO_PLAYBACK} is removed. + * FairPlay video decrypt stays: that is the mirror stream, not HLS. */ public final class AdvertisedReceiver { - public static final int FEATURES_LO = 0x5A7FFFF7; - public static final int FEATURES_HI = 0x1E; - - /** 64-bit features for {@code GET /info} plist. */ - public static final long FEATURES = ((long) FEATURES_HI << 32) | (FEATURES_LO & 0xFFFFFFFFL); + /** + * Screen mirroring, FairPlay, legacy pairing, RAOP, and the codecs we decode. + * Video playback ({@code /play}, volume, HLS, play queue) is included and + * dropped as a group when {@code airplay.hls.enabled} is false. + */ + private static final Set ADVERTISED = EnumSet.of( + AirPlayFeature.VIDEO, + AirPlayFeature.VIDEO_FAIRPLAY, + AirPlayFeature.VIDEO_VOLUME_CONTROL, + AirPlayFeature.VIDEO_HTTP_LIVE_STREAMS, + AirPlayFeature.SCREEN, + AirPlayFeature.AUDIO, + AirPlayFeature.FPSAP_V2_5_AES_GCM, + AirPlayFeature.AUTHENTICATION_4, + AirPlayFeature.AUDIO_FORMAT_2, + AirPlayFeature.AUDIO_FORMAT_3, + AirPlayFeature.AUDIO_FORMAT_4, + AirPlayFeature.SUPPORTS_LEGACY_PAIRING, + AirPlayFeature.RAOP, + AirPlayFeature.SUPPORTS_AIRPLAY_VIDEO_PLAY_QUEUE); - public static final String FEATURES_TXT = String.format("0x%X,0x%X", FEATURES_LO, FEATURES_HI); - - private static final int HLS_FEATURE_BITS = 0x1 | 0x4 | 0x10; + public static final long FEATURES = features(true); + public static final int FEATURES_LO = (int) FEATURES; + public static final int FEATURES_HI = (int) (FEATURES >>> 32); + public static final String FEATURES_TXT = featuresTxt(true); public static final String SOURCE_VERSION = "220.68"; public static final String MODEL = "AppleTV3,2"; @@ -32,15 +51,27 @@ public static String airTunesServerHeader() { return "AirTunes/" + SOURCE_VERSION; } + public static Set selected(boolean hlsEnabled) { + EnumSet selected = EnumSet.copyOf(ADVERTISED); + if (!hlsEnabled) { + selected.removeAll(AirPlayFeature.VIDEO_PLAYBACK); + } + return selected; + } + public static int featuresLo(boolean hlsEnabled) { - return hlsEnabled ? FEATURES_LO : (FEATURES_LO & ~HLS_FEATURE_BITS); + return (int) features(hlsEnabled); + } + + public static int featuresHi(boolean hlsEnabled) { + return (int) (features(hlsEnabled) >>> 32); } public static long features(boolean hlsEnabled) { - return ((long) FEATURES_HI << 32) | (featuresLo(hlsEnabled) & 0xFFFFFFFFL); + return AirPlayFeature.maskOf(selected(hlsEnabled)); } public static String featuresTxt(boolean hlsEnabled) { - return String.format("0x%X,0x%X", featuresLo(hlsEnabled), FEATURES_HI); + return String.format("0x%X,0x%X", featuresLo(hlsEnabled), featuresHi(hlsEnabled)); } } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/discovery/AirPlayFeature.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AirPlayFeature.java new file mode 100644 index 0000000..463d76d --- /dev/null +++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AirPlayFeature.java @@ -0,0 +1,111 @@ +package com.github.serezhka.airplay.server.discovery; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.Set; + +/** + * Published receiver feature bits (mDNS {@code features} / {@code ft} and {@code GET /info}). + * Bits whose name was never published stay in the catalog only when the historic mask sets them. + */ +public enum AirPlayFeature { + + VIDEO(0), + PHOTO(1), + VIDEO_FAIRPLAY(2), + VIDEO_VOLUME_CONTROL(3), + VIDEO_HTTP_LIVE_STREAMS(4), + SLIDESHOW(5), + /** Unpublished. Not advertised. */ + UNASSIGNED_6(6), + SCREEN(7), + SCREEN_ROTATE(8), + AUDIO(9), + /** Unpublished. Not advertised. */ + UNASSIGNED_10(10), + AUDIO_REDUNDANT(11), + FPSAP_V2_5_AES_GCM(12), + PHOTO_CACHING(13), + AUTHENTICATION_4(14), + METADATA_ARTWORK(15), + METADATA_PROGRESS(16), + METADATA_TEXT(17), + /** Audio format group 1 (PCM). Not advertised: incoming PCM is not decoded. */ + AUDIO_FORMAT_1(18), + /** Audio format group 2 (ALAC). */ + AUDIO_FORMAT_2(19), + /** Audio format group 3 (AAC-LC). */ + AUDIO_FORMAT_3(20), + /** Audio format group 4 (AAC-ELD). */ + AUDIO_FORMAT_4(21), + /** Unpublished. Not advertised. */ + UNASSIGNED_22(22), + AUTHENTICATION_1(23), + /** Unpublished. Not advertised. */ + UNASSIGNED_25(25), + HAS_UNIFIED_ADVERTISER_INFO(26), + SUPPORTS_LEGACY_PAIRING(27), + /** Unpublished. Not advertised. */ + UNASSIGNED_28(28), + RAOP(30), + IS_CARPLAY(32), + SUPPORTS_AIRPLAY_VIDEO_PLAY_QUEUE(33), + SUPPORTS_AIRPLAY_FROM_CLOUD(34), + SUPPORTS_TLS_PSK(35), + /** Unpublished. Not advertised. */ + UNASSIGNED_36(36), + SUPPORTS_UNIFIED_MEDIA_CONTROL(38), + SUPPORTS_BUFFERED_AUDIO(40), + SUPPORTS_PTP(41), + SUPPORTS_SCREEN_MULTI_CODEC(42), + SUPPORTS_SYSTEM_PAIRING(43), + IS_AP_VALERIA_SCREEN_SENDER(44), + SUPPORTS_HK_PAIRING_AND_ACCESS_CONTROL(46), + SUPPORTS_TRANSIENT_PAIRING(48), + SUPPORTS_AIRPLAY_VIDEO_V2(49), + METADATA_NOW_PLAYING(50), + SUPPORTS_UNIFIED_PAIR_SETUP_AND_MFI(51), + SUPPORTS_SET_PEERS_EXTENDED_MESSAGE(52), + SUPPORTS_AP_SYNC(54), + SUPPORTS_WAKE_ON_LAN(55), + SUPPORTS_WAKE_ON_LAN_ALTERNATE(56), + /** Remote control. No such endpoint exists; never advertise. */ + SUPPORTS_HANGDOG_REMOTE_CONTROL(58), + SUPPORTS_AUDIO_STREAM_CONNECTION_SETUP(59), + SUPPORTS_AUDIO_MEDIA_DATA_CONTROL(60), + SUPPORTS_RFC2198_REDUNDANCY(61); + + /** + * Bits a sender treats as "this receiver can play video". + * Cleared together when {@code airplay.hls.enabled} is false. + */ + public static final Set VIDEO_PLAYBACK = EnumSet.of( + VIDEO, + VIDEO_VOLUME_CONTROL, + VIDEO_HTTP_LIVE_STREAMS, + SUPPORTS_AIRPLAY_VIDEO_PLAY_QUEUE, + SUPPORTS_AIRPLAY_FROM_CLOUD, + SUPPORTS_AIRPLAY_VIDEO_V2); + + private final int bit; + + AirPlayFeature(int bit) { + this.bit = bit; + } + + public int bit() { + return bit; + } + + public long mask() { + return 1L << bit; + } + + public static long maskOf(Collection features) { + long mask = 0; + for (AirPlayFeature feature : features) { + mask |= feature.mask(); + } + return mask; + } +} diff --git a/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java index 830f2a7..6d6927c 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java @@ -28,11 +28,13 @@ public class MdnsAdvertiser { private final String serverName; private final boolean hlsEnabled; + private final String pairingPublicKey; private final List jmDNSList = new ArrayList<>(); public void start(int airTunesPort) throws Exception { - log.info("HLS {}", hlsEnabled ? "enabled" : "disabled"); + log.info("HLS {} features {} pk {}", hlsEnabled ? "enabled" : "disabled", + AdvertisedReceiver.featuresTxt(hlsEnabled), pairingPublicKey); NetworkInterface.networkInterfaces() .filter(networkInterfaceFilter()) .flatMap(NetworkInterface::inetAddresses) @@ -70,7 +72,6 @@ public void stop() { private Map airPlayMDNSProps(String deviceId) { HashMap airPlayMDNSProps = new HashMap<>(); airPlayMDNSProps.put("deviceid", deviceId); - // Bit 27 off (legacy pairing); LO bits 0/4/8 restored — see AdvertisedReceiver. airPlayMDNSProps.put("features", AdvertisedReceiver.featuresTxt(hlsEnabled)); airPlayMDNSProps.put("srcvers", AdvertisedReceiver.SOURCE_VERSION); airPlayMDNSProps.put("flags", AdvertisedReceiver.MDNS_FLAGS); @@ -78,7 +79,7 @@ private Map airPlayMDNSProps(String deviceId) { airPlayMDNSProps.put("model", AdvertisedReceiver.MODEL); airPlayMDNSProps.put("rhd", "5.6.0.0"); airPlayMDNSProps.put("pw", "false"); - airPlayMDNSProps.put("pk", "f3769a660475d27b4f6040381d784645e13e21c53e6d2da6a8c3d757086fc336"); + airPlayMDNSProps.put("pk", pairingPublicKey); return airPlayMDNSProps; } @@ -101,7 +102,7 @@ private Map airTunesMDNSProps() { airTunesMDNSProps.put("sf", AdvertisedReceiver.MDNS_FLAGS); airTunesMDNSProps.put("vs", AdvertisedReceiver.SOURCE_VERSION); airTunesMDNSProps.put("vn", "65537"); - airTunesMDNSProps.put("pk", "f3769a660475d27b4f6040381d784645e13e21c53e6d2da6a8c3d757086fc336"); + airTunesMDNSProps.put("pk", pairingPublicKey); return airTunesMDNSProps; } diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java index 1e8cbd4..eadde8e 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java @@ -1,5 +1,6 @@ package com.github.serezhka.airplay.server.internal; +import com.github.serezhka.airplay.protocol.pairing.PairingIdentity; import com.github.serezhka.airplay.server.AirPlayConfig; import com.github.serezhka.airplay.server.Playback; import com.github.serezhka.airplay.server.internal.handler.control.ControlHandler; @@ -30,7 +31,7 @@ @Slf4j public class ControlServer implements Runnable { - private final SessionManager sessionManager = new SessionManager(); + private final SessionManager sessionManager; private final HlsFcupService hlsFcupService; private final AirPlayConfig airPlayConfig; @@ -41,7 +42,8 @@ public class ControlServer implements Runnable { @Getter private int port; - public ControlServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer) { + public ControlServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer, PairingIdentity identity) { + this.sessionManager = new SessionManager(identity); this.airPlayConfig = airPlayConfig; this.airPlayConsumer = airPlayConsumer; this.hlsFcupService = new HlsFcupService(sessionManager, airPlayConsumer); diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java index 3c3dd4e..23381c5 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java @@ -207,7 +207,7 @@ private Session resolveSession(FullHttpRequest request) { } private void handleGetInfo(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception { - var info = PropertyListUtil.prepareInfoResponse(airPlayConfig); + var info = PropertyListUtil.prepareInfoResponse(airPlayConfig, sessionManager.pairingPublicKey()); var response = createRtspResponse(request); response.content().writeBytes(info); sendResponse(ctx, request, response); diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java index c91a157..44ca135 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java @@ -1,6 +1,7 @@ package com.github.serezhka.airplay.server.internal.handler.session; import com.github.serezhka.airplay.protocol.FairPlaySession; +import com.github.serezhka.airplay.protocol.pairing.PairingIdentity; import com.github.serezhka.airplay.server.internal.AudioControlServer; import com.github.serezhka.airplay.server.internal.AudioServer; import com.github.serezhka.airplay.server.internal.VideoServer; @@ -25,9 +26,9 @@ public class Session { private final Map> playlistRequests; private volatile HlsPlaylistState hlsPlaylistState; - Session(String id) { + Session(String id, PairingIdentity identity) { this.id = id; - airPlay = new FairPlaySession(); + airPlay = new FairPlaySession(identity); videoServer = new VideoServer(airPlay); audioServer = new AudioServer(airPlay); audioControlServer = new AudioControlServer(); diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/SessionManager.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/SessionManager.java index 47b66b7..d1198c3 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/SessionManager.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/SessionManager.java @@ -1,22 +1,34 @@ package com.github.serezhka.airplay.server.internal.handler.session; -import lombok.RequiredArgsConstructor; +import com.github.serezhka.airplay.protocol.pairing.PairingIdentity; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -@RequiredArgsConstructor public class SessionManager { + private final PairingIdentity identity; private final Map sessions = new HashMap<>(); + public SessionManager() { + this(PairingIdentity.generate()); + } + + public SessionManager(PairingIdentity identity) { + this.identity = identity; + } + + public byte[] pairingPublicKey() { + return identity.publicKey(); + } + public Session getSession(String sessionId) { synchronized (sessions) { Session session; if ((session = sessions.get(sessionId)) == null) { - session = new Session(sessionId); + session = new Session(sessionId, identity); sessions.put(sessionId, session); } return session; diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java index 8ed1830..cce7e71 100644 --- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java +++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java @@ -2,6 +2,7 @@ import com.dd.plist.BinaryPropertyListWriter; import com.dd.plist.NSArray; +import com.dd.plist.NSData; import com.dd.plist.NSDictionary; import com.github.serezhka.airplay.server.discovery.AdvertisedReceiver; import com.github.serezhka.airplay.server.AirPlayConfig; @@ -13,7 +14,7 @@ @Slf4j public class PropertyListUtil { - public static byte[] prepareInfoResponse(AirPlayConfig airPlayConfig) throws Exception { + public static byte[] prepareInfoResponse(AirPlayConfig airPlayConfig, byte[] pairingPublicKey) throws Exception { NSDictionary audioFormat100 = new NSDictionary(); audioFormat100.put("audioInputFormats", 67108860); audioFormat100.put("audioOutputFormats", 67108860); @@ -66,7 +67,7 @@ public static byte[] prepareInfoResponse(AirPlayConfig airPlayConfig) throws Exc response.put("sourceVersion", AdvertisedReceiver.SOURCE_VERSION); response.put("statusFlags", AdvertisedReceiver.STATUS_FLAGS); response.put("vv", AdvertisedReceiver.VV); - // response.put("pk", new NSData("XYMxJlYMsZoUGTcneJbw/UN7poAeshCsTDnZAHLXDag=")); + response.put("pk", new NSData(pairingPublicKey)); return BinaryPropertyListWriter.writeToArray(response); } diff --git a/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java index 3932c63..16f8fd5 100644 --- a/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java +++ b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java @@ -7,22 +7,27 @@ class ReceiverProfileTest { @Test - void matchesHistoricHlsEraMask() { - assertEquals(0x5A7FFFF7, AdvertisedReceiver.FEATURES_LO); - assertEquals(0x1E, AdvertisedReceiver.FEATURES_HI); - assertEquals(130367356919L, AdvertisedReceiver.FEATURES); + void matchesImplementedFeatures() { + assertEquals(0x4838529D, AdvertisedReceiver.FEATURES_LO); + assertEquals(0x2, AdvertisedReceiver.FEATURES_HI); + assertEquals("0x4838529D,0x2", AdvertisedReceiver.FEATURES_TXT); + assertEquals(AdvertisedReceiver.FEATURES, AdvertisedReceiver.features(true)); } @Test - void txtMatchesLoHi() { - assertEquals("0x5A7FFFF7,0x1E", AdvertisedReceiver.FEATURES_TXT); + void hangdogRemoteControlStaysOff() { + long hangdog = AirPlayFeature.SUPPORTS_HANGDOG_REMOTE_CONTROL.mask(); + assertEquals(0, AdvertisedReceiver.features(true) & hangdog); + assertEquals(0, AdvertisedReceiver.features(false) & hangdog); } @Test - void hlsDisabledDropsVideoBits() { - assertEquals(AdvertisedReceiver.FEATURES_LO, AdvertisedReceiver.featuresLo(true)); - assertEquals(0x5A7FFFE2, AdvertisedReceiver.featuresLo(false)); - assertEquals(0x80, AdvertisedReceiver.featuresLo(false) & 0x80); - assertEquals(0, AdvertisedReceiver.featuresLo(false) & 0x15); + void hlsDisabledDropsVideoPlaybackBits() { + assertEquals("0x48385284,0x0", AdvertisedReceiver.featuresTxt(false)); + long off = AdvertisedReceiver.features(false); + assertEquals(0, off & AirPlayFeature.maskOf(AirPlayFeature.VIDEO_PLAYBACK)); + assertEquals(AirPlayFeature.SCREEN.mask(), off & AirPlayFeature.SCREEN.mask()); + assertEquals(AirPlayFeature.AUDIO.mask(), off & AirPlayFeature.AUDIO.mask()); + assertEquals(AirPlayFeature.VIDEO_FAIRPLAY.mask(), off & AirPlayFeature.VIDEO_FAIRPLAY.mask()); } } From 54cd4951dc328e9d7359f1ce9a5afac305a911a4 Mon Sep 17 00:00:00 2001 From: Sergio Fedorini Date: Sat, 3 Oct 2026 12:11:50 +0300 Subject: [PATCH 6/6] Disable HLS by default and rebuild the video pipeline when playbin3 drops the picture. YouTube playback is not stable yet, and a decodebin3 slot failure leaves a frozen frame while the clock keeps moving. --- README.md | 10 +- .../src/main/resources/application.properties | 2 +- .../player/gstreamer/GstHlsPipeline.java | 169 ++++++++++++++---- 3 files changed, 141 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 312047d..1c2bd0f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - AirPlay audio + screen mirroring - FairPlay decryption for mirrored streams - Volume control -- YouTube HLS playback (AirPlay video from the YouTube app) +- YouTube HLS playback (not stable, disabled by default `airplay.hls.enabled=false`) - Playbacks: GStreamer, FFmpeg (`ffplay`) - Optional session dump (protocol + decrypted media) for debugging @@ -55,10 +55,10 @@ Create `application.properties` in the working directory: ```properties # airplay airplay.serverName=srzhka -airplay.width=1280 -airplay.height=720 -airplay.fps=24 -airplay.hls.enabled=true +airplay.width=1920 +airplay.height=1080 +airplay.fps=60 +airplay.hls.enabled=false # player (gstreamer, ffmpeg) player.implementation=gstreamer player.tray.enabled=true diff --git a/player/app/src/main/resources/application.properties b/player/app/src/main/resources/application.properties index 277bc41..4cb9f65 100644 --- a/player/app/src/main/resources/application.properties +++ b/player/app/src/main/resources/application.properties @@ -13,7 +13,7 @@ airplay.serverName=srzhka airplay.width=1920 airplay.height=1080 airplay.fps=60 -airplay.hls.enabled=true +airplay.hls.enabled=false # player (gstreamer, ffmpeg) player.implementation=gstreamer player.tray.enabled=true diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java index 019e94f..f8e4c56 100644 --- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java @@ -65,18 +65,34 @@ final class GstHlsPipeline { /** Wall-clock position must not run until GST reports a real media clock (avoids black-screen EOS). */ private volatile boolean mediaClockTrusted; private final ScrubClock scrubClock = new ScrubClock(); + private volatile double volumeLinear = 1.0; + /** First open uses playbin3. A slot failure rebuilds once with playbin. */ + private volatile boolean usePlaybin3 = true; + private volatile int recoveries; + private volatile int pipelineGeneration; + private final AtomicBoolean recovering = new AtomicBoolean(); + private final AtomicBoolean gaveUp = new AtomicBoolean(); + private final Object pipelineLock = new Object(); void start(String playlistUri, double volumeLinear) { - stop(); - uri = playlistUri; - paused = false; - ended = false; - positionBaseSeconds = 0; - positionAnchorNanos = System.nanoTime(); - startedAtNanos = System.nanoTime(); - lastEndedNotifyNanos = 0; - mediaClockTrusted = false; + synchronized (pipelineLock) { + stop(); + uri = playlistUri; + paused = false; + ended = false; + positionBaseSeconds = 0; + positionAnchorNanos = System.nanoTime(); + startedAtNanos = System.nanoTime(); + lastEndedNotifyNanos = 0; + mediaClockTrusted = false; + this.volumeLinear = clampVolume(volumeLinear); + usePlaybin3 = ElementFactory.find("playbin3") != null; + recoveries = 0; + buildPipeline(); + } + } + private void buildPipeline() { boolean headless = Boolean.parseBoolean(System.getProperty("airplay.gst.hls.headless", "false")); GstVideoSinkFactory.Result display = null; if (headless) { @@ -90,10 +106,11 @@ void start(String playlistUri, double volumeLinear) { videoSink = display.sink(); } - String launch = ElementFactory.find("playbin3") != null ? "playbin3 name=hls" : "playbin name=hls"; + String launch = usePlaybin3 ? "playbin3 name=hls" : "playbin name=hls"; + final int generation = ++pipelineGeneration; pipeline = (Pipeline) Gst.parseLaunch(launch); - pipeline.set("uri", playlistUri); - pipeline.set("volume", clampVolume(volumeLinear)); + pipeline.set("uri", uri); + pipeline.set("volume", volumeLinear); pipeline.set("video-sink", videoSink); watchFirstPicture(videoSink); log.info("HLS pipeline using {} headless={}", launch.split(" ")[0], headless); @@ -115,27 +132,47 @@ void start(String playlistUri, double volumeLinear) { } pipeline.getBus().connect((Bus.EOS) source -> { - if (uri == null) { + if (generation != pipelineGeneration || uri == null) { return; } markEndedAndRefresh("EOS"); }); pipeline.getBus().connect((Bus.ERROR) (source, code, message) -> { + if (generation != pipelineGeneration || uri == null || ended) { + return; + } log.error("HLS pipeline error: code={} message={}", code, message); // hlsdemux2 "Invalid manifest" after ad often never delivers bus EOS — treat as end. - if (uri != null && message != null && message.toLowerCase().contains("manifest")) { + if (message != null && message.toLowerCase().contains("manifest")) { markEndedAndRefresh("ERROR " + message); + return; + } + scheduleRebuild(code, message); + }); + pipeline.getBus().connect((Bus.WARNING) (source, code, message) -> { + if (generation != pipelineGeneration) { + return; } + log.warn("HLS pipeline warning: code={} message={}", code, message); }); - pipeline.getBus().connect((Bus.WARNING) (source, code, message) -> - log.warn("HLS pipeline warning: code={} message={}", code, message)); pipeline.getBus().connect((Bus.ASYNC_DONE) source -> { + if (generation != pipelineGeneration) { + return; + } tryPendingSeek("async-done"); ensurePlaying("async-done"); noteSeekPicture(); }); - pipeline.getBus().connect((Bus.DURATION_CHANGED) source -> tryPendingSeek("duration")); + pipeline.getBus().connect((Bus.DURATION_CHANGED) source -> { + if (generation != pipelineGeneration) { + return; + } + tryPendingSeek("duration"); + }); pipeline.getBus().connect((Bus.STATE_CHANGED) (source, old, current, pending) -> { + if (generation != pipelineGeneration) { + return; + } if (source == pipeline && current == State.PLAYING) { tryPendingSeek("playing"); } @@ -146,26 +183,66 @@ void start(String playlistUri, double volumeLinear) { // playbin3 + hlsdemux2 often never posts bus EOS for short VOD ENDLIST ads; // poll playlist duration vs position (same idea as the FFmpeg player ENDLIST path). endWatch = seekScheduler.scheduleAtFixedRate(this::checkPositionAtEnd, 400, 200, TimeUnit.MILLISECONDS); - log.info("HLS pipeline started uri={}", playlistUri); + log.info("HLS pipeline started uri={}", uri); } - void stop() { + /** + * playbin3 drops the picture when decodebin3 cannot take another stream + * (logged as a missing plug-in) and then leaves the last frame on screen. + * Rebuild once with playbin, which keeps decoding the streams it can show. + */ + private void scheduleRebuild(int code, String message) { + if (recoveries >= 1) { + if (!recovering.get() && gaveUp.compareAndSet(false, true)) { + log.warn("HLS pipeline error after restart, leaving it: code={} message={}", code, message); + } + return; + } + if (!recovering.compareAndSet(false, true)) { + return; + } + recoveries++; + usePlaybin3 = false; + Double pending = pendingSeekSeconds; + double resumeAt = pending != null ? pending : (mediaClockTrusted ? wallClockSeconds() : positionBaseSeconds); + final int token = pipelineGeneration; + final String keepUri = uri; + log.info("HLS playbin3 failed (code={} {}), restarting with playbin at {}s", code, message, resumeAt); + seekScheduler.execute(() -> { + try { + synchronized (pipelineLock) { + if (token != pipelineGeneration || keepUri == null || !keepUri.equals(uri) || ended) { + return; + } + boolean wasPaused = paused; + disposePipeline(); + if (!keepUri.equals(uri) || ended) { + return; + } + buildPipeline(); + if (resumeAt > 0.5) { + pendingSeekSeconds = resumeAt; + seekAttempts.set(0); + tryPendingSeek("restart"); + } + if (wasPaused && pipeline != null) { + pipeline.pause(); + paused = true; + } + } + } catch (RuntimeException e) { + log.warn("HLS pipeline restart failed: {}", e.toString()); + } finally { + recovering.set(false); + } + }); + } + + /** Drop the current pipeline without forgetting the playlist or the clock. */ + private void disposePipeline() { + pipelineGeneration++; cancelSeekRetry(); cancelEndWatch(); - pendingSeekSeconds = null; - seekAttempts.set(0); - paused = false; - ended = false; - positionBaseSeconds = 0; - positionAnchorNanos = 0; - playlistDurationSeconds = 0; - lastEndedNotifyNanos = 0; - startedAtNanos = 0; - mediaClockTrusted = false; - scrubClock.clear(); - presented.set(false); - seekPicturePending = false; - uri = null; if (pipeline != null) { try { pipeline.setState(State.NULL); @@ -186,6 +263,29 @@ void stop() { } } + void stop() { + synchronized (pipelineLock) { + cancelSeekRetry(); + cancelEndWatch(); + pendingSeekSeconds = null; + seekAttempts.set(0); + paused = false; + ended = false; + positionBaseSeconds = 0; + positionAnchorNanos = 0; + playlistDurationSeconds = 0; + lastEndedNotifyNanos = 0; + startedAtNanos = 0; + mediaClockTrusted = false; + scrubClock.clear(); + presented.set(false); + seekPicturePending = false; + gaveUp.set(false); + uri = null; + disposePipeline(); + } + } + void pause() { if (pipeline == null || ended) { return; @@ -250,8 +350,9 @@ void noteMediaDuration(double seconds) { } void setVolume(double volumeLinear) { + this.volumeLinear = clampVolume(volumeLinear); if (pipeline != null) { - pipeline.set("volume", clampVolume(volumeLinear)); + pipeline.set("volume", this.volumeLinear); } }