Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8a2fa68
Default Display setting added
ccarpiog Nov 27, 2025
c79a965
Add custom display names feature
ccarpiog Feb 3, 2026
5dd4ecd
Fix preferences UI and Display Names panel issues
ccarpiog Feb 3, 2026
0113e19
Add camera audio capture and monitoring feature
ccarpiog Feb 3, 2026
d965801
Fix display names persistence across reboots
ccarpiog Feb 5, 2026
192e303
Fix camera audio capture using AVCaptureAudioPreviewOutput
ccarpiog Feb 6, 2026
73e288f
Add Shift+Tab navigation in preferences text fields
ccarpiog Feb 6, 2026
5b3bea2
Close app when window is closed with red button
ccarpiog Feb 6, 2026
b4ac695
Add multi-window support (Phase 1 + Phase 2)
ccarpiog Feb 6, 2026
08280b2
Translate all UI text from Spanish to English and update LICENSE
ccarpiog Feb 6, 2026
ba0977e
Add HLS streaming pipeline and stabilize live delivery
ccarpiog Feb 18, 2026
58f2b6b
Avoid shutdown deadlock in frame capture
ccarpiog Feb 18, 2026
2f161f4
Fix HLS stability and add outgoing camera audio
ccarpiog Feb 18, 2026
7cf0f75
Translated streaming menu to English
ccarpiog Feb 18, 2026
e23797c
Update README with live streaming documentation
ccarpiog Feb 18, 2026
483e4ce
Add unified default source with camera naming and sorting
ccarpiog Feb 18, 2026
6bfe00e
Reduce HLS streaming latency to ~2-3 seconds
ccarpiog Feb 18, 2026
573cc79
Separate local audio monitoring from HLS audio streaming
ccarpiog Mar 14, 2026
35696f4
Copy URL now points directly to the HLS stream
ccarpiog Mar 15, 2026
10ebba6
Rename streaming menu items to clarify direction
ccarpiog May 31, 2026
32b25ec
Add Tailscale Funnel remote-access design doc
ccarpiog May 31, 2026
42de5c6
Ignore local agent instruction files (CLAUDE.md, AGENTS.md)
ccarpiog May 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(lsof:*)",
"Bash(log show:*)",
"Read(//Users/ccarpio/Library/Developer/Xcode/DerivedData/**)"
]
}
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ iOSInjectionProject/

.DS_Store
*.dmg

# Agent instruction files (local only)
CLAUDE.md
AGENTS.md
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ set(CMAKE_OSX_DEPLOYMENT_TARGET 11.0)

project(PiP)

add_compile_options(-fobjc-arc -Wno-deprecated-declarations -Wno-format)
add_compile_options(-fobjc-arc -Wno-deprecated-declarations -Wno-format -mmacosx-version-min=14.0)

set(frameworks AVFoundation Cocoa VideoToolbox AudioToolbox CoreMedia QuartzCore OpenGL Metal MetalKit PIP SkyLight ScreenCaptureKit)
list(TRANSFORM frameworks PREPEND "-framework ")

set(AIRPLAY_SUPPORT_ENABLED 1)

