diff --git a/Backend/App/MessageService.cs b/Backend/App/MessageService.cs index a3bfccc..a2a0384 100644 --- a/Backend/App/MessageService.cs +++ b/Backend/App/MessageService.cs @@ -178,6 +178,12 @@ public static async Task HandleMessage(string message) case "StopRecording": await Task.Run(OBSService.StopRecording); break; + case "StartStreaming": + await Task.Run(() => OBSService.StartStreaming()); + break; + case "StopStreaming": + await Task.Run(OBSService.StopStreaming); + break; case "NewConnection": Log.Information("NewConnection command received."); await SendSettingsToFrontend("New connection"); diff --git a/Backend/Core/Models/Settings.cs b/Backend/Core/Models/Settings.cs index 0f88082..d42c52b 100644 --- a/Backend/Core/Models/Settings.cs +++ b/Backend/Core/Models/Settings.cs @@ -66,6 +66,9 @@ internal class Settings private bool _enableSeparateAudioTracks = false; private string _videoQualityPreset = "high"; private string _clipQualityPreset = "high"; + private bool _enableStreaming = false; + private string _streamServer = "rtmp://live.twitch.tv/app/"; + private string _streamKey = ""; // Returns the default keybindings private static List GetDefaultKeybindings() @@ -694,6 +697,45 @@ public string ClipQualityPreset } } + [JsonPropertyName("enableStreaming")] + public bool EnableStreaming + { + get => _enableStreaming; + set + { + if (_enableStreaming != value) + { + _enableStreaming = value; + } + } + } + + [JsonPropertyName("streamServer")] + public string StreamServer + { + get => _streamServer; + set + { + if (_streamServer != value) + { + _streamServer = value; + } + } + } + + [JsonPropertyName("streamKey")] + public string StreamKey + { + get => _streamKey; + set + { + if (_streamKey != value) + { + _streamKey = value; + } + } + } + [JsonPropertyName("selectedOBSVersion")] public string? SelectedOBSVersion { @@ -828,6 +870,7 @@ internal class State : IDisposable private PreRecording? _preRecording = null; private Recording? _recording = null; private bool _hasLoadedObs = false; + private bool _isStreaming = false; private List _content = []; private List _inputDevices = []; @@ -925,6 +968,20 @@ public bool HasLoadedObs } } + [JsonPropertyName("isStreaming")] + public bool IsStreaming + { + get => _isStreaming; + set + { + if (_isStreaming != value) + { + _isStreaming = value; + SendToFrontend("State update: IsStreaming"); + } + } + } + [JsonPropertyName("content")] public List Content { diff --git a/Backend/Obs/OBSService.cs b/Backend/Obs/OBSService.cs index f5e39b7..36f3bf6 100644 --- a/Backend/Obs/OBSService.cs +++ b/Backend/Obs/OBSService.cs @@ -68,6 +68,8 @@ public static partial class OBSService // OBS output resources private static IntPtr _output = IntPtr.Zero; private static IntPtr _bufferOutput = IntPtr.Zero; + private static IntPtr _streamOutput = IntPtr.Zero; + private static IntPtr _streamService = IntPtr.Zero; // OBS source resources private static IntPtr _gameCaptureSource = IntPtr.Zero; @@ -1122,6 +1124,260 @@ public static async Task StopRecording() } } + public static bool StartStreaming() + { + if (!Settings.Instance.EnableStreaming) + { + Log.Warning("Streaming is not enabled in settings"); + return false; + } + + if (string.IsNullOrEmpty(Settings.Instance.StreamKey)) + { + Log.Warning("Stream key is not configured"); + _ = Task.Run(() => ShowModal("Stream Error", "Please configure your stream key in settings before streaming.", "error")); + return false; + } + + if (_streamOutput != IntPtr.Zero) + { + Log.Information("Stream is already active"); + return false; + } + + if (!IsInitialized) + { + Log.Warning("OBS is not initialized"); + return false; + } + + try + { + Log.Information("Starting Twitch stream..."); + + // Configure video settings for streaming + if (!ResetVideoSettings(customFps: (uint)Settings.Instance.FrameRate)) + { + throw new Exception("Failed to configure video settings for streaming."); + } + + // Create display capture source for streaming + if (_displaySource == IntPtr.Zero) + { + IntPtr displaySettings = obs_data_create(); + + // Select the display based on user's selected display + int? monitorIndex = Settings.Instance.State.Displays + .Select((d, i) => new { Display = d, Index = i }) + .Where(x => x.Display.DeviceId == Settings.Instance.SelectedDisplay?.DeviceId) + .Select(x => (int?)x.Index) + .FirstOrDefault(); + + if (monitorIndex.HasValue) + { + obs_data_set_int(displaySettings, "monitor", (uint)monitorIndex.Value); + } + else + { + _ = ShowModal("Display streaming", $"Could not find selected display. Defaulting to first automatically detected display.", "warning"); + } + + obs_data_set_bool(displaySettings, "capture_cursor", true); + _displaySource = obs_source_create("monitor_capture", "display", displaySettings, IntPtr.Zero); + obs_data_release(displaySettings); + + if (_displaySource != IntPtr.Zero) + { + obs_set_output_source(0, _displaySource); + } + } + + // Create video encoder for streaming + IntPtr videoEncoderSettings = obs_data_create(); + obs_data_set_string(videoEncoderSettings, "preset", "Quality"); + obs_data_set_string(videoEncoderSettings, "tune", "ll"); + obs_data_set_int(videoEncoderSettings, "keyint_sec", 2); + obs_data_set_string(videoEncoderSettings, "profile", "high"); + obs_data_set_bool(videoEncoderSettings, "use_bufsize", true); + + // Hardcode CBR at 6000 kbps for Twitch streaming + obs_data_set_string(videoEncoderSettings, "rate_control", "CBR"); + obs_data_set_int(videoEncoderSettings, "bitrate", 6000); + obs_data_set_int(videoEncoderSettings, "max_bitrate", 6000); + obs_data_set_int(videoEncoderSettings, "bufsize", 6000); + + string encoderId = Settings.Instance.Codec!.InternalEncoderId; + _videoEncoder = obs_video_encoder_create(encoderId, "Stream Encoder", videoEncoderSettings, IntPtr.Zero); + obs_encoder_set_video(_videoEncoder, obs_get_video()); + obs_data_release(videoEncoderSettings); + + // Setup audio sources and encoders + if (Settings.Instance.InputDevices != null && Settings.Instance.InputDevices.Count > 0) + { + int audioSourceIndex = 2; + foreach (var deviceSetting in Settings.Instance.InputDevices) + { + if (!string.IsNullOrEmpty(deviceSetting.Id)) + { + IntPtr micSettings = obs_data_create(); + obs_data_set_string(micSettings, "device_id", deviceSetting.Id); + string sourceName = $"Microphone_{_micSources.Count + 1}"; + IntPtr micSource = obs_source_create("wasapi_input_capture", sourceName, micSettings, IntPtr.Zero); + obs_data_release(micSettings); + SetForceMono(micSource, Settings.Instance.ForceMonoInputSources); + obs_source_set_volume(micSource, deviceSetting.Volume); + obs_set_output_source((uint)audioSourceIndex, micSource); + _micSources.Add(micSource); + audioSourceIndex++; + } + } + } + + if (Settings.Instance.OutputDevices != null && Settings.Instance.OutputDevices.Count > 0) + { + int desktopSourceIndex = _micSources.Count + 2; + foreach (var deviceSetting in Settings.Instance.OutputDevices) + { + if (!string.IsNullOrEmpty(deviceSetting.Id)) + { + IntPtr desktopSettings = obs_data_create(); + obs_data_set_string(desktopSettings, "device_id", deviceSetting.Id); + string sourceName = $"DesktopAudio_{_desktopSources.Count + 1}"; + IntPtr desktopSource = obs_source_create("wasapi_output_capture", sourceName, desktopSettings, IntPtr.Zero); + obs_data_release(desktopSettings); + obs_source_set_volume(desktopSource, 1.0f); + obs_set_output_source((uint)desktopSourceIndex, desktopSource); + _desktopSources.Add(desktopSource); + desktopSourceIndex++; + } + } + } + + // Create audio encoder for streaming + _audioEncoders.Clear(); + IntPtr audioEncoderSettings = obs_data_create(); + obs_data_set_int(audioEncoderSettings, "bitrate", 128); + IntPtr audioEncoder = obs_audio_encoder_create("ffmpeg_aac", "stream_audio_encoder", audioEncoderSettings, (UIntPtr)0, IntPtr.Zero); + obs_data_release(audioEncoderSettings); + obs_encoder_set_audio(audioEncoder, obs_get_audio()); + _audioEncoders.Add(audioEncoder); + + // Create service for RTMP streaming + IntPtr serviceSettings = obs_data_create(); + obs_data_set_string(serviceSettings, "server", Settings.Instance.StreamServer); + obs_data_set_string(serviceSettings, "key", Settings.Instance.StreamKey); + + _streamService = obs_service_create("rtmp_custom", "twitch_service", serviceSettings, IntPtr.Zero); + obs_data_release(serviceSettings); + + if (_streamService == IntPtr.Zero) + { + Log.Error("Failed to create streaming service"); + return false; + } + + // Create RTMP stream output + _streamOutput = obs_output_create("rtmp_output", "twitch_stream", IntPtr.Zero, IntPtr.Zero); + + if (_streamOutput == IntPtr.Zero) + { + Log.Error("Failed to create stream output"); + obs_service_release(_streamService); + _streamService = IntPtr.Zero; + return false; + } + + obs_output_set_video_encoder(_streamOutput, _videoEncoder); + obs_output_set_audio_encoder(_streamOutput, audioEncoder, 0); + obs_output_set_service(_streamOutput, _streamService); + + // Keep service alive - it will be released in DisposeStreamOutput() + + // Start streaming + if (!obs_output_start(_streamOutput)) + { + string error = obs_output_get_last_error(_streamOutput); + Log.Error($"Failed to start stream: {error}"); + _ = Task.Run(() => ShowModal("Stream Error", $"Failed to start stream: {error}", "error")); + DisposeStreamOutput(); + return false; + } + + Settings.Instance.State.IsStreaming = true; + _ = MessageService.SendSettingsToFrontend("Stream started"); + Log.Information("Twitch stream started successfully"); + _ = Task.Run(() => PlaySound("start", 50)); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error starting stream"); + _ = Task.Run(() => ShowModal("Stream Error", $"Failed to start stream: {ex.Message}", "error")); + DisposeStreamOutput(); + return false; + } + } + + public static async Task StopStreaming() + { + if (_streamOutput == IntPtr.Zero) + { + Log.Information("No active stream to stop"); + return; + } + + try + { + Log.Information("Stopping stream..."); + + obs_output_stop(_streamOutput); + + // Wait for stream to stop (up to 10 seconds) + int attempts = 0; + while (obs_output_active(_streamOutput) && attempts < 100) + { + await Task.Delay(100); + attempts++; + } + + if (obs_output_active(_streamOutput)) + { + Log.Warning("Stream did not stop gracefully, forcing stop"); + obs_output_force_stop(_streamOutput); + } + + DisposeStreamOutput(); + Settings.Instance.State.IsStreaming = false; + _ = MessageService.SendSettingsToFrontend("Stream stopped"); + Log.Information("Stream stopped successfully"); + } + catch (Exception ex) + { + Log.Error(ex, "Error stopping stream"); + DisposeStreamOutput(); + Settings.Instance.State.IsStreaming = false; + } + } + + private static void DisposeStreamOutput() + { + if (_streamOutput != IntPtr.Zero) + { + obs_output_release(_streamOutput); + _streamOutput = IntPtr.Zero; + } + + if (_streamService != IntPtr.Zero) + { + obs_service_release(_streamService); + _streamService = IntPtr.Zero; + } + + DisposeSources(); + DisposeEncoders(); + } + [System.Diagnostics.DebuggerStepThrough] private static void OnGameCaptureHooked(IntPtr data, calldata_t cd) { diff --git a/Backend/Services/SettingsService.cs b/Backend/Services/SettingsService.cs index 722d037..6daf2c5 100644 --- a/Backend/Services/SettingsService.cs +++ b/Backend/Services/SettingsService.cs @@ -359,6 +359,30 @@ private static void UpdateSettingsInstance(Settings updatedSettings) hasChanges = true; } + // Update EnableStreaming + if (settings.EnableStreaming != updatedSettings.EnableStreaming) + { + Log.Information($"EnableStreaming changed from '{settings.EnableStreaming}' to '{updatedSettings.EnableStreaming}'"); + settings.EnableStreaming = updatedSettings.EnableStreaming; + hasChanges = true; + } + + // Update StreamServer + if (settings.StreamServer != updatedSettings.StreamServer) + { + Log.Information($"StreamServer changed from '{settings.StreamServer}' to '{updatedSettings.StreamServer}'"); + settings.StreamServer = updatedSettings.StreamServer; + hasChanges = true; + } + + // Update StreamKey + if (settings.StreamKey != updatedSettings.StreamKey) + { + Log.Information($"StreamKey changed"); + settings.StreamKey = updatedSettings.StreamKey; + hasChanges = true; + } + // Update Theme if (settings.Theme != updatedSettings.Theme) { diff --git a/Frontend/src/Components/Settings/StreamingSettingsSection.tsx b/Frontend/src/Components/Settings/StreamingSettingsSection.tsx new file mode 100644 index 0000000..1b4024b --- /dev/null +++ b/Frontend/src/Components/Settings/StreamingSettingsSection.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Settings as SettingsType } from '../../Models/types'; +import { sendMessageToBackend } from '../../Utils/MessageUtils'; + +interface StreamingSettingsSectionProps { + settings: SettingsType; + updateSettings: (updates: Partial) => void; +} + +export default function StreamingSettingsSection({ + settings, + updateSettings, +}: StreamingSettingsSectionProps) { + const [localStreamServer, setLocalStreamServer] = useState(settings.streamServer); + const [localStreamKey, setLocalStreamKey] = useState(settings.streamKey); + + return ( +
+

