diff --git a/README.md b/README.md index d7b50ae..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,9 +55,10 @@ Create `application.properties` in the working directory: ```properties # airplay airplay.serverName=srzhka -airplay.width=1280 -airplay.height=720 -airplay.fps=24 +airplay.width=1920 +airplay.height=1080 +airplay.fps=60 +airplay.hls.enabled=false # 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/ observer.onEnded()); + hls.setOnPresented(() -> observer.onPresented()); + hls.setOnSeekDisplayed(() -> observer.onSeekDisplayed()); log.info("FFmpeg debug log: {}", NativeProcessLog.playerLogFile("ffmpeg")); } @@ -47,18 +49,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/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..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,9 +3,18 @@ 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; +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 +39,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 +53,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 AtomicBoolean incomingPicture = new AtomicBoolean(); void start(String playlistUri, double volume) { start(playlistUri, volume, 0); @@ -59,8 +75,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 +103,8 @@ void stop() { ended.set(false); pendingSeekSeconds.set(null); destroyFfplay(); + destroyLoose(heldFrame); + heldFrame = null; Thread thread = playbackThread; playbackThread = null; uri = null; @@ -108,8 +130,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 +149,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 +169,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 +224,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 +241,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 +306,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; + } + } + } + } + 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 +418,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 +481,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 +498,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); + AtomicBoolean picture = new 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 +550,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(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(InputStream in, AtomicBoolean picture) { + Path logFile = NativeProcessLog.playerLogFile("ffmpeg"); + try { + Path parent = logFile.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + } catch (IOException e) { + log.debug("HLS log dir: {}", e.toString()); + } + try (InputStream input = in; + OutputStream out = Files.newOutputStream( + logFile, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND)) { + byte[] buf = new byte[2048]; + 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(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(StandardCharsets.UTF_8)); + out.write('\n'); + } else { + line.write(b); + } + } + } + } catch (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 +716,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..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 @@ -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,11 @@ 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; private JFrame window; @@ -55,18 +64,35 @@ 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(); + 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) { @@ -80,11 +106,13 @@ 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); if (!headless && display != null && display.overlay() && display.canvas() != null) { @@ -104,26 +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 -> { + if (generation != pipelineGeneration) { + return; + } + tryPendingSeek("duration"); }); - pipeline.getBus().connect((Bus.DURATION_CHANGED) source -> tryPendingSeek("duration")); pipeline.getBus().connect((Bus.STATE_CHANGED) (source, old, current, pending) -> { + if (generation != pipelineGeneration) { + return; + } if (source == pipeline && current == State.PLAYING) { tryPendingSeek("playing"); } @@ -134,23 +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; - uri = null; if (pipeline != null) { try { pipeline.setState(State.NULL); @@ -171,12 +263,37 @@ 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; } - 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 +331,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(); @@ -230,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); } } @@ -244,7 +365,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 +421,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 +484,56 @@ 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 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()); + } + } + + /** 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; @@ -403,6 +584,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 b9c2035..95b899c 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,40 @@ 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"); } + /** + * Device H.264 decoders drop frames when they have no usable context. + *

+ * 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[] 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; private final Pipeline alacPipeline; private final Pipeline aacEldPipeline; @@ -63,6 +94,8 @@ public GstPlayer() { 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 new file mode 100644 index 0000000..5e4c8f2 --- /dev/null +++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/ScrubClock.java @@ -0,0 +1,46 @@ +package com.github.serezhka.airplay.player.gstreamer; + +/** + * 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 { + + 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; + } + + double report(double clockSeconds, double wallSeconds) { + if (hold) { + 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/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/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..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()); - 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/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/discovery/AdvertisedReceiver.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java index 6802966..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,21 +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); + 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"; @@ -29,4 +50,28 @@ private AdvertisedReceiver() { 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 (int) features(hlsEnabled); + } + + public static int featuresHi(boolean hlsEnabled) { + return (int) (features(hlsEnabled) >>> 32); + } + + public static long features(boolean hlsEnabled) { + return AirPlayFeature.maskOf(selected(hlsEnabled)); + } + + public static String featuresTxt(boolean hlsEnabled) { + 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 576ac9b..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 @@ -27,10 +27,14 @@ public class MdnsAdvertiser { private static final String AIRTUNES_SERVICE_TYPE = "._raop._tcp.local"; 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 {} features {} pk {}", hlsEnabled ? "enabled" : "disabled", + AdvertisedReceiver.featuresTxt(hlsEnabled), pairingPublicKey); NetworkInterface.networkInterfaces() .filter(networkInterfaceFilter()) .flatMap(NetworkInterface::inetAddresses) @@ -68,15 +72,14 @@ 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.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)); 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; } @@ -87,7 +90,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"); @@ -99,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 b03dabb..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,11 +42,27 @@ 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); - 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 { @@ -72,7 +89,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 9277f6c..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 @@ -145,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); @@ -203,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); @@ -268,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); @@ -400,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); @@ -413,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); @@ -423,13 +449,16 @@ 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); } 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); @@ -440,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()); } } @@ -495,27 +530,33 @@ private void handleRate(ChannelHandlerContext ctx, FullHttpRequest request) { } if (value == 0) { - 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"); + 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 wasPaused = hls != null && hls.getPlaybackRate() <= 0; if (hls != null) { - hls.clearScrubIgnorePause(); + hls.clearRateZero(); + hls.endScrubGesture(); hls.setPlaybackRate(1); } - // 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(); } - hlsFcupService.sendPlaybackStateEvent(session, "playing"); + // rate=1 can arrive before the first frame; wait for the picture callback. + 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); @@ -532,8 +573,8 @@ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) { var session = resolveSession(request); var hls = session.getHlsPlaylistState(); if (hls != null) { - // 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()) { @@ -541,6 +582,8 @@ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) { log.info("Deferring /scrub to pending seek until HLS starts"); } else { airPlayConsumer.onSeek(position); + // loading then playing (clock under params) unsticks the pause UI. + hlsFcupService.sendPlaybackStateEvent(session, "loading"); hlsFcupService.sendPlaybackStateEvent(session, "playing"); } } catch (NumberFormatException e) { @@ -563,25 +606,18 @@ 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(); 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; 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 { @@ -589,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; @@ -608,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) { @@ -654,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) { @@ -773,8 +805,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); @@ -810,6 +841,32 @@ private void schedulePlaybackStartFallback(Session session, HlsPlaylistState hls pendingPlaybackStarts.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"); + } + + /** + * 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; + } + 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) { ScheduledFuture future = pendingPlaybackStarts.remove(sessionId); if (future != null) { @@ -965,7 +1022,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..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; @@ -31,6 +32,14 @@ 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; + /** + * 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; @@ -40,13 +49,21 @@ 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<>(); + private final ScheduledFuture stateHeartbeat; public HlsFcupService(SessionManager sessionManager, Playback airPlayConsumer) { this.sessionManager = sessionManager; this.airPlayConsumer = airPlayConsumer; - startSignalFileWatcher(); + 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() { @@ -75,76 +92,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(); @@ -170,8 +122,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); @@ -219,8 +170,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); @@ -230,6 +180,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); @@ -238,6 +189,7 @@ public void cancelAllMasterPolls() { } public void clearReverseQueue(Session session) { + cancelPresentationFallback(session.getId()); ReverseQueue queue = reverseQueues.remove(session.getId()); if (queue != null) { synchronized (queue) { @@ -257,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; @@ -291,17 +245,167 @@ 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 — 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"); + } + } + + /** First picture is on the display. 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); + } + } + + /** + * 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)) { + 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()); - enqueueReverseEvent(session, body); + String xml = new String(body, StandardCharsets.UTF_8).replaceAll("\\s+", " ").trim(); + if (playback != null) { + log.info("Playback state event: {} position={} rate={} session={} xml={}", + state, playback.position(), playback.rate(), session.getId(), xml); + } else { + log.info("Playback state event: {} session={} xml={}", state, session.getId(), xml); + } + return body; } /** 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/HlsPlaylistState.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java index 3955f9e..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 @@ -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 @@ -62,30 +63,78 @@ 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); } - /** Arm after {@code /scrub}: drop rate=0 until the client sends rate=1. */ - public void markScrubIgnorePauseUntilPlay() { - ignorePauseUntilRateOne = true; + /** 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(); + } + + public void beginScrubGesture() { + scrubGesture = true; + } + + public boolean isScrubGesture() { + return scrubGesture; + } + + public void endScrubGesture() { + scrubGesture = false; } - public boolean shouldIgnorePause() { - return ignorePauseUntilRateOne; + public void armRateZero() { + rateZeroPending = true; + playbackInfosSinceRateZero = 0; } - public void clearScrubIgnorePause() { - ignorePauseUntilRateOne = false; + 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/session/HlsUriRewrite.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java index 9c9de05..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 @@ -48,7 +48,12 @@ 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 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); @@ -85,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) { @@ -93,16 +113,38 @@ public static String preferAvcVariants(String masterPlaylist) { } out.append(header).append('\n'); } + for (String media : defaultAudioOnly(audioMedia, audioGroups)) { + out.append(media).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<>(); + 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)) { - out.append(media).append('\n'); + 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); } } - for (String variantLine : variants) { - out.append(variantLine).append('\n'); - } - return out.toString(); + return new ArrayList<>(chosen.values()); } private static boolean isAvcStreamInfo(String streamInf) { 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 3defd51..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); @@ -58,7 +59,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"); @@ -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); } @@ -122,15 +123,14 @@ 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). - // - // 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) { + // 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); + 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); @@ -147,7 +147,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); } @@ -167,6 +167,17 @@ 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); + } + + /** + * 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, + Playback.Info playback) { NSDictionary event = new NSDictionary(); event.put("category", "video"); event.put("sessionID", reverseSessionId); @@ -174,14 +185,40 @@ 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); + 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/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/discovery/AdvertisedReceiverTest.java b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java index 746b379..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,14 +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 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()); } } 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..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,5 +180,46 @@ 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 + 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/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 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..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 @@ -59,6 +59,48 @@ 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 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