file(GLOB_RECURSE pip_src CONFIGURE_DEPENDS "pip/*.m")
file(GLOB_RECURSE pip_src CONFIGURE_DEPENDS "pip/*.m" "pip/*.c")
add_executable(pip ${pip_src})
target_include_directories(pip PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
Expand Down
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2020 amitv87
Copyright (c) 2026 ccarpiog

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Always on top window preview with AirPlay receiver support (if on macOS 12+, tur
## Features
* Clone any visibile window
* Clone multiple active display
* Camera preview
* HLS streaming
* Camera preview with audio monitoring
* HLS live streaming of any preview (window, display, or camera)
* Airplay sender (unstable)
* Crop the preview
* Auto and manual resize preserving the aspect ratio
Expand All @@ -30,6 +30,40 @@ Always on top window preview with AirPlay receiver support (if on macOS 12+, tur
* Minimal modern UI
* Upto 10 parallel airplay sessions (soft limit)

## Live Streaming

PiP can stream any preview window over your local network using HLS (HTTP Live Streaming). Any device with a web browser on the same network can watch the live feed.

### How to use
1. Right-click any active preview window and select **Broadcast > Broadcast This Window**
2. The stream URL (e.g. `http://192.168.1.42:8080`) appears at the bottom of the Broadcast menu
3. Open that URL on any device's browser to watch the live stream
4. Use **Copy URL** to copy the direct HLS stream URL (`/stream.m3u8`) or **Open in Browser** to open the web viewer

### Quality presets
| Preset | Resolution | Bitrate | FPS |
|--------|-----------|---------|-----|
| Low | 720p | 1.5 Mbps | 24 |
| Medium | 1080p | 3 Mbps | 30 |
| High | Native | 6 Mbps | 30 |

Change quality on the fly via **Broadcast > Quality**.

### Architecture
The streaming pipeline is fully self-contained with no third-party dependencies:

```
Frame Capture → H.264 Video Encoder → MPEG-TS Muxer → HLS Writer → HTTP Server
Audio Capture
```

* **Frame Capture** – grabs RGBA frames from the preview's OpenGL/Metal renderer
* **Video Encoder** – hardware-accelerated H.264 encoding via VideoToolbox
* **TS Muxer** – multiplexes video (and optional audio) into MPEG-TS segments
* **HLS Writer** – manages a ring buffer of `.ts` segments and generates the `.m3u8` playlist
* **HTTP Server** – lightweight embedded server that serves the playlist, segments, and a built-in web viewer with hls.js

## Installation

### Manual download
Expand Down
9 changes: 8 additions & 1 deletion airplay_sender/video_encoder.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ video_encoder_t *video_encoder_init(int width, int height, int fps, int bitrate)
*/
void video_encoder_set_callback(video_encoder_t *enc, encoded_frame_callback_t cb, void *ctx);

/**
* Set maximum keyframe interval in frames.
* Example: at 30fps, 30 => ~1 second keyframe cadence.
* Returns 0 on success, -1 on error.
*/
int video_encoder_set_keyframe_interval(video_encoder_t *enc, int keyframe_interval_frames);

/**
* Encode a frame
* rgba_data: RGBA32 format frame data (row-major)
Expand All @@ -78,4 +85,4 @@ void video_encoder_destroy(video_encoder_t *enc);
}
#endif

#endif
#endif
8 changes: 8 additions & 0 deletions airplay_sender/video_encoder_stub.c
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ video_encoder_set_callback(video_encoder_t *enc, encoded_frame_callback_t cb, vo
}
}

int
video_encoder_set_keyframe_interval(video_encoder_t *enc, int keyframe_interval_frames)
{
(void)enc;
(void)keyframe_interval_frames;
return 0;
}

int
video_encoder_encode_frame(video_encoder_t *enc, uint8_t *rgba_data, int stride, uint64_t pts)
{
Expand Down
30 changes: 30 additions & 0 deletions pip.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@
55FEB9A7 /* airplaySender.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEB9A2 /* airplaySender.m */; };
55FEB9A8 /* frame_capture.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEB9A4 /* frame_capture.m */; };
55FEB9A9 /* video_encoder.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEB9A6 /* video_encoder.m */; };
55FEBC11 /* ts_muxer.c in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC02 /* ts_muxer.c */; };
55FEBC12 /* hls_writer.c in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC04 /* hls_writer.c */; };
55FEBC13 /* stream_server.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC06 /* stream_server.m */; };
55FEBC14 /* stream_manager.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC08 /* stream_manager.m */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -325,6 +329,17 @@
55FEB9A5 /* video_encoder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = video_encoder.h; sourceTree = "<group>"; };
55FEB9A6 /* video_encoder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = video_encoder.m; sourceTree = "<group>"; };
55FEB9AA /* ScreenCaptureKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ScreenCaptureKit.framework; path = /System/Volumes/Data/Library/Developer/CommandLineTools/SDKs/MacOSX12.3.sdk/System/Library/Frameworks/ScreenCaptureKit.framework; sourceTree = "<group>"; };
55FEBC01 /* ts_muxer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ts_muxer.h; sourceTree = "<group>"; };
55FEBC02 /* ts_muxer.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = ts_muxer.c; sourceTree = "<group>"; };
55FEBC03 /* hls_writer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hls_writer.h; sourceTree = "<group>"; };
55FEBC04 /* hls_writer.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hls_writer.c; sourceTree = "<group>"; };
55FEBC05 /* stream_server.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stream_server.h; sourceTree = "<group>"; };
55FEBC06 /* stream_server.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = stream_server.m; sourceTree = "<group>"; };
55FEBC07 /* stream_manager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stream_manager.h; sourceTree = "<group>"; };
55FEBC08 /* stream_manager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = stream_manager.m; sourceTree = "<group>"; };
55FEBC09 /* viewer.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = viewer.html; sourceTree = "<group>"; };
55FEBC0A /* hls.min.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "hls.min.js"; sourceTree = "<group>"; };
55FEBC0B /* incbin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = incbin.h; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -576,6 +591,17 @@
55FEB9A4 /* frame_capture.m */,
55FEB9A5 /* video_encoder.h */,
55FEB9A6 /* video_encoder.m */,
55FEBC01 /* ts_muxer.h */,
55FEBC02 /* ts_muxer.c */,
55FEBC03 /* hls_writer.h */,
55FEBC04 /* hls_writer.c */,
55FEBC05 /* stream_server.h */,
55FEBC06 /* stream_server.m */,
55FEBC07 /* stream_manager.h */,
55FEBC08 /* stream_manager.m */,
55FEBC09 /* viewer.html */,
55FEBC0A /* hls.min.js */,
55FEBC0B /* incbin.h */,
);
path = pip;
sourceTree = "<group>";
Expand Down Expand Up @@ -807,6 +833,10 @@
55FEB9A7 /* airplaySender.m in Sources */,
55FEB9A8 /* frame_capture.m in Sources */,
55FEB9A9 /* video_encoder.m in Sources */,
55FEBC11 /* ts_muxer.c in Sources */,
55FEBC12 /* hls_writer.c in Sources */,
55FEBC13 /* stream_server.m in Sources */,
55FEBC14 /* stream_manager.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 2 additions & 0 deletions pip/HLSPlayer.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ - (instancetype)initWithURL:(NSURL *)url headers:(NSDictionary<NSString *, NSStr

_playerItem = [AVPlayerItem playerItemWithAsset:asset];
_player = [AVPlayer playerWithPlayerItem:_playerItem];
_player.automaticallyWaitsToMinimizeStalling = NO;
_playerItem.preferredForwardBufferDuration = 1.0;

// Setup video output for frame extraction
NSDictionary *pixBuffAttributes = @{
Expand Down
34 changes: 23 additions & 11 deletions pip/frame_capture.m
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,29 @@ static void capture_frame(frame_capture_t *cap) {
return;
}

// Get the current image from the renderer (must be on main thread)
// __block CIImage *currentImage = nil;
// if ([NSThread isMainThread]) {
// currentImage = [imageView.renderer currentImage];
// } else {
// dispatch_sync(dispatch_get_main_queue(), ^{
// currentImage = [imageView.renderer currentImage];
// });
// }

__block CIImage *currentImage = [imageView.renderer currentImage];
// Get the current image from the renderer on main thread.
// Use a bounded wait when called from the capture queue to avoid deadlock
// during shutdown (main thread stopping capture while capture queue requests main).
__block CIImage *currentImage = nil;
if ([NSThread isMainThread]) {
currentImage = [imageView.renderer currentImage];
} else {
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
dispatch_async(dispatch_get_main_queue(), ^{
currentImage = [imageView.renderer currentImage];
dispatch_semaphore_signal(sem);
});
long wait_result = dispatch_semaphore_wait(
sem,
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC))
);
if (wait_result != 0) {
if (cap->frame_count == 0 || cap->frame_count % 30 == 0) {
NSLog(@"frame_capture: timed out waiting for main-thread image (frame_count: %llu)", cap->frame_count);
}
return;
}
}

if (!currentImage) {
if (cap->frame_count == 0 || cap->frame_count % 30 == 0) {
Expand Down
2 changes: 2 additions & 0 deletions pip/hls.min.js

Large diffs are not rendered by default.

Loading