Streaming Settings

+ + {/* Enable Streaming Toggle */} +
+ +
+ + {settings.enableStreaming && ( +
+ {/* Stream Server */} +
+ + setLocalStreamServer(e.target.value)} + onBlur={() => updateSettings({ streamServer: localStreamServer })} + placeholder="rtmp://live.twitch.tv/app/" + /> +
+ + {/* Stream Key */} +
+ + setLocalStreamKey(e.target.value)} + onBlur={() => updateSettings({ streamKey: localStreamKey })} + placeholder="Enter your Twitch stream key" + /> + {localStreamKey.length === 0 && ( + + )} +
+
+ )} +
+ ); +} diff --git a/Frontend/src/Models/types.ts b/Frontend/src/Models/types.ts index 1c52125..be2b835 100644 --- a/Frontend/src/Models/types.ts +++ b/Frontend/src/Models/types.ts @@ -30,6 +30,7 @@ export interface State { preRecording?: PreRecording; recording?: Recording; hasLoadedObs: boolean; + isStreaming: boolean; content: Content[]; inputDevices: AudioDevice[]; outputDevices: AudioDevice[]; @@ -208,6 +209,9 @@ export interface Settings { enableSeparateAudioTracks: boolean; // Advanced: per-source audio tracks videoQualityPreset: VideoQualityPreset; clipQualityPreset: ClipQualityPreset; + enableStreaming: boolean; + streamServer: string; + streamKey: string; state: State; } @@ -215,6 +219,7 @@ export const initialState: State = { gpuVendor: GpuVendor.Unknown, recording: undefined, hasLoadedObs: false, + isStreaming: false, content: [], inputDevices: [], outputDevices: [], @@ -268,6 +273,9 @@ export const initialSettings: Settings = { enableSeparateAudioTracks: false, videoQualityPreset: 'custom', clipQualityPreset: 'custom', + enableStreaming: false, + streamServer: 'rtmp://live.twitch.tv/app/', + streamKey: '', keybindings: [ { keys: [119], action: KeybindAction.CreateBookmark, enabled: true }, // 119 is F8 { keys: [121], action: KeybindAction.SaveReplayBuffer, enabled: true }, // 121 is F10 diff --git a/Frontend/src/Pages/settings.tsx b/Frontend/src/Pages/settings.tsx index d6921c5..0bced68 100644 --- a/Frontend/src/Pages/settings.tsx +++ b/Frontend/src/Pages/settings.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../Hooks/useAuth'; import AccountSection from '../Components/Settings/AccountSection'; import CaptureModeSection from '../Components/Settings/CaptureModeSection'; import VideoSettingsSection from '../Components/Settings/VideoSettingsSection'; +import StreamingSettingsSection from '../Components/Settings/StreamingSettingsSection'; import StorageSettingsSection from '../Components/Settings/StorageSettingsSection'; import ClipSettingsSection from '../Components/Settings/ClipSettingsSection'; import AudioDevicesSection from '../Components/Settings/AudioDevicesSection'; @@ -27,6 +28,8 @@ export default function Settings() { + + diff --git a/Frontend/src/menu.tsx b/Frontend/src/menu.tsx index abe41d9..daed569 100644 --- a/Frontend/src/menu.tsx +++ b/Frontend/src/menu.tsx @@ -227,7 +227,31 @@ export default function Menu({ selectedMenu, onSelectMenu }: MenuProps) { {/* Start and Stop Buttons */}
-
+
+ {/* Streaming Button */} + {settings.enableStreaming && ( +
+ {settings.state.isStreaming ? ( + + ) : ( + + )} +
+ )} + + {/* Recording Button */} {settings.state.recording ? (