diff --git a/App/ttaccessible/AppDelegate.swift b/App/ttaccessible/AppDelegate.swift index 49a1ad6..9e5e1d5 100644 --- a/App/ttaccessible/AppDelegate.swift +++ b/App/ttaccessible/AppDelegate.swift @@ -77,8 +77,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var userAccountsWindowController: NSWindowController? private var bannedUsersWindowController: NSWindowController? private var userInfoWindowController: UserInfoWindowController? - private var mediaStreamingPlayerWindowController: MediaStreamingPlayerWindowController? - private weak var mediaStreamingPlayerViewController: MediaStreamingPlayerViewController? private weak var savedServersViewController: SavedServersViewController? private weak var connectedServerViewController: ConnectedServerViewController? private weak var privateMessagesViewController: PrivateMessagesViewController? @@ -1259,7 +1257,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panel.title = L10n.text("mediaStream.panel.title") panel.message = L10n.text("mediaStream.panel.message") panel.prompt = L10n.text("mediaStream.panel.choose") - panel.allowedContentTypes = [.audio, .mp3, .mpeg4Audio, .wav, .aiff] + panel.allowedContentTypes = [.audio, .mp3, .mpeg4Audio, .wav, .aiff, .movie, .mpeg4Movie, .video, .avi, .quickTimeMovie] guard let parentWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first else { return } panel.beginSheetModal(for: parentWindow) { [weak self] response in guard response == .OK, let url = panel.url, let self else { return } @@ -2095,64 +2093,11 @@ extension AppDelegate: TeamTalkConnectionControllerDelegate { func teamTalkConnectionController(_ controller: TeamTalkConnectionController, didUpdateMediaStreamingProgress progress: MediaStreamingProgress) { menuState.setMediaStreamingActive(progress.isActive) - if progress.isActive { - showMediaStreamingPlayerWindow(with: progress) - } else { - closeMediaStreamingPlayerWindow() - } - } -} - -extension AppDelegate: MediaStreamingPlayerActions { - func mediaStreamingPlayerDidTogglePlayPause() { - connectionController.toggleMediaStreamingPaused() - } - - func mediaStreamingPlayerDidStop() { - connectionController.stopStreamingMediaFile() - } - - func mediaStreamingPlayerDidSeek(toMSec offsetMSec: UInt32) { - connectionController.seekMediaStreaming(toMSec: offsetMSec) - } - - func mediaStreamingPlayerDidChangeBroadcastGainPercent(_ percent: Int) { - connectionController.setMediaStreamingBroadcastGainPercent(percent) - } -} - -private extension AppDelegate { - func showMediaStreamingPlayerWindow(with progress: MediaStreamingProgress) { - let viewController: MediaStreamingPlayerViewController - if let existing = mediaStreamingPlayerViewController { - viewController = existing - } else { - let newViewController = MediaStreamingPlayerViewController() - newViewController.actions = self - mediaStreamingPlayerViewController = newViewController - viewController = newViewController - } - - if mediaStreamingPlayerWindowController == nil { - let controller = MediaStreamingPlayerWindowController(contentViewController: viewController) - controller.onCloseRequested = { [weak self] in - self?.connectionController.stopStreamingMediaFile() - } - mediaStreamingPlayerWindowController = controller - } - - viewController.update(with: progress) - - if let window = mediaStreamingPlayerWindowController?.window, !window.isVisible { - mediaStreamingPlayerWindowController?.showWindow(nil) - window.makeKeyAndOrderFront(nil) - } + connectedServerViewController?.applyMediaStreamingProgress(progress) } - func closeMediaStreamingPlayerWindow() { - mediaStreamingPlayerWindowController?.close() - mediaStreamingPlayerWindowController = nil - mediaStreamingPlayerViewController = nil + func teamTalkConnectionController(_ controller: TeamTalkConnectionController, didUpdateVideoDisplay state: VideoDisplayState) { + connectedServerViewController?.applyVideoDisplay(state) } } diff --git a/App/ttaccessible/AppKit/ChannelFilesViewController.swift b/App/ttaccessible/AppKit/ChannelFilesViewController.swift index c7c3248..9f7cddd 100644 --- a/App/ttaccessible/AppKit/ChannelFilesViewController.swift +++ b/App/ttaccessible/AppKit/ChannelFilesViewController.swift @@ -154,7 +154,8 @@ final class ChannelFilesViewController: NSViewController { outputGainDB: session.outputGainDB, recordingActive: session.recordingActive, mediaStreamingActive: session.mediaStreamingActive, - mediaStreamingFileName: session.mediaStreamingFileName + mediaStreamingFileName: session.mediaStreamingFileName, + mediaStreamingHasVideo: session.mediaStreamingHasVideo ) announceNewTransfers(transfers) if oldUploads != activeUploadTransfers { diff --git a/App/ttaccessible/AppKit/CollapsibleVideoPanelView.swift b/App/ttaccessible/AppKit/CollapsibleVideoPanelView.swift new file mode 100644 index 0000000..f7453cd --- /dev/null +++ b/App/ttaccessible/AppKit/CollapsibleVideoPanelView.swift @@ -0,0 +1,105 @@ +// +// CollapsibleVideoPanelView.swift +// ttaccessible +// + +import AppKit + +@MainActor +protocol CollapsibleVideoPanelViewDelegate: AnyObject { + func collapsibleVideoPanelViewDidToggleExpanded(_ view: CollapsibleVideoPanelView, expanded: Bool) +} + +final class CollapsibleVideoPanelView: NSView { + weak var delegate: CollapsibleVideoPanelViewDelegate? + + private(set) var isExpanded = true + private let toggleButton = NSButton() + private let videoFrameView = VideoFrameView() + private var expandedHeightConstraint: NSLayoutConstraint? + private var collapsedHeightConstraint: NSLayoutConstraint? + + var videoView: VideoFrameView { videoFrameView } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + func setExpanded(_ expanded: Bool, notifyDelegate: Bool) { + guard isExpanded != expanded else { return } + isExpanded = expanded + applyExpandedState() + if notifyDelegate { + delegate?.collapsibleVideoPanelViewDidToggleExpanded(self, expanded: expanded) + } + } + + func updateVideoState(_ state: VideoDisplayState) { + if state.userID == 0 || state.frame == nil { + videoFrameView.update(frame: nil) + videoFrameView.setAccessibilitySourceLabel("") + } else { + videoFrameView.update(frame: state.frame) + videoFrameView.setAccessibilitySourceLabel( + L10n.format("video.panel.source.mediaFile", state.displayName) + ) + } + } + + private func configureUI() { + toggleButton.bezelStyle = .disclosure + toggleButton.setButtonType(.switch) + toggleButton.title = L10n.text("video.panel.toggle.title") + toggleButton.state = .on + toggleButton.target = self + toggleButton.action = #selector(toggleExpanded) + toggleButton.setAccessibilityLabel(L10n.text("video.panel.toggle.accessibilityLabel")) + + videoFrameView.translatesAutoresizingMaskIntoConstraints = false + toggleButton.translatesAutoresizingMaskIntoConstraints = false + translatesAutoresizingMaskIntoConstraints = false + + addSubview(toggleButton) + addSubview(videoFrameView) + + expandedHeightConstraint = videoFrameView.heightAnchor.constraint(equalToConstant: 220) + collapsedHeightConstraint = videoFrameView.heightAnchor.constraint(equalToConstant: 0) + collapsedHeightConstraint?.priority = .defaultHigh + + NSLayoutConstraint.activate([ + toggleButton.topAnchor.constraint(equalTo: topAnchor), + toggleButton.leadingAnchor.constraint(equalTo: leadingAnchor), + toggleButton.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + + videoFrameView.topAnchor.constraint(equalTo: toggleButton.bottomAnchor, constant: 6), + videoFrameView.leadingAnchor.constraint(equalTo: leadingAnchor), + videoFrameView.trailingAnchor.constraint(equalTo: trailingAnchor), + videoFrameView.bottomAnchor.constraint(equalTo: bottomAnchor), + expandedHeightConstraint! + ]) + + applyExpandedState() + } + + private func applyExpandedState() { + toggleButton.state = isExpanded ? .on : .off + videoFrameView.isHidden = !isExpanded + if isExpanded { + collapsedHeightConstraint?.isActive = false + expandedHeightConstraint?.isActive = true + } else { + expandedHeightConstraint?.isActive = false + collapsedHeightConstraint?.isActive = true + } + } + + @objc private func toggleExpanded() { + setExpanded(toggleButton.state == .on, notifyDelegate: true) + } +} diff --git a/App/ttaccessible/AppKit/ConnectedServerViewController+OutlineDelegate.swift b/App/ttaccessible/AppKit/ConnectedServerViewController+OutlineDelegate.swift index edbdbe5..c3d9c8b 100644 --- a/App/ttaccessible/AppKit/ConnectedServerViewController+OutlineDelegate.swift +++ b/App/ttaccessible/AppKit/ConnectedServerViewController+OutlineDelegate.swift @@ -84,6 +84,7 @@ extension ConnectedServerViewController: NSOutlineViewDelegate { func outlineViewSelectionDidChange(_ notification: Notification) { selectedKey = currentSelectionKey() updateMenuState() + updateVideoSelectionFromTree() } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { diff --git a/App/ttaccessible/AppKit/ConnectedServerViewController.swift b/App/ttaccessible/AppKit/ConnectedServerViewController.swift index cfa69de..118e159 100644 --- a/App/ttaccessible/AppKit/ConnectedServerViewController.swift +++ b/App/ttaccessible/AppKit/ConnectedServerViewController.swift @@ -45,6 +45,9 @@ final class ConnectedServerViewController: NSViewController { let messageField = NSTextField(frame: .zero) let sendButton = NSButton(title: "", target: nil, action: nil) let microphoneButton = NSButton(title: "", target: nil, action: nil) + let collapsibleVideoPanel = CollapsibleVideoPanelView() + let embeddedMediaStreamingControls = MediaStreamingPlayerViewController() + var lastVideoDisplayState = VideoDisplayState.empty lazy var inputGainControl = AudioGainControlView( title: L10n.text("connectedServer.audio.inputGain.label"), accessibilityLabel: L10n.text("connectedServer.audio.inputGain.accessibilityLabel") @@ -101,6 +104,8 @@ final class ConnectedServerViewController: NSViewController { self.menuState = menuState self.appDelegate = appDelegate super.init(nibName: nil, bundle: nil) + embeddedMediaStreamingControls.actions = self + collapsibleVideoPanel.delegate = self } @available(*, unavailable) @@ -405,6 +410,12 @@ final class ConnectedServerViewController: NSViewController { microphoneButton.action = #selector(toggleMicrophone) microphoneButton.bezelStyle = .rounded + collapsibleVideoPanel.setExpanded(preferencesStore.preferences.videoPanelExpanded, notifyDelegate: false) + collapsibleVideoPanel.translatesAutoresizingMaskIntoConstraints = false + + addChild(embeddedMediaStreamingControls) + embeddedMediaStreamingControls.view.translatesAutoresizingMaskIntoConstraints = false + // -- Layout en colonne unique -- // Ordre : titre, statut, recherche, liste canaux, gains, audio, chat, message, historique @@ -418,14 +429,24 @@ final class ConnectedServerViewController: NSViewController { chatScrollView.translatesAutoresizingMaskIntoConstraints = false historyScrollView.translatesAutoresizingMaskIntoConstraints = false + let audioControlsStack = NSStackView(views: [ + outputGainControl, + inputGainControl, + embeddedMediaStreamingControls.view + ]) + audioControlsStack.orientation = .vertical + audioControlsStack.alignment = .leading + audioControlsStack.spacing = 8 + audioControlsStack.translatesAutoresizingMaskIntoConstraints = false + let mainStack = NSStackView(views: [ titleLabel, statusLabel, audioStatusLabel, microphoneButton, channelsScrollView, - outputGainControl, - inputGainControl, + collapsibleVideoPanel, + audioControlsStack, chatTitleLabel, chatScrollView, inputStack, @@ -446,8 +467,11 @@ final class ConnectedServerViewController: NSViewController { mainStack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20), channelsScrollView.widthAnchor.constraint(equalTo: mainStack.widthAnchor), channelsScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 200), - outputGainControl.widthAnchor.constraint(equalTo: mainStack.widthAnchor), - inputGainControl.widthAnchor.constraint(equalTo: mainStack.widthAnchor), + collapsibleVideoPanel.widthAnchor.constraint(equalTo: mainStack.widthAnchor), + audioControlsStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor), + outputGainControl.widthAnchor.constraint(equalTo: audioControlsStack.widthAnchor), + inputGainControl.widthAnchor.constraint(equalTo: audioControlsStack.widthAnchor), + embeddedMediaStreamingControls.view.widthAnchor.constraint(equalTo: audioControlsStack.widthAnchor), chatScrollView.widthAnchor.constraint(equalTo: mainStack.widthAnchor), chatScrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), inputStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor), @@ -493,6 +517,7 @@ final class ConnectedServerViewController: NSViewController { } updateChatInputState() updateAudioControls() + updateVideoSelectionFromTree() // Only reload the outline when the channel tree or user list changed. let treeChanged = previousSession.rootChannels != session.rootChannels @@ -561,7 +586,8 @@ final class ConnectedServerViewController: NSViewController { outputGainDB: session.outputGainDB, recordingActive: session.recordingActive, mediaStreamingActive: session.mediaStreamingActive, - mediaStreamingFileName: session.mediaStreamingFileName + mediaStreamingFileName: session.mediaStreamingFileName, + mediaStreamingHasVideo: session.mediaStreamingHasVideo ) updateAudioControls() @@ -569,6 +595,33 @@ final class ConnectedServerViewController: NSViewController { updateMenuState() } + func applyVideoDisplay(_ state: VideoDisplayState) { + lastVideoDisplayState = state + collapsibleVideoPanel.updateVideoState(state) + } + + func applyMediaStreamingProgress(_ progress: MediaStreamingProgress) { + embeddedMediaStreamingControls.update(with: progress) + } + + func updateVideoSelectionFromTree() { + guard case .user(let user)? = selectedNode else { + if session.mediaStreamingActive, session.mediaStreamingHasVideo, let me = session.currentUser { + connectionController.setActiveVideoDisplayFromSelection( + userID: me.id, + hasMediaVideo: true + ) + } else { + connectionController.setActiveVideoDisplayFromSelection(userID: 0, hasMediaVideo: false) + } + return + } + connectionController.setActiveVideoDisplayFromSelection( + userID: user.id, + hasMediaVideo: user.isStreamingMediaFileVideo + ) + } + func updateMenuState() { let selectedUsers = selectedUserNodes() .filter { $0.isCurrentUser == false } @@ -673,6 +726,7 @@ final class ConnectedServerViewController: NSViewController { isTalking: update.isTalking, isMuted: update.isMuted, isMediaFileMuted: update.isMediaFileMuted, + isStreamingMediaFileVideo: user.isStreamingMediaFileVideo, isAway: user.isAway, isQuestion: user.isQuestion, ipAddress: user.ipAddress, @@ -751,7 +805,8 @@ final class ConnectedServerViewController: NSViewController { outputGainDB: session.outputGainDB, recordingActive: session.recordingActive, mediaStreamingActive: session.mediaStreamingActive, - mediaStreamingFileName: session.mediaStreamingFileName + mediaStreamingFileName: session.mediaStreamingFileName, + mediaStreamingHasVideo: session.mediaStreamingHasVideo ) updateAudioControls() } @@ -788,7 +843,8 @@ final class ConnectedServerViewController: NSViewController { outputGainDB: normalized, recordingActive: session.recordingActive, mediaStreamingActive: session.mediaStreamingActive, - mediaStreamingFileName: session.mediaStreamingFileName + mediaStreamingFileName: session.mediaStreamingFileName, + mediaStreamingHasVideo: session.mediaStreamingHasVideo ) updateAudioControls() } @@ -1330,6 +1386,31 @@ extension ConnectedServerViewController: ConnectedServerOutlineViewActionDelegat } } +extension ConnectedServerViewController: MediaStreamingPlayerActions { + func mediaStreamingPlayerDidTogglePlayPause() { + connectionController.toggleMediaStreamingPaused() + } + + func mediaStreamingPlayerDidStop() { + connectionController.stopStreamingMediaFile() + announce(L10n.text("mediaStream.announced.finished")) + } + + func mediaStreamingPlayerDidSeek(toMSec offsetMSec: UInt32) { + connectionController.seekMediaStreaming(toMSec: offsetMSec) + } + + func mediaStreamingPlayerDidChangeBroadcastGainPercent(_ percent: Int) { + connectionController.setMediaStreamingBroadcastGainPercent(percent) + } +} + +extension ConnectedServerViewController: CollapsibleVideoPanelViewDelegate { + func collapsibleVideoPanelViewDidToggleExpanded(_ view: CollapsibleVideoPanelView, expanded: Bool) { + preferencesStore.updateVideoPanelExpanded(expanded) + } +} + extension ConnectedServerViewController: NSUserInterfaceValidations { func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { diff --git a/App/ttaccessible/AppKit/MediaStreamingPlayerViewController.swift b/App/ttaccessible/AppKit/MediaStreamingPlayerViewController.swift index 9096ea6..26f32ce 100644 --- a/App/ttaccessible/AppKit/MediaStreamingPlayerViewController.swift +++ b/App/ttaccessible/AppKit/MediaStreamingPlayerViewController.swift @@ -13,6 +13,7 @@ protocol MediaStreamingPlayerActions: AnyObject { func mediaStreamingPlayerDidChangeBroadcastGainPercent(_ percent: Int) } +/// Compact embedded playback controls (no separate window). final class MediaStreamingPlayerViewController: NSViewController { weak var actions: MediaStreamingPlayerActions? @@ -30,27 +31,13 @@ final class MediaStreamingPlayerViewController: NSViewController { private var suppressGainAction = false override func loadView() { - let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: 440, height: 320)) - visualEffectView.material = .underWindowBackground - visualEffectView.blendingMode = .behindWindow - visualEffectView.state = .active - view = visualEffectView + view = NSView() configureUI() } - override func viewDidAppear() { - super.viewDidAppear() - startDisplayTimer() - view.window?.makeFirstResponder(view) - } - - override func viewWillDisappear() { - super.viewWillDisappear() - stopDisplayTimer() - } - func update(with progress: MediaStreamingProgress) { lastProgress = progress + view.isHidden = !progress.isActive if let fileName = progress.fileName { fileNameLabel.stringValue = L10n.format("mediaPlayer.fileName.format", fileName) @@ -78,8 +65,6 @@ final class MediaStreamingPlayerViewController: NSViewController { refreshTimeLabel() } - // MARK: - Keyboard - override var acceptsFirstResponder: Bool { true } override func keyDown(with event: NSEvent) { @@ -87,41 +72,21 @@ final class MediaStreamingPlayerViewController: NSViewController { let noModifiers = modifiers.isDisjoint(with: [.command, .option, .control, .shift]) switch event.keyCode { - case 49: // Space - if noModifiers { - actions?.mediaStreamingPlayerDidTogglePlayPause() - return - } - case 53: // Escape - if noModifiers { - actions?.mediaStreamingPlayerDidStop() - return - } - case 123: // Left arrow - if noModifiers { - seekDelta(seconds: -5) - return - } - case 124: // Right arrow - if noModifiers { - seekDelta(seconds: +5) - return - } - case 126: // Up arrow - if noModifiers { - adjustBroadcastGain(delta: +5) - return - } - case 125: // Down arrow - if noModifiers { - adjustBroadcastGain(delta: -5) - return - } + case 49 where noModifiers: + actions?.mediaStreamingPlayerDidTogglePlayPause() + case 53 where noModifiers: + actions?.mediaStreamingPlayerDidStop() + case 123 where noModifiers: + seekDelta(seconds: -5) + case 124 where noModifiers: + seekDelta(seconds: +5) + case 126 where noModifiers: + adjustBroadcastGain(delta: +5) + case 125 where noModifiers: + adjustBroadcastGain(delta: -5) default: - break + super.keyDown(with: event) } - - super.keyDown(with: event) } private func seekDelta(seconds: Int) { @@ -138,8 +103,6 @@ final class MediaStreamingPlayerViewController: NSViewController { actions?.mediaStreamingPlayerDidChangeBroadcastGainPercent(newValue) } - // MARK: - Actions (controls) - @objc private func playPauseButtonClicked() { actions?.mediaStreamingPlayerDidTogglePlayPause() } @@ -156,8 +119,7 @@ final class MediaStreamingPlayerViewController: NSViewController { return } isUserDraggingPosition = false - let target = UInt32(max(0, sender.doubleValue)) - actions?.mediaStreamingPlayerDidSeek(toMSec: target) + actions?.mediaStreamingPlayerDidSeek(toMSec: UInt32(max(0, sender.doubleValue))) } @objc private func broadcastGainSliderAction(_ sender: NSSlider) { @@ -165,22 +127,15 @@ final class MediaStreamingPlayerViewController: NSViewController { actions?.mediaStreamingPlayerDidChangeBroadcastGainPercent(Int(sender.doubleValue.rounded())) } - // MARK: - Display refresh - - private func startDisplayTimer() { - stopDisplayTimer() - let timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + private func startDisplayTimerIfNeeded() { + guard displayTimer == nil else { return } + displayTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in MainActor.assumeIsolated { - self?.refreshTimeLabel() - self?.refreshPositionSliderIfNeeded() + guard let self, self.lastProgress.isActive else { return } + self.refreshTimeLabel() + self.refreshPositionSliderIfNeeded() } } - displayTimer = timer - } - - private func stopDisplayTimer() { - displayTimer?.invalidate() - displayTimer = nil } private func refreshTimeLabel() { @@ -207,43 +162,32 @@ final class MediaStreamingPlayerViewController: NSViewController { } private func updatePositionLabelPreview(forSliderValue value: Double) { - let elapsed = UInt32(max(0, value)) - timeLabel.stringValue = "\(formatMSec(elapsed)) / \(formatMSec(lastProgress.durationMSec))" + timeLabel.stringValue = "\(formatMSec(UInt32(max(0, value)))) / \(formatMSec(lastProgress.durationMSec))" } private func formatMSec(_ msec: UInt32) -> String { let totalSec = Int(msec / 1000) - let m = totalSec / 60 - let s = totalSec % 60 - return String(format: "%02d:%02d", m, s) + return String(format: "%02d:%02d", totalSec / 60, totalSec % 60) } - // MARK: - UI - private func configureUI() { - fileNameLabel.font = .preferredFont(forTextStyle: .title3) + view.isHidden = true + fileNameLabel.lineBreakMode = .byTruncatingMiddle fileNameLabel.maximumNumberOfLines = 1 - fileNameLabel.setAccessibilityRole(.staticText) - timeLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + timeLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) timeLabel.textColor = .secondaryLabelColor positionSlider.target = self positionSlider.action = #selector(positionSliderAction(_:)) - positionSlider.minValue = 0 - positionSlider.maxValue = 1 - positionSlider.doubleValue = 0 positionSlider.isContinuous = true - positionSlider.isEnabled = false - positionSlider.pageStep = 10_000 // 10 seconds per Page Up / Page Down + positionSlider.pageStep = 10_000 positionSlider.setAccessibilityLabel(L10n.text("mediaPlayer.position.label")) playPauseButton.bezelStyle = .rounded - playPauseButton.title = L10n.text("mediaPlayer.pause") playPauseButton.target = self playPauseButton.action = #selector(playPauseButtonClicked) - playPauseButton.keyEquivalent = "" stopButton.bezelStyle = .rounded stopButton.title = L10n.text("mediaPlayer.stop") @@ -254,56 +198,36 @@ final class MediaStreamingPlayerViewController: NSViewController { broadcastGainSlider.action = #selector(broadcastGainSliderAction(_:)) broadcastGainSlider.minValue = 0 broadcastGainSlider.maxValue = 100 - broadcastGainSlider.doubleValue = 50 broadcastGainSlider.isContinuous = true broadcastGainSlider.setAccessibilityLabel(L10n.text("mediaPlayer.broadcastGain.label")) - let controlsRow = NSStackView(views: [playPauseButton, stopButton]) + broadcastGainValueLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + + let controlsRow = NSStackView(views: [playPauseButton, stopButton, timeLabel]) controlsRow.orientation = .horizontal controlsRow.spacing = 8 - let broadcastGainTitle = NSTextField(labelWithString: L10n.text("mediaPlayer.broadcastGain.label")) - broadcastGainTitle.font = .boldSystemFont(ofSize: NSFont.systemFontSize) - let broadcastGainRow = NSStackView(views: [broadcastGainSlider, broadcastGainValueLabel]) - broadcastGainRow.orientation = .horizontal - broadcastGainRow.spacing = 8 - broadcastGainRow.distribution = .fill + let gainRow = NSStackView(views: [broadcastGainSlider, broadcastGainValueLabel]) + gainRow.orientation = .horizontal + gainRow.spacing = 8 broadcastGainSlider.setContentHuggingPriority(.defaultLow, for: .horizontal) - broadcastGainValueLabel.setContentHuggingPriority(.required, for: .horizontal) - broadcastGainValueLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - - let hint = NSTextField(labelWithString: L10n.text("mediaPlayer.shortcuts.hint")) - hint.textColor = .tertiaryLabelColor - hint.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - hint.lineBreakMode = .byWordWrapping - hint.maximumNumberOfLines = 0 - - let stack = NSStackView(views: [ - fileNameLabel, - timeLabel, - positionSlider, - controlsRow, - broadcastGainTitle, - broadcastGainRow, - hint - ]) + + let stack = NSStackView(views: [fileNameLabel, positionSlider, controlsRow, gainRow]) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 8 + stack.spacing = 6 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20), - stack.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -20), - - positionSlider.leadingAnchor.constraint(equalTo: stack.leadingAnchor), - positionSlider.trailingAnchor.constraint(equalTo: stack.trailingAnchor), - - broadcastGainRow.leadingAnchor.constraint(equalTo: stack.leadingAnchor), - broadcastGainRow.trailingAnchor.constraint(equalTo: stack.trailingAnchor) + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + positionSlider.widthAnchor.constraint(equalTo: stack.widthAnchor), + gainRow.widthAnchor.constraint(equalTo: stack.widthAnchor) ]) + + startDisplayTimerIfNeeded() } } diff --git a/App/ttaccessible/AppKit/MediaStreamingPlayerWindowController.swift b/App/ttaccessible/AppKit/MediaStreamingPlayerWindowController.swift deleted file mode 100644 index d58b1bc..0000000 --- a/App/ttaccessible/AppKit/MediaStreamingPlayerWindowController.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// MediaStreamingPlayerWindowController.swift -// ttaccessible -// - -import AppKit - -final class MediaStreamingPlayerWindowController: NSWindowController, NSWindowDelegate { - var onCloseRequested: (() -> Void)? - - init(contentViewController: NSViewController) { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 440, height: 320), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.title = L10n.text("mediaPlayer.window.title") - window.isReleasedWhenClosed = false - window.center() - window.contentViewController = contentViewController - super.init(window: window) - window.delegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - nil - } - - func windowShouldClose(_ sender: NSWindow) -> Bool { - onCloseRequested?() - return false - } -} diff --git a/App/ttaccessible/AppKit/VideoFrameView.swift b/App/ttaccessible/AppKit/VideoFrameView.swift new file mode 100644 index 0000000..cefb9e6 --- /dev/null +++ b/App/ttaccessible/AppKit/VideoFrameView.swift @@ -0,0 +1,72 @@ +// +// VideoFrameView.swift +// ttaccessible +// + +import AppKit + +final class VideoFrameView: NSView { + private let imageLayer = CALayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + imageLayer.contentsGravity = .resizeAspect + layer?.addSublayer(imageLayer) + setAccessibilityRole(.image) + setAccessibilityLabel(L10n.text("video.panel.placeholder")) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func layout() { + super.layout() + imageLayer.frame = bounds + } + + func update(frame payload: VideoFramePayload?) { + guard let payload, payload.isEmpty == false else { + imageLayer.contents = nil + return + } + + let width = payload.width + let height = payload.height + let bytesPerRow = width * 4 + guard payload.pixels.count >= bytesPerRow * height else { return } + + guard let provider = CGDataProvider(data: payload.pixels as CFData) else { return } + let bitmapInfo = CGBitmapInfo(rawValue: + CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.noneSkipFirst.rawValue + ) + guard let image = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { + return + } + + imageLayer.contents = image + } + + func setAccessibilitySourceLabel(_ label: String) { + if label.isEmpty { + setAccessibilityLabel(L10n.text("video.panel.placeholder")) + } else { + setAccessibilityLabel(label) + } + } +} diff --git a/App/ttaccessible/Models/AppPreferences.swift b/App/ttaccessible/Models/AppPreferences.swift index f35ade2..7d9df24 100644 --- a/App/ttaccessible/Models/AppPreferences.swift +++ b/App/ttaccessible/Models/AppPreferences.swift @@ -102,6 +102,7 @@ struct AppPreferences: Codable, Equatable { case includeBetaUpdates case microphoneMode case pushToTalkBeepEnabled + case videoPanelExpanded } var defaultNickname: String @@ -155,6 +156,7 @@ struct AppPreferences: Codable, Equatable { var includeBetaUpdates: Bool var microphoneMode: MicrophoneMode var pushToTalkBeepEnabled: Bool + var videoPanelExpanded: Bool init( defaultNickname: String = "TTAccessible", defaultStatusMessage: String = "", @@ -206,7 +208,8 @@ struct AppPreferences: Codable, Equatable { autoCheckForUpdates: Bool = true, includeBetaUpdates: Bool = false, microphoneMode: MicrophoneMode = .alwaysOn, - pushToTalkBeepEnabled: Bool = true + pushToTalkBeepEnabled: Bool = true, + videoPanelExpanded: Bool = true ) { self.defaultNickname = defaultNickname self.defaultStatusMessage = defaultStatusMessage @@ -259,6 +262,7 @@ struct AppPreferences: Codable, Equatable { self.includeBetaUpdates = includeBetaUpdates self.microphoneMode = microphoneMode self.pushToTalkBeepEnabled = pushToTalkBeepEnabled + self.videoPanelExpanded = videoPanelExpanded } nonisolated static func clampGainDB(_ value: Double) -> Double { @@ -353,6 +357,7 @@ struct AppPreferences: Codable, Equatable { includeBetaUpdates = try container.decodeIfPresent(Bool.self, forKey: .includeBetaUpdates) ?? false microphoneMode = try container.decodeIfPresent(MicrophoneMode.self, forKey: .microphoneMode) ?? .alwaysOn pushToTalkBeepEnabled = try container.decodeIfPresent(Bool.self, forKey: .pushToTalkBeepEnabled) ?? true + videoPanelExpanded = try container.decodeIfPresent(Bool.self, forKey: .videoPanelExpanded) ?? true } func encode(to encoder: Encoder) throws { @@ -408,6 +413,7 @@ struct AppPreferences: Codable, Equatable { try container.encode(includeBetaUpdates, forKey: .includeBetaUpdates) try container.encode(microphoneMode, forKey: .microphoneMode) try container.encode(pushToTalkBeepEnabled, forKey: .pushToTalkBeepEnabled) + try container.encode(videoPanelExpanded, forKey: .videoPanelExpanded) } func isSubscriptionEnabledByDefault(_ option: UserSubscriptionOption) -> Bool { diff --git a/App/ttaccessible/Models/ConnectedServerSession.swift b/App/ttaccessible/Models/ConnectedServerSession.swift index ba940d8..2898f23 100644 --- a/App/ttaccessible/Models/ConnectedServerSession.swift +++ b/App/ttaccessible/Models/ConnectedServerSession.swift @@ -21,6 +21,7 @@ struct ConnectedServerUser: Equatable, Identifiable { let isTalking: Bool let isMuted: Bool let isMediaFileMuted: Bool + let isStreamingMediaFileVideo: Bool let isAway: Bool let isQuestion: Bool let ipAddress: String @@ -162,6 +163,7 @@ struct ConnectedServerSession: Equatable { let recordingActive: Bool let mediaStreamingActive: Bool let mediaStreamingFileName: String? + let mediaStreamingHasVideo: Bool } struct ConnectedUserAudioState: Equatable { diff --git a/App/ttaccessible/Models/SessionHistoryEntry.swift b/App/ttaccessible/Models/SessionHistoryEntry.swift index 70518f5..fdbdeee 100644 --- a/App/ttaccessible/Models/SessionHistoryEntry.swift +++ b/App/ttaccessible/Models/SessionHistoryEntry.swift @@ -33,6 +33,8 @@ struct SessionHistoryEntry: Equatable, Identifiable { case transmissionBlocked case mediaStreamingStarted case mediaStreamingFinished + case webcamStarted + case webcamStopped var localizationKey: String { "preferences.historyEvent.\(rawValue)" diff --git a/App/ttaccessible/Models/VideoFramePayload.swift b/App/ttaccessible/Models/VideoFramePayload.swift new file mode 100644 index 0000000..1f002d6 --- /dev/null +++ b/App/ttaccessible/Models/VideoFramePayload.swift @@ -0,0 +1,25 @@ +// +// VideoFramePayload.swift +// ttaccessible +// + +import Foundation + +/// RGB32 frame copied from TeamTalk `VideoFrame.frameBuffer`. +struct VideoFramePayload: Equatable { + let width: Int + let height: Int + let pixels: Data + + var isEmpty: Bool { + width <= 0 || height <= 0 || pixels.isEmpty + } +} + +struct VideoDisplayState: Equatable { + let userID: Int32 + let displayName: String + let frame: VideoFramePayload? + + static let empty = VideoDisplayState(userID: 0, displayName: "", frame: nil) +} diff --git a/App/ttaccessible/Services/AppPreferencesStore.swift b/App/ttaccessible/Services/AppPreferencesStore.swift index 83f7446..822be18 100644 --- a/App/ttaccessible/Services/AppPreferencesStore.swift +++ b/App/ttaccessible/Services/AppPreferencesStore.swift @@ -67,6 +67,10 @@ final class AppPreferencesStore: ObservableObject { mutate { $0.useRelativeTimestamps = enabled } } + func updateVideoPanelExpanded(_ expanded: Bool) { + mutate { $0.videoPanelExpanded = expanded } + } + func updateAutoCheckForUpdates(_ enabled: Bool) { mutate { $0.autoCheckForUpdates = enabled } } diff --git a/App/ttaccessible/Services/MediaStreamCompatibility.swift b/App/ttaccessible/Services/MediaStreamCompatibility.swift new file mode 100644 index 0000000..12be647 --- /dev/null +++ b/App/ttaccessible/Services/MediaStreamCompatibility.swift @@ -0,0 +1,168 @@ +// +// MediaStreamCompatibility.swift +// ttaccessible +// + +import Foundation + +/// Checks whether a local media file can be streamed through the TeamTalk SDK as-is. +enum MediaStreamCompatibility { + static let maxStreamingDimension = 1280 + + /// ffprobe `codec_name` values treated as streamable without conversion. + private static let streamableVideoCodecs: Set = [ + "h264", + "mjpeg", + "jpeg", + "mpeg4" + ] + + /// Codecs that are never attempted (fast, explicit rejection when ffprobe is available). + private static let blockedVideoCodecs: Set = [ + "hevc", + "h265", + "hev1", + "vp9", + "av1", + "av01" + ] + private static let ffprobeCandidates = [ + "/opt/homebrew/bin/ffprobe", + "/usr/local/bin/ffprobe", + "/usr/bin/ffprobe" + ] + + /// Fast file-only check (ffprobe). Safe to call off the TeamTalk connection queue. + static func preflightUnsupportedMessage(sourceURL: URL) -> String? { + guard sourceURL.isFileURL else { return nil } + guard let stream = probeVideoStreamWithFFprobe(sourceURL: sourceURL) else { return nil } + return formattedMessage(for: reasonsFromVideoStream(stream)) + } + + /// SDK probe follow-up after preflight passes (no ffprobe — avoids blocking the connection queue twice). + static func unsupportedMessageAfterSDKProbe(probe: MediaFileProbe) -> String? { + var reasons: [String] = [] + if !probe.sdkSupported { + reasons.append(L10n.text("mediaStream.error.reason.sdkUnsupported")) + } + if probe.hasVideo, probe.videoWidth > 0, probe.videoHeight > 0, + max(probe.videoWidth, probe.videoHeight) > maxStreamingDimension { + reasons.append( + L10n.format( + "mediaStream.error.reason.videoResolution", + "\(probe.videoWidth)", + "\(probe.videoHeight)" + ) + ) + } + return formattedMessage(for: reasons) + } + + private static func formattedMessage(for reasons: [String]) -> String? { + guard !reasons.isEmpty else { return nil } + if reasons.count == 1, + reasons[0] == L10n.text("mediaStream.error.reason.sdkUnsupported") { + return L10n.text("mediaStream.error.unsupportedFormat") + } + return L10n.format("mediaStream.error.unsupportedFormat.detail", reasons.joined(separator: " ")) + } + + private static func reasonsFromVideoStream(_ stream: FFprobeVideoStream) -> [String] { + var reasons: [String] = [] + if let codec = stream.codec?.lowercased() { + if blockedVideoCodecs.contains(codec) { + let label = displayLabel(forVideoCodec: codec) + reasons.append(L10n.format("mediaStream.error.reason.videoCodecBlocked", label)) + } else if !streamableVideoCodecs.contains(codec) { + let label = displayLabel(forVideoCodec: codec) + reasons.append(L10n.format("mediaStream.error.reason.videoCodec", label)) + } + } + if max(stream.width, stream.height) > maxStreamingDimension { + reasons.append( + L10n.format( + "mediaStream.error.reason.videoResolution", + "\(stream.width)", + "\(stream.height)" + ) + ) + } + if isTenBitPixelFormat(stream.pixelFormat) { + reasons.append(L10n.text("mediaStream.error.reason.tenBitVideo")) + } + return reasons + } + + private static func displayLabel(forVideoCodec codec: String) -> String { + switch codec { + case "mjpeg", "jpeg": + return "MJPEG" + case "mpeg4": + return "MPEG-4" + case "h264": + return "H.264" + default: + return codec.uppercased() + } + } + + private static func isTenBitPixelFormat(_ pixelFormat: String?) -> Bool { + guard let pixelFormat else { return false } + let pix = pixelFormat.lowercased() + return pix.contains("10le") || pix.contains("10be") || pix.contains("p010") + } + + private struct FFprobeVideoStream { + let codec: String? + let pixelFormat: String? + let width: Int + let height: Int + } + + private static func probeVideoStreamWithFFprobe(sourceURL: URL) -> FFprobeVideoStream? { + guard let ffprobe = resolveFFprobePath() else { return nil } + + let process = Process() + process.executableURL = URL(fileURLWithPath: ffprobe) + process.arguments = [ + "-v", "quiet", + "-print_format", "json", + "-show_streams", + "-select_streams", "v:0", + sourceURL.path + ] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let streams = json["streams"] as? [[String: Any]], + let videoStream = streams.first else { + return nil + } + + return FFprobeVideoStream( + codec: videoStream["codec_name"] as? String, + pixelFormat: videoStream["pix_fmt"] as? String, + width: videoStream["width"] as? Int ?? 0, + height: videoStream["height"] as? Int ?? 0 + ) + } + + private static func resolveFFprobePath() -> String? { + for path in ffprobeCandidates where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return nil + } +} diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Administration.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Administration.swift index b566b8a..faeff67 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+Administration.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Administration.swift @@ -903,6 +903,7 @@ extension TeamTalkConnectionController { } updateObservedSubscriptionStateLocked(option, enabled: enabled, userID: userID) } + unsubscribeMask |= UInt32(SUBSCRIBE_VIDEOCAPTURE.rawValue) if unsubscribeMask != 0 { _ = TT_DoUnsubscribe(instance, userID, Subscriptions(unsubscribeMask)) } diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift index 0511a5d..41325e0 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift @@ -221,19 +221,18 @@ extension TeamTalkConnectionController { func activateVoiceTransmission(completion: @escaping (Result) -> Void) { queue.async { [weak self] in - guard let self, - let instance = self.instance, - let record = self.connectedRecord else { - DispatchQueue.main.async { - completion(.failure(TeamTalkConnectionError.connectionFailed)) - } + guard let self else { return } + guard let instance = self.instance, let record = self.connectedRecord else { + self.healStaleSessionIfNeededLocked() + self.finishOnMain(.failure(self.sessionUnavailableErrorLocked()), completion: completion) return } guard TT_GetMyChannelID(instance) > 0 else { - DispatchQueue.main.async { - completion(.failure(TeamTalkConnectionError.internalError(L10n.text("connectedServer.audio.error.notInChannel")))) - } + self.finishOnMain( + .failure(TeamTalkConnectionError.internalError(L10n.text("connectedServer.audio.error.notInChannel"))), + completion: completion + ) return } @@ -245,24 +244,20 @@ extension TeamTalkConnectionController { let preferencesStore = self.preferencesStore DispatchQueue.main.async { preferencesStore.updateLastVoiceTransmissionEnabled(true) - completion(.success(())) } + self.finishOnMain(.success(()), completion: completion) } catch { - DispatchQueue.main.async { - completion(.failure(error)) - } + self.finishOnMain(.failure(error), completion: completion) } } } func deactivateVoiceTransmission(completion: @escaping (Result) -> Void) { queue.async { [weak self] in - guard let self, - let instance = self.instance, - let record = self.connectedRecord else { - DispatchQueue.main.async { - completion(.failure(TeamTalkConnectionError.connectionFailed)) - } + guard let self else { return } + guard let instance = self.instance, let record = self.connectedRecord else { + self.healStaleSessionIfNeededLocked() + self.finishOnMain(.failure(self.sessionUnavailableErrorLocked()), completion: completion) return } @@ -278,8 +273,8 @@ extension TeamTalkConnectionController { let preferencesStore = self.preferencesStore DispatchQueue.main.async { preferencesStore.updateLastVoiceTransmissionEnabled(false) - completion(.success(())) } + self.finishOnMain(.success(()), completion: completion) } } diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift index 89bcbba..3d79f76 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift @@ -551,8 +551,13 @@ extension TeamTalkConnectionController { publishInvalidation.insert(.activeTransfers) } case CLIENTEVENT_USER_STATECHANGE: - if connectedRecord != nil { + if let record = connectedRecord { publishAudioRuntimeUpdateLocked(instance: instance) + publishSessionLocked(instance: instance, record: record, invalidation: .rootTree) + } + case CLIENTEVENT_USER_MEDIAFILE_VIDEO: + if connectedRecord != nil { + handleUserMediaFileVideoEventLocked(userID: message.nSource) } case CLIENTEVENT_USER_RECORD_MEDIAFILE: if connectedRecord != nil { @@ -576,12 +581,21 @@ extension TeamTalkConnectionController { publishInvalidation.insert(.history) } updateMediaStreamingProgressLocked(elapsedMSec: info.uElapsedMSec, durationMSec: info.uDurationMSec) - case MFS_PLAYING: - updateMediaStreamingProgressLocked(elapsedMSec: info.uElapsedMSec, durationMSec: info.uDurationMSec) case MFS_PAUSED: - mediaStreamingPaused = true - updateMediaStreamingProgressLocked(elapsedMSec: info.uElapsedMSec, durationMSec: info.uDurationMSec) + if !mediaStreamingRestartInFlight { + mediaStreamingUserPauseIntent = false + mediaStreamingPaused = true + updateMediaStreamingProgressLocked(elapsedMSec: info.uElapsedMSec, durationMSec: info.uDurationMSec) + } + case MFS_PLAYING: + if !mediaStreamingRestartInFlight, !mediaStreamingUserPauseIntent { + mediaStreamingPaused = false + updateMediaStreamingProgressLocked(elapsedMSec: info.uElapsedMSec, durationMSec: info.uDurationMSec) + } case MFS_FINISHED, MFS_ABORTED, MFS_CLOSED: + if shouldIgnoreMediaStreamingFinalizeLocked(info: info) { + break + } finalizeMediaStreamingLocked(instance: instance, reason: .finished) case MFS_ERROR: finalizeMediaStreamingLocked(instance: instance, reason: .error) @@ -761,6 +775,7 @@ extension TeamTalkConnectionController { stopPollingLocked() if let instance { + cleanupVideoLocked() if mediaStreamingActive { _ = TT_StopStreamingMediaFileToChannel(instance) } @@ -784,12 +799,20 @@ extension TeamTalkConnectionController { mediaStreamingSecurityScopedURL?.stopAccessingSecurityScopedResource() mediaStreamingSecurityScopedURL = nil mediaStreamingActive = false + mediaStreamingPath = nil mediaStreamingFileName = nil + mediaStreamingRestartInFlight = false + mediaStreamingUserPauseIntent = false mediaStreamingPaused = false mediaStreamingDurationMSec = 0 mediaStreamingElapsedMSec = 0 mediaStreamingElapsedSampleAt = nil mediaStreamingBroadcastGainLevel = INT32(SOUND_GAIN_DEFAULT.rawValue) + mediaStreamingHasVideo = false + mediaStreamingActiveVideoCodec = VideoCodec() + mediaStreamingFinalizeSuppressedUntil = nil + activeVideoDisplayUserID = 0 + usersWithPendingMediaVideoFrame.removeAll() publishMediaStreamingProgressLocked() recordingMuxedActive = false recordingSeparateActive = false diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift b/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift index 8b6a9b8..c5bc457 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift @@ -13,6 +13,7 @@ extension TeamTalkConnectionController { path: url.path, displayName: url.lastPathComponent, securityScopedURL: didAccess ? url : nil, + sourceURL: url, completion: completion ) } @@ -22,6 +23,7 @@ extension TeamTalkConnectionController { path: url.absoluteString, displayName: url.host ?? url.absoluteString, securityScopedURL: nil, + sourceURL: nil, completion: completion ) } @@ -30,56 +32,113 @@ extension TeamTalkConnectionController { path: String, displayName: String, securityScopedURL: URL?, + sourceURL: URL?, completion: @escaping (Result) -> Void ) { - queue.async { [weak self] in - guard let self, - let instance = self.instance, - let record = self.connectedRecord else { + let fileURL = sourceURL ?? URL(fileURLWithPath: path) + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { securityScopedURL?.stopAccessingSecurityScopedResource() - DispatchQueue.main.async { - completion(.failure(TeamTalkConnectionError.connectionFailed)) - } return } - if self.mediaStreamingActive { - self.stopStreamingMediaFileLocked(instance: instance) - } + let preflightMessage = fileURL.isFileURL + ? MediaStreamCompatibility.preflightUnsupportedMessage(sourceURL: fileURL) + : nil - self.mediaStreamingPaused = false - self.mediaStreamingBroadcastGainLevel = INT32(SOUND_GAIN_DEFAULT.rawValue) + self.queue.async { [weak self] in + guard let self else { + securityScopedURL?.stopAccessingSecurityScopedResource() + return + } - var playback = self.makeMediaFilePlaybackLocked(offsetMSec: 0) - var videoCodec = VideoCodec() - videoCodec.nCodec = NO_CODEC + guard let instance = self.instance, let record = self.connectedRecord else { + securityScopedURL?.stopAccessingSecurityScopedResource() + self.healStaleSessionIfNeededLocked() + self.finishOnMain(.failure(self.sessionUnavailableErrorLocked()), completion: completion) + return + } - let started = path.withCString { cPath -> Bool in - TT_StartStreamingMediaFileToChannelEx(instance, cPath, &playback, &videoCodec) != 0 - } + if let preflightMessage { + securityScopedURL?.stopAccessingSecurityScopedResource() + self.finishOnMain( + .failure(TeamTalkConnectionError.internalError(preflightMessage)), + completion: completion + ) + return + } - guard started else { - securityScopedURL?.stopAccessingSecurityScopedResource() - DispatchQueue.main.async { - completion(.failure(TeamTalkConnectionError.internalError(L10n.text("mediaStream.error.startFailed")))) + if self.mediaStreamingActive { + self.stopStreamingMediaFileLocked(instance: instance) } - return - } - self.mediaStreamingActive = true - self.mediaStreamingFileName = displayName - self.mediaStreamingSecurityScopedURL = securityScopedURL - self.mediaStreamingDurationMSec = 0 - self.mediaStreamingElapsedMSec = 0 - self.mediaStreamingElapsedSampleAt = nil - self.publishSessionLocked(instance: instance, record: record, invalidation: .audio) - self.publishMediaStreamingProgressLocked() - DispatchQueue.main.async { - completion(.success(())) + do { + let resolved = try self.resolveStreamingPathLocked(originalPath: path, sourceURL: sourceURL) + self.mediaStreamingPaused = false + self.mediaStreamingBroadcastGainLevel = INT32(SOUND_GAIN_DEFAULT.rawValue) + self.mediaStreamingHasVideo = resolved.probe.hasVideo + + var playback = self.makeMediaFilePlaybackLocked(offsetMSec: 0) + var videoCodec = self.makeVideoCodecLocked(from: resolved.probe) + self.mediaStreamingActiveVideoCodec = videoCodec + + let started = resolved.path.withCString { cPath -> Bool in + TT_StartStreamingMediaFileToChannelEx(instance, cPath, &playback, &videoCodec) != 0 + } + + guard started else { + securityScopedURL?.stopAccessingSecurityScopedResource() + self.finishOnMain( + .failure(TeamTalkConnectionError.internalError(L10n.text("mediaStream.error.startFailed"))), + completion: completion + ) + return + } + + if resolved.probe.durationMSec > 0 { + self.mediaStreamingDurationMSec = resolved.probe.durationMSec + } + + self.mediaStreamingActive = true + self.mediaStreamingPath = resolved.path + self.mediaStreamingFileName = displayName + self.mediaStreamingSecurityScopedURL = securityScopedURL + self.mediaStreamingElapsedMSec = 0 + self.mediaStreamingElapsedSampleAt = nil + + let myID = TT_GetMyUserID(instance) + if resolved.probe.hasVideo, myID > 0 { + self.activeVideoDisplayUserID = myID + } + + self.publishSessionLocked(instance: instance, record: record, invalidation: .audio) + self.publishMediaStreamingProgressLocked() + self.publishVideoDisplayStateLocked() + self.finishOnMain(.success(()), completion: completion) + } catch { + securityScopedURL?.stopAccessingSecurityScopedResource() + self.finishOnMain(.failure(error), completion: completion) + } } } } + private struct ResolvedStreamingPath { + let path: String + let probe: MediaFileProbe + } + + private func resolveStreamingPathLocked(originalPath: String, sourceURL: URL?) throws -> ResolvedStreamingPath { + let probe = probeMediaFileLocked(path: originalPath) + + if let message = MediaStreamCompatibility.unsupportedMessageAfterSDKProbe(probe: probe) { + throw TeamTalkConnectionError.internalError(message) + } + + return ResolvedStreamingPath(path: originalPath, probe: probe) + } + func stopStreamingMediaFile() { queue.async { [weak self] in guard let self, let instance = self.instance else { return } @@ -103,11 +162,22 @@ extension TeamTalkConnectionController { mediaStreamingSecurityScopedURL?.stopAccessingSecurityScopedResource() mediaStreamingSecurityScopedURL = nil mediaStreamingActive = false + mediaStreamingPath = nil mediaStreamingFileName = nil + mediaStreamingRestartInFlight = false + mediaStreamingUserPauseIntent = false mediaStreamingPaused = false mediaStreamingDurationMSec = 0 mediaStreamingElapsedMSec = 0 mediaStreamingElapsedSampleAt = nil + mediaStreamingHasVideo = false + mediaStreamingActiveVideoCodec = VideoCodec() + mediaStreamingFinalizeSuppressedUntil = nil + let myID = TT_GetMyUserID(instance) + if myID > 0, activeVideoDisplayUserID == myID { + activeVideoDisplayUserID = 0 + publishVideoDisplayStateLocked(clearFrame: true) + } switch reason { case .finished: @@ -151,33 +221,48 @@ extension TeamTalkConnectionController { private func setMediaStreamingPausedLocked(_ paused: Bool) { guard let instance = self.instance, self.mediaStreamingActive else { return } if self.mediaStreamingPaused == paused { return } - if paused { - self.mediaStreamingElapsedMSec = self.currentMediaStreamingElapsedMSecLocked() + + let offsetMSec = currentMediaStreamingElapsedMSecLocked() + let previousPaused = mediaStreamingPaused + let previousPauseIntent = mediaStreamingUserPauseIntent + + mediaStreamingPaused = paused + mediaStreamingElapsedMSec = offsetMSec + mediaStreamingElapsedSampleAt = paused ? nil : Date() + mediaStreamingUserPauseIntent = paused + + var playback = makeMediaFilePlaybackLocked(offsetMSec: UInt32(TT_MEDIAPLAYBACK_OFFSET_IGNORE)) + guard applyMediaStreamingUpdateLocked(instance: instance, playback: &playback) else { + mediaStreamingPaused = previousPaused + mediaStreamingUserPauseIntent = previousPauseIntent + mediaStreamingElapsedSampleAt = previousPaused ? nil : Date() + AudioLogger.log("Media stream: pause/resume update failed paused=%d", paused ? 1 : 0) + return } - self.mediaStreamingPaused = paused - self.mediaStreamingElapsedSampleAt = Date() - var playback = self.makeMediaFilePlaybackLocked(offsetMSec: UInt32(TT_MEDIAPLAYBACK_OFFSET_IGNORE)) - var videoCodec = VideoCodec() - videoCodec.nCodec = NO_CODEC - _ = TT_UpdateStreamingMediaFileToChannel(instance, &playback, &videoCodec) - self.publishMediaStreamingProgressLocked() + publishMediaStreamingProgressLocked() } func seekMediaStreaming(toMSec offsetMSec: UInt32) { queue.async { [weak self] in guard let self, let instance = self.instance, self.mediaStreamingActive else { return } - let clamped: UInt32 - if self.mediaStreamingDurationMSec > 0 { - clamped = min(offsetMSec, self.mediaStreamingDurationMSec - 1) + let clamped = self.clampedMediaStreamOffsetMSec(offsetMSec) + if self.mediaStreamingHasVideo { + guard self.restartMediaStreamLocked(instance: instance, offsetMSec: clamped, paused: self.mediaStreamingPaused) else { + AudioLogger.log("Media stream: seek restart failed at %u ms", clamped) + self.publishMediaStreamingProgressLocked() + return + } } else { - clamped = offsetMSec + var playback = self.makeMediaFilePlaybackLocked(offsetMSec: clamped) + guard self.applyMediaStreamingUpdateLocked(instance: instance, playback: &playback) else { + AudioLogger.log("Media stream: seek update failed at %u ms", clamped) + self.publishMediaStreamingProgressLocked() + return + } + self.mediaStreamingElapsedMSec = clamped + self.mediaStreamingElapsedSampleAt = self.mediaStreamingPaused ? nil : Date() } - self.mediaStreamingElapsedMSec = clamped - self.mediaStreamingElapsedSampleAt = Date() - var playback = self.makeMediaFilePlaybackLocked(offsetMSec: clamped) - var videoCodec = VideoCodec() - videoCodec.nCodec = NO_CODEC - _ = TT_UpdateStreamingMediaFileToChannel(instance, &playback, &videoCodec) + self.mediaStreamingFinalizeSuppressedUntil = Date().addingTimeInterval(1.0) self.publishMediaStreamingProgressLocked() } } @@ -187,13 +272,68 @@ extension TeamTalkConnectionController { guard let self, let instance = self.instance, self.mediaStreamingActive else { return } self.mediaStreamingBroadcastGainLevel = Self.userVolumeFromPercent(Double(percent)) var playback = self.makeMediaFilePlaybackLocked(offsetMSec: UInt32(TT_MEDIAPLAYBACK_OFFSET_IGNORE)) - var videoCodec = VideoCodec() - videoCodec.nCodec = NO_CODEC - _ = TT_UpdateStreamingMediaFileToChannel(instance, &playback, &videoCodec) + guard self.applyMediaStreamingUpdateLocked(instance: instance, playback: &playback) else { + AudioLogger.log("Media stream: broadcast gain update failed") + return + } self.publishMediaStreamingProgressLocked() } } + private func clampedMediaStreamOffsetMSec(_ offsetMSec: UInt32) -> UInt32 { + guard mediaStreamingDurationMSec > 1 else { return offsetMSec } + return min(offsetMSec, mediaStreamingDurationMSec - 1) + } + + func shouldIgnoreMediaStreamingFinalizeLocked(info: MediaFileInfo) -> Bool { + if mediaStreamingRestartInFlight { return true } + guard let until = mediaStreamingFinalizeSuppressedUntil, Date() < until else { + return false + } + guard mediaStreamingDurationMSec > 0 else { return true } + // Ignore spurious finish/abort right after a seek while still far from the end. + return info.uElapsedMSec + 2_000 < mediaStreamingDurationMSec + } + + /// Stop and restart channel media streaming (used for seek on video files where TT_Update is unreliable). + @discardableResult + private func restartMediaStreamLocked( + instance: UnsafeMutableRawPointer, + offsetMSec: UInt32, + paused: Bool + ) -> Bool { + guard let path = mediaStreamingPath else { return false } + + mediaStreamingRestartInFlight = true + defer { mediaStreamingRestartInFlight = false } + + _ = TT_StopStreamingMediaFileToChannel(instance) + + var playback = makeMediaFilePlaybackLocked(offsetMSec: offsetMSec) + playback.bPaused = paused ? 1 : 0 + var videoCodec = mediaStreamingActiveVideoCodec + + let started = path.withCString { cPath -> Bool in + TT_StartStreamingMediaFileToChannelEx(instance, cPath, &playback, &videoCodec) != 0 + } + guard started else { return false } + + mediaStreamingElapsedMSec = offsetMSec + mediaStreamingPaused = paused + mediaStreamingElapsedSampleAt = paused ? nil : Date() + mediaStreamingFinalizeSuppressedUntil = Date().addingTimeInterval(1.0) + return true + } + + @discardableResult + private func applyMediaStreamingUpdateLocked( + instance: UnsafeMutableRawPointer, + playback: inout MediaFilePlayback + ) -> Bool { + // Audio-only updates: pass NULL video codec per SDK (do not re-send WEBM_VP8 on update). + TT_UpdateStreamingMediaFileToChannel(instance, &playback, nil) != 0 + } + // MARK: - Internals func makeMediaFilePlaybackLocked(offsetMSec: UInt32) -> MediaFilePlayback { @@ -230,6 +370,9 @@ extension TeamTalkConnectionController { mediaStreamingDurationMSec = durationMSec } publishMediaStreamingProgressLocked() + if mediaStreamingHasVideo, let instance, activeVideoDisplayUserID == TT_GetMyUserID(instance) { + tryAcquireMediaVideoFrameLocked(userID: activeVideoDisplayUserID) + } } func publishMediaStreamingProgressLocked() { @@ -237,7 +380,7 @@ extension TeamTalkConnectionController { isActive: mediaStreamingActive, isPaused: mediaStreamingPaused, fileName: mediaStreamingFileName, - elapsedMSec: mediaStreamingElapsedMSec, + elapsedMSec: currentMediaStreamingElapsedMSecLocked(), elapsedSampleAt: mediaStreamingElapsedSampleAt, durationMSec: mediaStreamingDurationMSec, broadcastGainPercent: Self.percentFromUserVolume(mediaStreamingBroadcastGainLevel) diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+SessionGuard.swift b/App/ttaccessible/Services/TeamTalkConnectionController+SessionGuard.swift new file mode 100644 index 0000000..8bfb2c5 --- /dev/null +++ b/App/ttaccessible/Services/TeamTalkConnectionController+SessionGuard.swift @@ -0,0 +1,34 @@ +// +// TeamTalkConnectionController+SessionGuard.swift +// ttaccessible +// + +import Foundation + +extension TeamTalkConnectionController { + + /// True while auto-reconnect is waiting for a new TeamTalk instance. + var isReconnectingLocked: Bool { + reconnectTimer != nil + } + + /// User-facing error when session work is requested without a live SDK instance. + func sessionUnavailableErrorLocked() -> TeamTalkConnectionError { + if isReconnectingLocked { + return .internalError(L10n.text("connectedServer.error.reconnecting")) + } + return .connectionFailed + } + + /// If the UI still shows a connected server but the SDK session is gone, surface disconnect instead of limbo. + func healStaleSessionIfNeededLocked() { + guard instance == nil, connectedRecord == nil, !isReconnectingLocked else { return } + publishDisconnected(message: L10n.text("connectedServer.disconnect.connectionLost")) + } + + func finishOnMain(_ result: Result, completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { + completion(result) + } + } +} diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+SessionSnapshot.swift b/App/ttaccessible/Services/TeamTalkConnectionController+SessionSnapshot.swift index 47049e0..19e2c42 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+SessionSnapshot.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+SessionSnapshot.swift @@ -211,6 +211,7 @@ extension TeamTalkConnectionController { : (user.uUserState & UInt32(USERSTATE_VOICE.rawValue)) != 0, isMuted: (user.uUserState & UInt32(USERSTATE_MUTE_VOICE.rawValue)) != 0, isMediaFileMuted: (user.uUserState & UInt32(USERSTATE_MUTE_MEDIAFILE.rawValue)) != 0, + isStreamingMediaFileVideo: (user.uUserState & UInt32(USERSTATE_MEDIAFILE_VIDEO.rawValue)) != 0, isAway: (user.nStatusMode & 0xFF) == 0x01, isQuestion: (user.nStatusMode & 0xFF) == 0x02, ipAddress: ttString(from: user.szIPAddress), @@ -330,7 +331,8 @@ extension TeamTalkConnectionController { outputGainDB: preferences.outputGainDB, recordingActive: recordingMuxedActive || recordingSeparateActive, mediaStreamingActive: mediaStreamingActive, - mediaStreamingFileName: mediaStreamingFileName + mediaStreamingFileName: mediaStreamingFileName, + mediaStreamingHasVideo: mediaStreamingHasVideo ) } diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Video.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Video.swift new file mode 100644 index 0000000..407d81d --- /dev/null +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Video.swift @@ -0,0 +1,174 @@ +// +// TeamTalkConnectionController+Video.swift +// ttaccessible +// + +import Foundation + +struct MediaFileProbe: Equatable { + let hasAudio: Bool + let hasVideo: Bool + let durationMSec: UInt32 + let sdkSupported: Bool + let videoWidth: Int + let videoHeight: Int +} + +extension TeamTalkConnectionController { + + // MARK: - Media probe / codec + + func probeMediaFileLocked(path: String) -> MediaFileProbe { + var info = MediaFileInfo() + let supported = path.withCString { cPath -> Bool in + TT_GetMediaFileInfo(cPath, &info) != 0 + } + let hasVideo = info.videoFmt.nWidth > 0 && info.videoFmt.nHeight > 0 + let hasAudio = info.audioFmt.nSampleRate > 0 + return MediaFileProbe( + hasAudio: hasAudio, + hasVideo: hasVideo, + durationMSec: info.uDurationMSec, + sdkSupported: supported, + videoWidth: hasVideo ? Int(info.videoFmt.nWidth) : 0, + videoHeight: hasVideo ? Int(info.videoFmt.nHeight) : 0 + ) + } + + func makeVideoCodecLocked(includeVideo: Bool, bitrateKbps: UInt32 = 512) -> VideoCodec { + var codec = VideoCodec() + guard includeVideo else { + codec.nCodec = NO_CODEC + return codec + } + codec.nCodec = WEBM_VP8_CODEC + codec.webm_vp8.rc_target_bitrate = max(64, bitrateKbps) + codec.webm_vp8.nEncodeDeadline = UInt32(WEBM_VPX_DL_REALTIME) + return codec + } + + func makeVideoCodecLocked(from probe: MediaFileProbe) -> VideoCodec { + makeVideoCodecLocked(includeVideo: probe.hasVideo) + } + + // MARK: - Display target + + func setActiveVideoDisplayUserID(_ userID: Int32) { + queue.async { [weak self] in + guard let self else { return } + self.activeVideoDisplayUserID = userID + self.publishVideoDisplayStateLocked() + self.tryAcquireDisplayedFrameLocked() + } + } + + func setActiveVideoDisplayFromSelection(userID: Int32, hasMediaVideo: Bool) { + queue.async { [weak self] in + guard let self else { return } + if userID <= 0 { + self.activeVideoDisplayUserID = 0 + self.publishVideoDisplayStateLocked(clearFrame: true) + return + } + let showsLocalPreview = self.mediaStreamingActive + && self.instance.map { userID == TT_GetMyUserID($0) } == true + && self.mediaStreamingHasVideo + guard hasMediaVideo || showsLocalPreview else { + self.activeVideoDisplayUserID = 0 + self.publishVideoDisplayStateLocked(clearFrame: true) + return + } + self.activeVideoDisplayUserID = userID + self.publishVideoDisplayStateLocked() + self.tryAcquireDisplayedFrameLocked() + } + } + + func handleUserMediaFileVideoEventLocked(userID: Int32) { + usersWithPendingMediaVideoFrame.insert(userID) + if userID == activeVideoDisplayUserID || shouldAutoDisplayLocalMediaPreviewLocked(userID: userID) { + if shouldAutoDisplayLocalMediaPreviewLocked(userID: userID) { + activeVideoDisplayUserID = userID + } + tryAcquireMediaVideoFrameLocked(userID: userID) + } + } + + private func shouldAutoDisplayLocalMediaPreviewLocked(userID: Int32) -> Bool { + guard mediaStreamingActive, mediaStreamingHasVideo, let instance else { return false } + return activeVideoDisplayUserID == 0 && userID == TT_GetMyUserID(instance) + } + + func tryAcquireDisplayedFrameLocked() { + guard activeVideoDisplayUserID > 0 else { return } + tryAcquireMediaVideoFrameLocked(userID: activeVideoDisplayUserID) + } + + func tryAcquireMediaVideoFrameLocked(userID: Int32) { + guard let instance else { return } + guard let framePtr = TT_AcquireUserMediaVideoFrame(instance, userID) else { return } + defer { TT_ReleaseUserMediaVideoFrame(instance, framePtr) } + usersWithPendingMediaVideoFrame.remove(userID) + guard userID == activeVideoDisplayUserID else { return } + publishVideoFrameLocked(userID: userID, framePtr: framePtr) + } + + func publishVideoFrameLocked(userID: Int32, framePtr: UnsafePointer) { + let frame = framePtr.pointee + let payload = copyVideoFramePayload(from: frame) + let displayName = displayNameForVideoUserLocked(userID: userID) + let state = VideoDisplayState( + userID: userID, + displayName: displayName, + frame: payload + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.teamTalkConnectionController(self, didUpdateVideoDisplay: state) + } + } + + func publishVideoDisplayStateLocked(clearFrame: Bool = false) { + let userID = activeVideoDisplayUserID + guard userID > 0 else { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.teamTalkConnectionController(self, didUpdateVideoDisplay: .empty) + } + return + } + let state = VideoDisplayState( + userID: userID, + displayName: displayNameForVideoUserLocked(userID: userID), + frame: clearFrame ? nil : nil + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.teamTalkConnectionController(self, didUpdateVideoDisplay: state) + } + } + + func displayNameForVideoUserLocked(userID: Int32) -> String { + guard let instance else { return "" } + var user = User() + guard TT_GetUser(instance, userID, &user) != 0 else { return "" } + return displayName(for: user) + } + + func copyVideoFramePayload(from frame: VideoFrame) -> VideoFramePayload? { + let width = Int(frame.nWidth) + let height = Int(frame.nHeight) + let size = Int(frame.nFrameBufferSize) + guard width > 0, height > 0, size > 0, let buffer = frame.frameBuffer else { return nil } + let data = Data(bytes: buffer, count: size) + return VideoFramePayload(width: width, height: height, pixels: data) + } + + func cleanupVideoLocked() { + mediaStreamingHasVideo = false + activeVideoDisplayUserID = 0 + usersWithPendingMediaVideoFrame.removeAll() + publishVideoDisplayStateLocked(clearFrame: true) + } + +} diff --git a/App/ttaccessible/Services/TeamTalkConnectionController.swift b/App/ttaccessible/Services/TeamTalkConnectionController.swift index 9486902..ef98836 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController.swift @@ -28,6 +28,7 @@ protocol TeamTalkConnectionControllerDelegate: AnyObject { func teamTalkConnectionController(_ controller: TeamTalkConnectionController, didReceiveBannedUsers users: [BannedUserProperties]) func teamTalkConnectionController(_ controller: TeamTalkConnectionController, didReceiveIncomingTextMessage event: IncomingTextMessageEvent) func teamTalkConnectionController(_ controller: TeamTalkConnectionController, didUpdateMediaStreamingProgress progress: MediaStreamingProgress) + func teamTalkConnectionController(_ controller: TeamTalkConnectionController, didUpdateVideoDisplay state: VideoDisplayState) } final class TeamTalkConnectionController { @@ -113,13 +114,22 @@ final class TeamTalkConnectionController { var recordingFolder: URL? var recordingFormat: AudioFileFormat = AFF_WAVE_FORMAT var mediaStreamingActive = false + var mediaStreamingPath: String? var mediaStreamingFileName: String? var mediaStreamingSecurityScopedURL: URL? + var mediaStreamingRestartInFlight = false + /// True after the user requests pause until the SDK reports `MFS_PAUSED` (blocks spurious `MFS_PLAYING`). + var mediaStreamingUserPauseIntent = false var mediaStreamingPaused = false var mediaStreamingDurationMSec: UInt32 = 0 var mediaStreamingElapsedMSec: UInt32 = 0 var mediaStreamingElapsedSampleAt: Date? var mediaStreamingBroadcastGainLevel: INT32 = 1000 + var mediaStreamingHasVideo = false + var mediaStreamingActiveVideoCodec = VideoCodec() + var mediaStreamingFinalizeSuppressedUntil: Date? + var activeVideoDisplayUserID: Int32 = 0 + var usersWithPendingMediaVideoFrame = Set() var teamTalkVirtualInputReady = false var advancedMicrophoneTargetFormat: AdvancedMicrophoneAudioTargetFormat? var reconnectTimer: DispatchSourceTimer? diff --git a/App/ttaccessible/en.lproj/Localizable.strings b/App/ttaccessible/en.lproj/Localizable.strings index 4e5cf0e..cee90a0 100644 --- a/App/ttaccessible/en.lproj/Localizable.strings +++ b/App/ttaccessible/en.lproj/Localizable.strings @@ -305,6 +305,7 @@ "connectedServer.accessibility.channelChanged" = "Channel changed."; "connectedServer.accessibility.channelLeft" = "Left channel."; "connectedServer.disconnect.connectionLost" = "The connection to the server was lost."; +"connectedServer.error.reconnecting" = "Reconnecting to the server. Wait a moment, then try again."; "connectedServer.disconnect.alert.title" = "Disconnected from Server"; "preferences.menu.title" = "Preferences…"; "preferences.alert.title" = "Preferences"; @@ -531,11 +532,11 @@ "recording.announced.startedSeparate" = "Per-user recording started"; "preferences.recording.autoRestart" = "Automatically restart recording when joining a channel"; "preferences.recording.help" = "Press Cmd+R while connected to start or stop recording. Files are saved to the chosen folder."; -"shortcuts.mediaStream.startFile" = "Stream Audio File…"; +"shortcuts.mediaStream.startFile" = "Stream Media File…"; "shortcuts.mediaStream.startURL" = "Stream URL…"; "shortcuts.mediaStream.stop" = "Stop Streaming"; -"mediaStream.panel.title" = "Stream Audio File"; -"mediaStream.panel.message" = "Choose an audio file to stream into the channel."; +"mediaStream.panel.title" = "Stream Media File"; +"mediaStream.panel.message" = "Choose an audio or video file to stream into the channel. Supported video: H.264, MJPEG (AVI), or MPEG-4, up to 1280p and 8-bit. HEVC, 4K, and 10-bit recordings are not supported — convert them with another tool first."; "mediaStream.panel.choose" = "Stream"; "mediaStream.url.prompt.title" = "Stream URL"; "mediaStream.url.prompt.message" = "Enter the URL of an audio stream (internet radio, http, rtmp, rtsp)."; @@ -549,8 +550,19 @@ "mediaStream.announced.finished" = "Streaming finished"; "mediaStream.announced.error" = "Streaming failed"; "mediaStream.error.startFailed" = "Failed to start streaming."; +"mediaStream.error.unsupportedFormat" = "This media format is not supported for streaming."; +"mediaStream.error.unsupportedFormat.detail" = "This file cannot be streamed in TeamTalk. %@"; +"mediaStream.error.reason.sdkUnsupported" = "TeamTalk cannot open this file format."; +"mediaStream.error.reason.videoCodec" = "Video codec is %@ (supported: H.264, MJPEG, MPEG-4)."; +"mediaStream.error.reason.videoCodecBlocked" = "Video codec is %@ (HEVC, VP9, and AV1 are not supported)."; +"mediaStream.error.reason.videoResolution" = "Video resolution is %1$@×%2$@ (maximum 1280 pixels on the longest side)."; +"mediaStream.error.reason.tenBitVideo" = "10-bit video is not supported."; "history.mediaStreamingStarted" = "Streaming media: %@"; "history.mediaStreamingFinished" = "Streaming finished"; +"video.panel.toggle.title" = "Video"; +"video.panel.toggle.accessibilityLabel" = "Show or hide video panel"; +"video.panel.placeholder" = "No video"; +"video.panel.source.mediaFile" = "Video from %@ (media stream)"; "mediaPlayer.window.title" = "Media Player"; "mediaPlayer.fileName.format" = "Playing: %@"; "mediaPlayer.fileName.empty" = "No file playing"; diff --git a/App/ttaccessible/fr.lproj/Localizable.strings b/App/ttaccessible/fr.lproj/Localizable.strings index de44e79..64aee29 100644 --- a/App/ttaccessible/fr.lproj/Localizable.strings +++ b/App/ttaccessible/fr.lproj/Localizable.strings @@ -305,6 +305,7 @@ "connectedServer.accessibility.channelChanged" = "Canal changé."; "connectedServer.accessibility.channelLeft" = "Canal quitté."; "connectedServer.disconnect.connectionLost" = "La connexion au serveur a été perdue."; +"connectedServer.error.reconnecting" = "Reconnexion au serveur en cours. Patientez un instant, puis réessayez."; "connectedServer.disconnect.alert.title" = "Déconnexion du serveur"; "preferences.menu.title" = "Préférences…"; "preferences.alert.title" = "Préférences"; @@ -531,11 +532,11 @@ "recording.announced.startedSeparate" = "Enregistrement par utilisateur démarré"; "preferences.recording.autoRestart" = "Redémarrer automatiquement l'enregistrement en rejoignant un canal"; "preferences.recording.help" = "Appuyez sur Cmd+R en étant connecté pour démarrer ou arrêter l'enregistrement. Les fichiers sont sauvegardés dans le dossier choisi."; -"shortcuts.mediaStream.startFile" = "Diffuser un fichier audio…"; +"shortcuts.mediaStream.startFile" = "Diffuser un fichier média…"; "shortcuts.mediaStream.startURL" = "Diffuser une URL…"; "shortcuts.mediaStream.stop" = "Arrêter la diffusion"; -"mediaStream.panel.title" = "Diffuser un fichier audio"; -"mediaStream.panel.message" = "Choisissez un fichier audio à diffuser dans le canal."; +"mediaStream.panel.title" = "Diffuser un fichier média"; +"mediaStream.panel.message" = "Choisissez un fichier audio ou vidéo à diffuser dans le canal. Vidéo prise en charge : H.264, MJPEG (AVI) ou MPEG-4, jusqu'à 1280p et en 8 bits. Les enregistrements HEVC, 4K ou 10 bits ne sont pas pris en charge — convertissez-les d'abord avec un autre outil."; "mediaStream.panel.choose" = "Diffuser"; "mediaStream.url.prompt.title" = "Diffuser une URL"; "mediaStream.url.prompt.message" = "Saisissez l'URL d'un flux audio (radio internet, http, rtmp, rtsp)."; @@ -549,8 +550,19 @@ "mediaStream.announced.finished" = "Diffusion terminée"; "mediaStream.announced.error" = "Échec de la diffusion"; "mediaStream.error.startFailed" = "Impossible de démarrer la diffusion."; +"mediaStream.error.unsupportedFormat" = "Ce format média n'est pas pris en charge pour la diffusion."; +"mediaStream.error.unsupportedFormat.detail" = "Ce fichier ne peut pas être diffusé dans TeamTalk. %@"; +"mediaStream.error.reason.sdkUnsupported" = "TeamTalk ne peut pas ouvrir ce format de fichier."; +"mediaStream.error.reason.videoCodec" = "Le codec vidéo est %@ (pris en charge : H.264, MJPEG, MPEG-4)."; +"mediaStream.error.reason.videoCodecBlocked" = "Le codec vidéo est %@ (HEVC, VP9 et AV1 ne sont pas pris en charge)."; +"mediaStream.error.reason.videoResolution" = "La résolution vidéo est %1$@×%2$@ (1280 pixels maximum sur le côté le plus long)."; +"mediaStream.error.reason.tenBitVideo" = "La vidéo 10 bits n'est pas prise en charge."; "history.mediaStreamingStarted" = "Diffusion du média : %@"; "history.mediaStreamingFinished" = "Diffusion du média terminée"; +"video.panel.toggle.title" = "Vidéo"; +"video.panel.toggle.accessibilityLabel" = "Afficher ou masquer le panneau vidéo"; +"video.panel.placeholder" = "Pas de vidéo"; +"video.panel.source.mediaFile" = "Vidéo de %@ (fichier média)"; "mediaPlayer.window.title" = "Lecteur média"; "mediaPlayer.fileName.format" = "Lecture : %@"; "mediaPlayer.fileName.empty" = "Aucun fichier en lecture"; diff --git a/App/ttaccessible/ttaccessibleApp.swift b/App/ttaccessible/ttaccessibleApp.swift index 5f47ce3..cd7b003 100644 --- a/App/ttaccessible/ttaccessibleApp.swift +++ b/App/ttaccessible/ttaccessibleApp.swift @@ -363,8 +363,6 @@ struct ttaccessibleApp: App { .keyboardShortcut(".", modifiers: [.command, .option]) .disabled(menuState.mode != .connectedServer || !menuState.isMediaStreamingActive) - Divider() - Button(L10n.text("shortcuts.hearMyself")) { appDelegate.toggleHearMyself() } diff --git a/README.md b/README.md index 5cae297..ee1f235 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The official TeamTalk Qt client on Mac has significant accessibility issues — - **Adaptive jitter buffer** — improves audio quality on unstable connections - **Recording** — muxed (all voices) or per-user, WAV or OGG format, auto-restart on channel change - **Per-user volume and stereo balance** — persisted across sessions +- **In-window video** — media file streaming with collapsible panel (H.264, MJPEG, or MPEG-4 video up to 1280p; HEVC/4K/10-bit files must be converted separately before streaming) - **Server administration** — user accounts, bans, server properties, save config - **Per-event announcement customization** — choose exactly which events get announced - **Three sound packs** — Default, Majorly-G, Old