mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
Fix sending extracted videos (Twitch streams) to other devices
The remote control loadVideo command only carried the raw video ID and an instance URL, so extracted videos (Twitch live streams and other yt-dlp sites) failed on the receiver: it tried to fetch the ID from the /api/v1/videos endpoint, which rejects non-YouTube IDs. The command now carries the full ContentSource and title. The receiver rebuilds the VideoID from the source and opens a placeholder Video - the player then re-extracts streams and full metadata from the original URL, same as when opening the video locally. Media-source extractors (WebDAV/SMB/local) keep using the existing UUID:path branch. Both fields are optional for protocol compatibility: old receivers ignore them, commands from old senders keep the legacy behavior. Also stop sending a start time for live streams - there is no shared timeline, the receiver joins at the live edge.
This commit is contained in:
@@ -389,7 +389,7 @@ final class RemoteControlCoordinator {
|
|||||||
/// - startTime: Optional start time to seek to after loading.
|
/// - startTime: Optional start time to seek to after loading.
|
||||||
/// - pauseLocalPlayback: If true, pause local playback when remote device starts playing (for "Move to" feature).
|
/// - pauseLocalPlayback: If true, pause local playback when remote device starts playing (for "Move to" feature).
|
||||||
/// - device: The device to load the video on.
|
/// - device: The device to load the video on.
|
||||||
func loadVideo(videoID: String, videoTitle: String? = nil, instanceURL: String?, startTime: TimeInterval? = nil, pauseLocalPlayback: Bool = false, on device: DiscoveredDevice) async {
|
func loadVideo(videoID: String, videoTitle: String? = nil, videoSource: ContentSource? = nil, instanceURL: String?, startTime: TimeInterval? = nil, pauseLocalPlayback: Bool = false, on device: DiscoveredDevice) async {
|
||||||
rcLog("REMOTEPLAY", "[\(device.name)] Starting remote play", details: "videoID=\(videoID), instance=\(instanceURL ?? "default"), startTime=\(startTime ?? 0), pauseLocal=\(pauseLocalPlayback)")
|
rcLog("REMOTEPLAY", "[\(device.name)] Starting remote play", details: "videoID=\(videoID), instance=\(instanceURL ?? "default"), startTime=\(startTime ?? 0), pauseLocal=\(pauseLocalPlayback)")
|
||||||
|
|
||||||
// Clear any stale pending state from previous timed-out operations
|
// Clear any stale pending state from previous timed-out operations
|
||||||
@@ -432,7 +432,7 @@ final class RemoteControlCoordinator {
|
|||||||
|
|
||||||
// For move operations, use handshake protocol: remote prepares but waits for play command
|
// For move operations, use handshake protocol: remote prepares but waits for play command
|
||||||
let awaitPlayCommand = pauseLocalPlayback
|
let awaitPlayCommand = pauseLocalPlayback
|
||||||
await sendCommand(.loadVideo(videoID: videoID, instanceURL: instanceURL, startTime: startTime, awaitPlayCommand: awaitPlayCommand), to: device)
|
await sendCommand(.loadVideo(videoID: videoID, instanceURL: instanceURL, startTime: startTime, awaitPlayCommand: awaitPlayCommand, videoSource: videoSource, videoTitle: videoTitle), to: device)
|
||||||
rcLog("REMOTEPLAY", "[\(device.name)] loadVideo command sent, waiting for state update...")
|
rcLog("REMOTEPLAY", "[\(device.name)] loadVideo command sent, waiting for state update...")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,8 +620,8 @@ final class RemoteControlCoordinator {
|
|||||||
playerService?.state.rate = playbackRate
|
playerService?.state.rate = playbackRate
|
||||||
}
|
}
|
||||||
|
|
||||||
case .loadVideo(let videoID, let instanceURLString, let startTime, let awaitPlayCommand):
|
case .loadVideo(let videoID, let instanceURLString, let startTime, let awaitPlayCommand, let videoSource, let videoTitle):
|
||||||
rcLog("HANDLE", "[\(senderName)] Executing: loadVideo", details: "videoID=\(videoID), instance=\(instanceURLString ?? "default"), startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand ?? false)")
|
rcLog("HANDLE", "[\(senderName)] Executing: loadVideo", details: "videoID=\(videoID), instance=\(instanceURLString ?? "default"), startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand ?? false), source=\(videoSource.map(String.init(describing:)) ?? "none")")
|
||||||
// Show toast indicating remote video opening
|
// Show toast indicating remote video opening
|
||||||
if let deviceName = controllingDevice?.name {
|
if let deviceName = controllingDevice?.name {
|
||||||
toastManager?.show(
|
toastManager?.show(
|
||||||
@@ -633,7 +633,7 @@ final class RemoteControlCoordinator {
|
|||||||
autoDismissDelay: 5.0
|
autoDismissDelay: 5.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
await handleLoadVideo(videoID: videoID, instanceURLString: instanceURLString, startTime: startTime, awaitPlayCommand: awaitPlayCommand ?? false, senderDeviceID: message.senderDeviceID)
|
await handleLoadVideo(videoID: videoID, instanceURLString: instanceURLString, startTime: startTime, awaitPlayCommand: awaitPlayCommand ?? false, videoSource: videoSource, videoTitle: videoTitle, senderDeviceID: message.senderDeviceID)
|
||||||
|
|
||||||
case .closeVideo:
|
case .closeVideo:
|
||||||
rcLog("HANDLE", "[\(senderName)] Executing: closeVideo")
|
rcLog("HANDLE", "[\(senderName)] Executing: closeVideo")
|
||||||
@@ -766,8 +766,11 @@ final class RemoteControlCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLoadVideo(videoID: String, instanceURLString: String?, startTime: TimeInterval? = nil, awaitPlayCommand: Bool = false, senderDeviceID: String? = nil) async {
|
private func handleLoadVideo(videoID: String, instanceURLString: String?, startTime: TimeInterval? = nil, awaitPlayCommand: Bool = false, videoSource: ContentSource? = nil, videoTitle: String? = nil, senderDeviceID: String? = nil) async {
|
||||||
rcLog("LOADVIDEO", "Loading video: \(videoID)", details: "startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand)")
|
rcLog("LOADVIDEO", "Loading video: \(videoID)", details: "startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand)")
|
||||||
|
// Short standalone line so it can't be lost to log truncation - confirms this build
|
||||||
|
// understands videoSource and shows what was decoded from the wire
|
||||||
|
rcLog("LOADVIDEO", "Protocol v2: videoSource=\(videoSource?.id ?? "nil"), title=\(videoTitle ?? "nil")")
|
||||||
|
|
||||||
// Check if we're already playing the same video - just seek instead of reloading
|
// Check if we're already playing the same video - just seek instead of reloading
|
||||||
if let currentVideoID = playerService?.state.currentVideo?.id.videoID,
|
if let currentVideoID = playerService?.state.currentVideo?.id.videoID,
|
||||||
@@ -808,6 +811,29 @@ final class RemoteControlCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extracted videos (Twitch streams, other yt-dlp sites) can't be fetched from the
|
||||||
|
// /videos API - reconstruct the VideoID from the sender's ContentSource and let the
|
||||||
|
// player re-extract from the original URL. Media-source extractors (WebDAV/SMB/local)
|
||||||
|
// are excluded: they're handled by the UUID:path branch above and need a locally
|
||||||
|
// configured source, not extraction.
|
||||||
|
if let videoSource,
|
||||||
|
case .extracted(let extractor, let originalURL) = videoSource,
|
||||||
|
extractor != MediaFile.webdavProvider,
|
||||||
|
extractor != MediaFile.smbProvider,
|
||||||
|
extractor != MediaFile.localFolderProvider {
|
||||||
|
rcLog("LOADVIDEO", "Detected extracted video (\(extractor)), loading via original URL: \(originalURL.absoluteString)")
|
||||||
|
await handleLoadExtractedVideo(
|
||||||
|
videoID: videoID,
|
||||||
|
source: videoSource,
|
||||||
|
originalURL: originalURL,
|
||||||
|
title: videoTitle,
|
||||||
|
startTime: startTime,
|
||||||
|
awaitPlayCommand: awaitPlayCommand,
|
||||||
|
senderDeviceID: senderDeviceID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Existing API video loading...
|
// Existing API video loading...
|
||||||
guard let contentService, let instancesManager else {
|
guard let contentService, let instancesManager else {
|
||||||
rcLog("LOADVIDEO", "Missing contentService or instancesManager", isError: true)
|
rcLog("LOADVIDEO", "Missing contentService or instancesManager", isError: true)
|
||||||
@@ -941,6 +967,53 @@ final class RemoteControlCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads an extracted video (Twitch stream, other yt-dlp sites) received from a remote device.
|
||||||
|
/// Builds a placeholder Video carrying the original ContentSource - the player re-extracts
|
||||||
|
/// streams and full metadata from the original URL when opening it.
|
||||||
|
private func handleLoadExtractedVideo(videoID: String, source: ContentSource, originalURL: URL, title: String?, startTime: TimeInterval? = nil, awaitPlayCommand: Bool = false, senderDeviceID: String? = nil) async {
|
||||||
|
let video = Video(
|
||||||
|
id: VideoID(source: source, videoID: videoID),
|
||||||
|
title: title ?? originalURL.absoluteString,
|
||||||
|
description: nil,
|
||||||
|
author: Author(id: "", name: originalURL.host ?? "", hasRealChannelInfo: false),
|
||||||
|
duration: 0,
|
||||||
|
publishedAt: nil,
|
||||||
|
publishedText: nil,
|
||||||
|
viewCount: nil,
|
||||||
|
likeCount: nil,
|
||||||
|
thumbnails: [],
|
||||||
|
isLive: false,
|
||||||
|
isUpcoming: false,
|
||||||
|
scheduledStartTime: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if awaitPlayCommand {
|
||||||
|
// Handshake protocol: load video, then pause and notify sender we're ready
|
||||||
|
rcLog("LOADVIDEO", "Handshake mode: loading extracted video, will pause when ready")
|
||||||
|
playerService?.openVideo(video, startTime: startTime)
|
||||||
|
|
||||||
|
// Wait for video to start playing, then pause
|
||||||
|
try? await Task.sleep(for: .milliseconds(1000))
|
||||||
|
|
||||||
|
// Pause playback - video is loaded and at correct position but not playing
|
||||||
|
playerService?.pause()
|
||||||
|
rcLog("LOADVIDEO", "Extracted video loaded and paused, ready for handoff")
|
||||||
|
|
||||||
|
// Send state update to let sender know we're ready (isPlaying will be false)
|
||||||
|
let state = currentRemoteState()
|
||||||
|
rcLog("LOADVIDEO", "Sending ready state to sender", details: "videoID=\(state.videoID ?? "nil"), playing=\(state.isPlaying)")
|
||||||
|
if let senderID = senderDeviceID {
|
||||||
|
try? await networkService.send(command: .stateUpdate(state), to: senderID)
|
||||||
|
} else {
|
||||||
|
await networkService.broadcast(command: .stateUpdate(state))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal mode: start playing immediately
|
||||||
|
playerService?.openVideo(video, startTime: startTime)
|
||||||
|
rcLog("LOADVIDEO", "Extracted video opened: \(video.title)", details: "startTime=\(startTime ?? 0)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func expandPlayerIfNeeded() {
|
private func expandPlayerIfNeeded() {
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
let isPiPActive = (playerService?.currentBackend as? MPVBackend)?.isPiPActive ?? false
|
let isPiPActive = (playerService?.currentBackend as? MPVBackend)?.isPiPActive ?? false
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ enum RemoteControlCommand: Codable, Sendable {
|
|||||||
case setVolume(Float)
|
case setVolume(Float)
|
||||||
case setMuted(Bool)
|
case setMuted(Bool)
|
||||||
case setRate(Float)
|
case setRate(Float)
|
||||||
case loadVideo(videoID: String, instanceURL: String?, startTime: TimeInterval?, awaitPlayCommand: Bool?)
|
/// `videoSource` carries the full ContentSource so non-API videos (e.g. extracted
|
||||||
|
/// Twitch streams) can be reconstructed on the receiver. Optional for
|
||||||
|
/// backward compatibility with older app versions.
|
||||||
|
case loadVideo(videoID: String, instanceURL: String?, startTime: TimeInterval?, awaitPlayCommand: Bool?, videoSource: ContentSource?, videoTitle: String?)
|
||||||
case closeVideo
|
case closeVideo
|
||||||
case toggleFullscreen
|
case toggleFullscreen
|
||||||
case playNext
|
case playNext
|
||||||
|
|||||||
@@ -527,8 +527,9 @@ struct VideoContextMenuContent: View {
|
|||||||
await remoteControl.loadVideo(
|
await remoteControl.loadVideo(
|
||||||
videoID: video.id.videoID,
|
videoID: video.id.videoID,
|
||||||
videoTitle: video.title,
|
videoTitle: video.title,
|
||||||
|
videoSource: video.id.source,
|
||||||
instanceURL: instanceURL,
|
instanceURL: instanceURL,
|
||||||
startTime: startTime,
|
startTime: video.isLive ? nil : startTime,
|
||||||
pauseLocalPlayback: false,
|
pauseLocalPlayback: false,
|
||||||
on: device
|
on: device
|
||||||
)
|
)
|
||||||
@@ -556,11 +557,13 @@ struct VideoContextMenuContent: View {
|
|||||||
|
|
||||||
// Send load video command with current playback time
|
// Send load video command with current playback time
|
||||||
// pauseLocalPlayback: true will pause local playback when remote device confirms it started playing
|
// pauseLocalPlayback: true will pause local playback when remote device confirms it started playing
|
||||||
|
// Live streams have no shared timeline - the remote device joins the stream at the live edge
|
||||||
await remoteControl.loadVideo(
|
await remoteControl.loadVideo(
|
||||||
videoID: video.id.videoID,
|
videoID: video.id.videoID,
|
||||||
videoTitle: video.title,
|
videoTitle: video.title,
|
||||||
|
videoSource: video.id.source,
|
||||||
instanceURL: instanceURL,
|
instanceURL: instanceURL,
|
||||||
startTime: currentTime,
|
startTime: video.isLive ? nil : currentTime,
|
||||||
pauseLocalPlayback: true,
|
pauseLocalPlayback: true,
|
||||||
on: device
|
on: device
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user