feat(android): share podcast playback and foreground broadcasting
Add native Camera2 capture, MPEG-TS segmentation and foreground service with notification stop. Reuse shared authenticated uploads and replay finalization, add capture regression tests and document platform limitations. Update Avalonia to 12.1.4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
2dcbcd35b3
commit
ad92744a94
24 changed files with 1495 additions and 70 deletions
100
src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs
Normal file
100
src/PostIt/PostIt.Tests/AndroidPodcastCaptureTestActivity.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#if ANDROID
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.OS;
|
||||
using Avalonia.Threading;
|
||||
using PostIt.Android;
|
||||
using PostIt.Android.Services;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>Debug-only local encoder test; never connects to the podcast API.</summary>
|
||||
[Activity(Name = "fr.pschneider.postit.PodcastCaptureTestActivity", Exported = true)]
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
public sealed class AndroidPodcastCaptureTestActivity : Activity
|
||||
{
|
||||
private bool _microphone = true;
|
||||
private bool _notificationStop;
|
||||
private int _cameraIndex;
|
||||
protected override async void OnCreate(Bundle? state)
|
||||
{
|
||||
base.OnCreate(state);
|
||||
_microphone = Intent?.GetStringExtra("microphone") != "false";
|
||||
_notificationStop = Intent?.GetStringExtra("notificationStop") == "true";
|
||||
_cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0;
|
||||
var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt");
|
||||
File.WriteAllText(resultPath, "RUNNING");
|
||||
var result = new Bundle();
|
||||
try
|
||||
{
|
||||
await RunAsync(result);
|
||||
File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(resultPath, $"FAIL\n{ex}");
|
||||
}
|
||||
finally { Finish(); }
|
||||
}
|
||||
|
||||
private async Task RunAsync(Bundle result)
|
||||
{
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
throw new PlatformNotSupportedException("Android 8 required.");
|
||||
var context = this;
|
||||
var intent = new Intent(context, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask);
|
||||
StartActivity(intent);
|
||||
for (var i = 0; i < 100 && !MainActivity.IsForeground; i++) await Task.Delay(100);
|
||||
await Task.Delay(1000);
|
||||
var directory = Path.Combine(context.FilesDir!.AbsolutePath, "podcast-capture-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var devices = AndroidPodcastCapture.GetDevices();
|
||||
if (devices.Cameras.Count == 0) throw new InvalidOperationException("No emulator camera.");
|
||||
IPodcastCapture? capture = null;
|
||||
try
|
||||
{
|
||||
capture = await Dispatcher.UIThread.InvokeAsync(() => AndroidPodcastCapture.StartAsync(
|
||||
devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value,
|
||||
_microphone ? "microphone" : null, directory, CancellationToken.None));
|
||||
await Task.Delay(3000);
|
||||
var before = SegmentCount(directory);
|
||||
context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask));
|
||||
await Task.Delay(9000);
|
||||
var after = SegmentCount(directory);
|
||||
if (capture.Completion.IsFaulted) await capture.Completion;
|
||||
if (after <= before) throw new InvalidOperationException($"Capture did not continue in background: {before} -> {after} segments.");
|
||||
if (_notificationStop)
|
||||
{
|
||||
using var stop = PendingIntent.GetService(context, 1,
|
||||
new Intent(context, typeof(PodcastCaptureService)).SetAction(PodcastCaptureService.StopAction),
|
||||
PendingIntentFlags.NoCreate | PendingIntentFlags.Immutable)
|
||||
?? throw new InvalidOperationException("Notification stop action missing.");
|
||||
stop.Send();
|
||||
}
|
||||
else await capture.StopAsync().WaitAsync(TimeSpan.FromSeconds(15));
|
||||
await capture.Completion.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8")));
|
||||
if (segments.Count < 3) throw new InvalidOperationException("Insufficient capture segments.");
|
||||
result.PutInt("segments", segments.Count);
|
||||
result.PutString("directory", directory);
|
||||
result.PutString("background", $"segments {before} -> {after}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (capture is not null) await capture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static int SegmentCount(string directory)
|
||||
{
|
||||
var path = Path.Combine(directory, "index.m3u8");
|
||||
return File.Exists(path) ? HlsCapturePlaylist.Parse(File.ReadAllText(path)).Count : 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -464,11 +464,31 @@ public class BlogReaderTests
|
|||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
|
||||
public void Video_viewmodel_resolves_to_the_shared_page_with_a_platform_video_surface_slot()
|
||||
{
|
||||
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
||||
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
||||
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
|
||||
Assert.NotNull(page.FindControl<ContentControl>("VideoSurface"));
|
||||
Assert.NotNull(page.FindControl<Button>("PlayButton"));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Video_page_observes_platform_lifecycle_only_while_navigated()
|
||||
{
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(new ContentPage());
|
||||
var page = new VideoPage();
|
||||
await nav.PushAsync(page);
|
||||
var status = page.FindControl<TextBlock>("PlaybackStatus")!;
|
||||
Platform.SuspendPlayback();
|
||||
Assert.Equal("Lecture suspendue.", status.Text);
|
||||
Assert.False(page.FindControl<Button>("PlayButton")!.IsEnabled);
|
||||
Platform.ResumePlayback();
|
||||
Assert.Null(page.FindControl<ContentControl>("VideoSurface")!.Content);
|
||||
await nav.PopAsync();
|
||||
status.Text = "No longer active";
|
||||
Platform.SuspendPlayback();
|
||||
Assert.Equal("No longer active", status.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
175
src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
Normal file
175
src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using System.Net;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class PodcastBroadcasterTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-podcast-{Guid.NewGuid():N}.json");
|
||||
var devices = Platform.GetPodcastCaptureDevices;
|
||||
var permissions = Platform.RequestPodcastPermissionsAsync;
|
||||
var factory = Platform.CreatePodcastCaptureAsync;
|
||||
var store = new TokenStore(tokenPath);
|
||||
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
|
||||
await using var api = new YavscApiClient(new Settings(), store);
|
||||
await using var transport = new Transport();
|
||||
try
|
||||
{
|
||||
Platform.GetPodcastCaptureDevices = () => new(
|
||||
[new("Front", "front"), new("Rear", "rear")],
|
||||
[new("None", ""), new("Android microphone", "microphone")]);
|
||||
Platform.RequestPodcastPermissionsAsync = (_, _) =>
|
||||
Task.FromException(new UnauthorizedAccessException("Camera denied"));
|
||||
Platform.CreatePodcastCaptureAsync = (_, _, _, _) =>
|
||||
throw new InvalidOperationException("Capture must not start after permission denial.");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
session.Refresh();
|
||||
var vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
|
||||
{
|
||||
Title = "Android live"
|
||||
};
|
||||
Assert.True(vm.CanBroadcast);
|
||||
vm.DetectDevicesCommand.Execute(null);
|
||||
Assert.Equal(2, vm.Cameras.Count);
|
||||
Assert.Equal("front", vm.SelectedCamera?.Value);
|
||||
Assert.Equal("microphone", vm.SelectedMicrophone?.Value);
|
||||
Assert.True(vm.StartCommand.CanExecute(null));
|
||||
await vm.StartCommand.ExecuteAsync(null);
|
||||
Assert.Contains("Camera denied", vm.Status);
|
||||
Assert.Equal(0, transport.CreateCount);
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.False(vm.IsBroadcasting);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Platform.GetPodcastCaptureDevices = devices;
|
||||
Platform.RequestPodcastPermissionsAsync = permissions;
|
||||
Platform.CreatePodcastCaptureAsync = factory;
|
||||
File.Delete(tokenPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
|
||||
{
|
||||
await using var transport = new Transport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
FakeCapture? capture = null;
|
||||
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
|
||||
(directory, _) =>
|
||||
{
|
||||
capture = new FakeCapture(directory);
|
||||
return Task.FromResult<IPodcastCapture>(capture);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
await transport.FirstUpload.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
await capture!.StopAsync();
|
||||
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(new[] { 0, 1 }, transport.Uploads);
|
||||
Assert.Equal(new[] { "upload:0", "upload:1", "complete" }, transport.Events);
|
||||
Assert.True(capture.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_failure_stops_capture_and_preserves_unacknowledged_segments()
|
||||
{
|
||||
await using var transport = new Transport { FailUpload = true };
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
FakeCapture? capture = null;
|
||||
var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
|
||||
(directory, _) =>
|
||||
{
|
||||
capture = new FakeCapture(directory);
|
||||
return Task.FromResult<IPodcastCapture>(capture);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.Completion);
|
||||
Assert.True(capture!.Stopped);
|
||||
Assert.True(File.Exists(Path.Combine(capture.Directory, "segment-00000000.ts")));
|
||||
Assert.DoesNotContain("complete", transport.Events);
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.DisposeAsync().AsTask());
|
||||
Assert.True(capture.Disposed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (capture is not null) Directory.Delete(capture.Directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_failure_finalizes_empty_server_broadcast()
|
||||
{
|
||||
await using var transport = new Transport();
|
||||
var client = new PodcastApiClient(transport, () => "https://api.example/");
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => PodcastBroadcaster.StartAsync(client, "Denied",
|
||||
(_, _) => Task.FromException<IPodcastCapture>(new UnauthorizedAccessException("Camera denied")),
|
||||
TestContext.Current.CancellationToken));
|
||||
Assert.Equal(new[] { "complete" }, transport.Events);
|
||||
}
|
||||
|
||||
private sealed class FakeCapture : IPodcastCapture
|
||||
{
|
||||
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public string Directory { get; }
|
||||
public bool Stopped { get; private set; }
|
||||
public bool Disposed { get; private set; }
|
||||
public Task Completion => _completion.Task;
|
||||
public FakeCapture(string directory)
|
||||
{
|
||||
Directory = directory;
|
||||
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
|
||||
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
|
||||
}
|
||||
public Task StopAsync()
|
||||
{
|
||||
if (Stopped) return Task.CompletedTask;
|
||||
File.WriteAllBytes(Path.Combine(Directory, "segment-00000001.ts"), [0x47, 2]);
|
||||
File.AppendAllText(Path.Combine(Directory, "index.m3u8"), "#EXTINF:0.5,\nsegment-00000001.ts\n");
|
||||
Stopped = true;
|
||||
_completion.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
|
||||
}
|
||||
|
||||
private sealed class Transport : IYavscApiClient
|
||||
{
|
||||
private readonly PodcastResponse _podcast = new(Guid.NewGuid(), "Test", "alice", "Alice",
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<int> Uploads { get; } = [];
|
||||
public List<string> Events { get; } = [];
|
||||
public bool FailUpload { get; init; }
|
||||
public int CreateCount { get; private set; }
|
||||
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
|
||||
else CreateCount++;
|
||||
return Task.FromResult((T)(object)_podcast);
|
||||
}
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
{
|
||||
if (FailUpload) throw new HttpRequestException("Denied", null, HttpStatusCode.Forbidden);
|
||||
var sequence = int.Parse(new Uri(path).Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
|
||||
Uploads.Add(sequence);
|
||||
Events.Add($"upload:{sequence}");
|
||||
FirstUpload.TrySetResult();
|
||||
return Task.FromResult((T)(object)_podcast);
|
||||
}
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, body, ct);
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
146
src/PostIt/PostIt.Tests/TransportStreamCaptureTests.cs
Normal file
146
src/PostIt/PostIt.Tests/TransportStreamCaptureTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class TransportStreamCaptureTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Segments_on_IDR_and_flushes_final_segment_with_real_PTS_durations()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream();
|
||||
input.Write(Pat());
|
||||
input.Write(Pmt());
|
||||
for (var i = 0; i <= 120; i++) input.Write(Video(i * 3000L, i % 60 == 0));
|
||||
input.Position = 0;
|
||||
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(2, segments[0].DurationSeconds);
|
||||
Assert.Equal(2, segments[1].DurationSeconds);
|
||||
Assert.InRange(segments[2].DurationSeconds, 0.033, 0.034);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(0, bytes.Length % 188);
|
||||
Assert.Equal(Pat(), bytes[..188]);
|
||||
Assert.Equal(Pmt(), bytes[188..376]);
|
||||
Assert.Contains((byte)0x65, bytes[376..]);
|
||||
}
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Rejects_corrupt_or_truncated_transport_without_publishing_a_segment(bool truncated)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream(new byte[truncated ? 187 : 188]);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||||
new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken));
|
||||
Assert.False(File.Exists(Path.Combine(directory, "index.m3u8")));
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preserves_out_of_band_H264_configuration_at_every_segment_start()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream();
|
||||
input.Write(Pat());
|
||||
input.Write(Pmt());
|
||||
var configuration = Video(0, false);
|
||||
configuration[22] = 0x67;
|
||||
new byte[] { 0, 0, 0, 1, 0x68, 0x80 }.CopyTo(configuration, 25);
|
||||
input.Write(configuration);
|
||||
for (var i = 0; i <= 120; i++) input.Write(Video(90000 + i * 3000L, i % 60 == 0));
|
||||
input.Position = 0;
|
||||
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(3, segments.Count);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(0x67, bytes[376 + 22]);
|
||||
Assert.Equal(0x68, bytes[376 + 29]);
|
||||
Assert.Equal(0x65, bytes[564 + 22]);
|
||||
Assert.Equal((bytes[564 + 3] - 1) & 0x0f, bytes[376 + 3] & 0x0f);
|
||||
Assert.Equal(bytes[(564 + 13)..(564 + 18)], bytes[(376 + 13)..(376 + 18)]);
|
||||
}
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handles_33_bit_PTS_wrap_without_negative_duration()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream();
|
||||
input.Write(Pat());
|
||||
input.Write(Pmt());
|
||||
for (var i = 0; i <= 120; i++)
|
||||
input.Write(Video(((1L << 33) - 90000 + i * 3000L) & ((1L << 33) - 1), i % 60 == 0));
|
||||
input.Position = 0;
|
||||
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
|
||||
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(2, segments[0].DurationSeconds);
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
private static byte[] Packet(int pid)
|
||||
{
|
||||
var packet = Enumerable.Repeat((byte)0xff, 188).ToArray();
|
||||
packet[0] = 0x47;
|
||||
packet[1] = (byte)(0x40 | (pid >> 8));
|
||||
packet[2] = (byte)pid;
|
||||
packet[3] = 0x10;
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static byte[] Pat()
|
||||
{
|
||||
var packet = Packet(0);
|
||||
packet[4] = 0;
|
||||
new byte[] { 0, 0xb0, 13, 0, 1, 0xc1, 0, 0, 0, 1, 0xe1, 0 }.CopyTo(packet, 5);
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static byte[] Pmt()
|
||||
{
|
||||
var packet = Packet(256);
|
||||
packet[4] = 0;
|
||||
new byte[] { 2, 0xb0, 18, 0, 1, 0xc1, 0, 0, 0xe1, 1, 0xf0, 0,
|
||||
0x1b, 0xe1, 1, 0xf0, 0 }.CopyTo(packet, 5);
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static byte[] Video(long pts, bool idr)
|
||||
{
|
||||
var packet = Packet(257);
|
||||
new byte[] { 0, 0, 1, 0xe0, 0, 0, 0x84, 0x80, 5 }.CopyTo(packet, 4);
|
||||
packet[13] = (byte)(0x21 | ((pts >> 29) & 0xe));
|
||||
packet[14] = (byte)(pts >> 22);
|
||||
packet[15] = (byte)(1 | ((pts >> 14) & 0xfe));
|
||||
packet[16] = (byte)(pts >> 7);
|
||||
packet[17] = (byte)(1 | ((pts << 1) & 0xfe));
|
||||
new byte[] { 0, 0, 0, 1, idr ? (byte)0x65 : (byte)0x41, 0x88 }.CopyTo(packet, 18);
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue