mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Add URL scheme actions: play, queue, download, bookmark, subscribe, player controls
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.
Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
This commit is contained in:
@@ -627,6 +627,20 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether URL scheme actions that change saved state (queue, bookmark,
|
||||
/// download, subscribe) run without asking first. Off by default: any web
|
||||
/// page can open a yattee:// link. Local-only, like `defaultLinkAction`.
|
||||
var linkActionsRunWithoutConfirmation: Bool {
|
||||
get {
|
||||
if let cached = _linkActionsRunWithoutConfirmation { return cached }
|
||||
return localDefaults.bool(forKey: "linkActionsRunWithoutConfirmation")
|
||||
}
|
||||
set {
|
||||
_linkActionsRunWithoutConfirmation = newValue
|
||||
localDefaults.set(newValue, forKey: "linkActionsRunWithoutConfirmation")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Tap Actions (iOS/macOS only)
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
@@ -215,6 +215,7 @@ final class SettingsManager {
|
||||
|
||||
// Link action settings
|
||||
var _defaultLinkAction: DefaultLinkAction?
|
||||
var _linkActionsRunWithoutConfirmation: Bool?
|
||||
|
||||
// Video tap actions (iOS/macOS only)
|
||||
#if !os(tvOS)
|
||||
@@ -554,6 +555,7 @@ final class SettingsManager {
|
||||
_handoffEnabled = nil
|
||||
_privacyFrontendSharingEnabled = nil
|
||||
_defaultLinkAction = nil
|
||||
_linkActionsRunWithoutConfirmation = nil
|
||||
_remoteControlCustomDeviceName = nil
|
||||
_remoteControlHideWhenBackgrounded = nil
|
||||
#if !os(tvOS)
|
||||
|
||||
@@ -2535,6 +2535,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deepLink.unsupported.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Can't open link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"discovery.empty.description" : {
|
||||
"comment" : "Description when no network shares are found",
|
||||
"localizations" : {
|
||||
@@ -5733,6 +5744,259 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.bookmark.button" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Bookmark"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.bookmark.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Add Bookmark?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.download.button" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Download"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.download.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Download Video?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.playNext.button" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Play Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.playNext.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Play Next?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.queue.button" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Add to Queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.queue.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Add to Queue?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.subscribe.button" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Subscribe"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.subscribe.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Subscribe?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.unsubscribe.button" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Unsubscribe"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.confirm.unsubscribe.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Unsubscribe?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.done.bookmark" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Bookmarked"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.done.playNext" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Playing next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.done.queue" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Added to queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.done.subscribe" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Subscribed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.done.unsubscribe" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Unsubscribed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.error.downloadUnsupported" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Downloads aren't available on this device."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.error.notAChannel" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "The link isn't a channel."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.error.nothingPlaying" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Nothing is playing."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.error.queueDisabled" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "The queue is turned off in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.error.title" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Couldn't complete the link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAction.error.unsupportedRate" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Supported rates: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"login.error.invalidCredentials" : {
|
||||
"comment" : "Error message for invalid login credentials",
|
||||
"localizations" : {
|
||||
@@ -11768,6 +12032,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.behavior.linkActionsWithoutConfirmation" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Run Link Actions Without Asking"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.behavior.linkActionsWithoutConfirmation.footer" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "yattee:// links can queue, bookmark, download and subscribe. Leave this off to confirm each one, since any website or app can open these links."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.behavior.miniPlayer.header" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
176
Yattee/Services/Navigation/LinkAction.swift
Normal file
176
Yattee/Services/Navigation/LinkAction.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// LinkAction.swift
|
||||
// Yattee
|
||||
//
|
||||
// URL scheme actions: links that do something rather than open a screen.
|
||||
//
|
||||
// yattee://play?url={link}&t={seconds}
|
||||
// yattee://play-next?url={link}
|
||||
// yattee://queue?url={link}
|
||||
// yattee://download?url={link}
|
||||
// yattee://bookmark?url={link}
|
||||
// yattee://subscribe?url={channel link}
|
||||
// yattee://unsubscribe?url={channel link}
|
||||
// yattee://player/play | pause | toggle | next | previous | close
|
||||
// yattee://player/seek?t={seconds} | seek?by={±seconds}
|
||||
// yattee://player/rate?value={rate}
|
||||
//
|
||||
// `url` may be percent-encoded or raw; a raw link keeps its own query
|
||||
// (`&list=…`) because only the action's own parameters end it.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// An action requested through the URL scheme.
|
||||
enum LinkAction: Equatable {
|
||||
case play(URL, startTime: TimeInterval?)
|
||||
case playNext(URL)
|
||||
case addToQueue(URL)
|
||||
case download(URL)
|
||||
case bookmark(URL)
|
||||
case subscribe(URL)
|
||||
case unsubscribe(URL)
|
||||
case player(PlayerCommand)
|
||||
|
||||
enum PlayerCommand: Equatable {
|
||||
case play
|
||||
case pause
|
||||
case toggle
|
||||
case next
|
||||
case previous
|
||||
case close
|
||||
case seek(to: TimeInterval)
|
||||
case seekBy(TimeInterval)
|
||||
case rate(Double)
|
||||
}
|
||||
|
||||
/// Whether the main window's sheets should go away first. Only actions
|
||||
/// that move the user somewhere do; queueing a video or pausing the
|
||||
/// player leaves whatever is on screen alone.
|
||||
var dismissesPresentations: Bool {
|
||||
if case .play = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// What an action did, for callers that report back (x-callback-url).
|
||||
enum LinkActionResult: Equatable {
|
||||
case success([String: String] = [:])
|
||||
case failure(code: String, message: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
struct LinkActionParser {
|
||||
/// Parameters that belong to the action URL itself. Everything else
|
||||
/// after `url=` is part of the wrapped link.
|
||||
static let actionParameters: Set<String> = [
|
||||
"url", "t", "time", "start", "by", "value",
|
||||
"x-success", "x-error", "x-cancel", "x-source"
|
||||
]
|
||||
|
||||
/// Hosts claimed by actions, so `URLRouter` never reads them as 1.x links.
|
||||
static let hosts: Set<String> = [
|
||||
"play", "play-next", "queue", "download", "bookmark", "subscribe", "unsubscribe", "player"
|
||||
]
|
||||
|
||||
func parse(_ url: URL) -> LinkAction? {
|
||||
guard url.scheme?.lowercased() == "yattee",
|
||||
let host = url.host?.lowercased(), Self.hosts.contains(host) else {
|
||||
return nil
|
||||
}
|
||||
let query = Self.queryParameters(of: url)
|
||||
|
||||
if host == "player" {
|
||||
return parsePlayerCommand(url: url, query: query)
|
||||
}
|
||||
|
||||
guard let target = query["url"].flatMap(Self.targetURL) else { return nil }
|
||||
|
||||
switch host {
|
||||
case "play":
|
||||
let outer = ["t", "time", "start"].lazy.compactMap { query[$0] }.first
|
||||
.flatMap(URLRouter.parseTimestampValue)
|
||||
return .play(target, startTime: outer ?? URLRouter().parseTimestamp(target))
|
||||
case "play-next":
|
||||
return .playNext(target)
|
||||
case "queue":
|
||||
return .addToQueue(target)
|
||||
case "download":
|
||||
return .download(target)
|
||||
case "bookmark":
|
||||
return .bookmark(target)
|
||||
case "subscribe":
|
||||
return .subscribe(target)
|
||||
case "unsubscribe":
|
||||
return .unsubscribe(target)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func parsePlayerCommand(url: URL, query: [String: String]) -> LinkAction? {
|
||||
let verb = url.pathComponents.dropFirst().first?.lowercased() ?? ""
|
||||
switch verb {
|
||||
case "play": return .player(.play)
|
||||
case "pause": return .player(.pause)
|
||||
case "toggle": return .player(.toggle)
|
||||
case "next": return .player(.next)
|
||||
case "previous": return .player(.previous)
|
||||
case "close": return .player(.close)
|
||||
case "seek":
|
||||
if let raw = query["by"], let delta = Double(raw), delta.isFinite {
|
||||
return .player(.seekBy(delta))
|
||||
}
|
||||
if let raw = query["t"] ?? query["time"], let time = URLRouter.parseTimestampValue(raw) {
|
||||
return .player(.seek(to: time))
|
||||
}
|
||||
return nil
|
||||
case "rate":
|
||||
guard let raw = query["value"], let rate = Double(raw), rate.isFinite, rate > 0 else { return nil }
|
||||
return .player(.rate(rate))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapped link: http(s) or a yattee:// screen link, never a local file.
|
||||
private static func targetURL(_ raw: String) -> URL? {
|
||||
// A raw link keeps its percent-escapes; an encoded one is decoded once.
|
||||
let string = raw.contains("://") ? raw : (raw.removingPercentEncoding ?? raw)
|
||||
guard let url = URL(string: string),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
["http", "https", "yattee"].contains(scheme) else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Splits the query on `&`, but keeps an unencoded `url=` value whole:
|
||||
/// it runs until the next parameter the action itself defines.
|
||||
static func queryParameters(of url: URL) -> [String: String] {
|
||||
let absolute = url.absoluteString
|
||||
guard let questionMark = absolute.firstIndex(of: "?") else { return [:] }
|
||||
let pieces = absolute[absolute.index(after: questionMark)...].split(separator: "&", omittingEmptySubsequences: false)
|
||||
|
||||
var result: [String: String] = [:]
|
||||
var currentKey: String?
|
||||
for piece in pieces {
|
||||
let parts = piece.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let key = (parts.first.map(String.init) ?? "").removingPercentEncoding ?? ""
|
||||
let value = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
if currentKey == "url", !actionParameters.contains(key) {
|
||||
// Still inside the wrapped link
|
||||
result["url", default: ""] += "&" + piece
|
||||
continue
|
||||
}
|
||||
currentKey = key
|
||||
guard !key.isEmpty else { continue }
|
||||
// The wrapped link is decoded later, once the whole of it is known
|
||||
result[key] = key == "url" ? value : (value.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
336
Yattee/Services/Navigation/LinkActionPerformer.swift
Normal file
336
Yattee/Services/Navigation/LinkActionPerformer.swift
Normal file
@@ -0,0 +1,336 @@
|
||||
//
|
||||
// LinkActionPerformer.swift
|
||||
// Yattee
|
||||
//
|
||||
// Runs URL scheme actions (`LinkAction`) and reports what happened, so the
|
||||
// same code serves plain links now and x-callback-url callers later.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct LinkActionPerformer {
|
||||
let appEnvironment: AppEnvironment
|
||||
|
||||
func perform(_ action: LinkAction) async -> LinkActionResult {
|
||||
LoggingService.shared.info("Link action: \(action)", category: .general)
|
||||
let result: LinkActionResult
|
||||
switch action {
|
||||
case .play(let url, let startTime):
|
||||
result = await play(url, startTime: startTime)
|
||||
case .playNext(let url):
|
||||
result = await queue(url, next: true)
|
||||
case .addToQueue(let url):
|
||||
result = await queue(url, next: false)
|
||||
case .download(let url):
|
||||
result = await download(url)
|
||||
case .bookmark(let url):
|
||||
result = await bookmark(url)
|
||||
case .subscribe(let url):
|
||||
result = await setSubscribed(true, channelURL: url)
|
||||
case .unsubscribe(let url):
|
||||
result = await setSubscribed(false, channelURL: url)
|
||||
case .player(let command):
|
||||
result = await perform(command)
|
||||
}
|
||||
report(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Video Actions
|
||||
|
||||
private func play(_ url: URL, startTime: TimeInterval?) async -> LinkActionResult {
|
||||
let resolved: Video
|
||||
switch await resolve(url) {
|
||||
case .success(let video): resolved = video.0
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
appEnvironment.playerService.openVideo(resolved, startTime: startTime, forceStartTime: startTime != nil)
|
||||
return .success(values(for: resolved))
|
||||
}
|
||||
|
||||
private func queue(_ url: URL, next: Bool) async -> LinkActionResult {
|
||||
let queueManager = appEnvironment.queueManager
|
||||
guard queueManager.isQueueEnabled else {
|
||||
return .failure(code: "queueDisabled", message: String(localized: "linkAction.error.queueDisabled", defaultValue: "The queue is turned off in Settings."))
|
||||
}
|
||||
let video: Video
|
||||
switch await resolve(url) {
|
||||
case .success(let resolved): video = resolved.0
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
let confirmed = await confirm(
|
||||
title: next
|
||||
? String(localized: "linkAction.confirm.playNext.title", defaultValue: "Play Next?")
|
||||
: String(localized: "linkAction.confirm.queue.title", defaultValue: "Add to Queue?"),
|
||||
subject: video.title,
|
||||
detail: video.author.name,
|
||||
confirmTitle: next
|
||||
? String(localized: "linkAction.confirm.playNext.button", defaultValue: "Play Next")
|
||||
: String(localized: "linkAction.confirm.queue.button", defaultValue: "Add to Queue"),
|
||||
systemImage: next ? "text.line.first.and.arrowtriangle.forward" : "text.append"
|
||||
)
|
||||
guard confirmed else { return .cancelled }
|
||||
|
||||
if next {
|
||||
queueManager.playNext(video, queueSource: .manual)
|
||||
} else {
|
||||
queueManager.addToQueue(video, queueSource: .manual)
|
||||
}
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
next
|
||||
? String(localized: "linkAction.done.playNext", defaultValue: "Playing next")
|
||||
: String(localized: "linkAction.done.queue", defaultValue: "Added to queue"),
|
||||
subtitle: video.title
|
||||
)
|
||||
return .success(values(for: video))
|
||||
}
|
||||
|
||||
private func download(_ url: URL) async -> LinkActionResult {
|
||||
#if os(tvOS)
|
||||
return .failure(code: "unsupported", message: String(localized: "linkAction.error.downloadUnsupported", defaultValue: "Downloads aren't available on this device."))
|
||||
#else
|
||||
let video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]
|
||||
switch await resolve(url) {
|
||||
case .success(let resolved): (video, streams, captions, storyboards) = resolved
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
|
||||
let downloadSettings = appEnvironment.downloadSettings
|
||||
// The quality picker is already a confirmation: nothing is saved until
|
||||
// the user picks
|
||||
guard downloadSettings.preferredDownloadQuality != .ask else {
|
||||
appEnvironment.navigationCoordinator.presenter.present(.downloadOptions(video, streams: streams, captions: captions))
|
||||
return .success(values(for: video).merging(["status": "picking"]) { $1 })
|
||||
}
|
||||
|
||||
let confirmed = await confirm(
|
||||
title: String(localized: "linkAction.confirm.download.title", defaultValue: "Download Video?"),
|
||||
subject: video.title,
|
||||
detail: video.author.name,
|
||||
confirmTitle: String(localized: "linkAction.confirm.download.button", defaultValue: "Download"),
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
guard confirmed else { return .cancelled }
|
||||
|
||||
do {
|
||||
try await LinkVideoResolver(appEnvironment: appEnvironment).enqueueDownload(
|
||||
video: video,
|
||||
streams: streams,
|
||||
captions: captions,
|
||||
storyboards: storyboards,
|
||||
quality: downloadSettings.preferredDownloadQuality,
|
||||
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload
|
||||
)
|
||||
} catch {
|
||||
return .failure(code: "downloadFailed", message: error.localizedDescription)
|
||||
}
|
||||
return .success(values(for: video).merging(["status": "queued"]) { $1 })
|
||||
#endif
|
||||
}
|
||||
|
||||
private func bookmark(_ url: URL) async -> LinkActionResult {
|
||||
let video: Video
|
||||
switch await resolve(url) {
|
||||
case .success(let resolved): video = resolved.0
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
let dataManager = appEnvironment.dataManager
|
||||
guard !dataManager.isBookmarked(videoID: video.id.videoID) else {
|
||||
return .success(values(for: video))
|
||||
}
|
||||
let confirmed = await confirm(
|
||||
title: String(localized: "linkAction.confirm.bookmark.title", defaultValue: "Add Bookmark?"),
|
||||
subject: video.title,
|
||||
detail: video.author.name,
|
||||
confirmTitle: String(localized: "linkAction.confirm.bookmark.button", defaultValue: "Bookmark"),
|
||||
systemImage: "bookmark"
|
||||
)
|
||||
guard confirmed else { return .cancelled }
|
||||
|
||||
dataManager.addBookmark(for: video)
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
String(localized: "linkAction.done.bookmark", defaultValue: "Bookmarked"),
|
||||
subtitle: video.title
|
||||
)
|
||||
return .success(values(for: video))
|
||||
}
|
||||
|
||||
// MARK: - Subscriptions
|
||||
|
||||
private func setSubscribed(_ subscribe: Bool, channelURL url: URL) async -> LinkActionResult {
|
||||
guard case .channel(let channelID, let source) = URLRouter().route(url) else {
|
||||
return .failure(code: "notAChannel", message: String(localized: "linkAction.error.notAChannel", defaultValue: "The link isn't a channel."))
|
||||
}
|
||||
guard let instance = appEnvironment.instancesManager.instance(for: source) else {
|
||||
return LinkActionFailure.noSource.result
|
||||
}
|
||||
|
||||
let channel: Channel
|
||||
do {
|
||||
// Resolves @handles and custom URLs to the channel's real ID
|
||||
channel = try await appEnvironment.contentService.channel(id: channelID, instance: instance)
|
||||
} catch {
|
||||
return .failure(code: "loadFailed", message: error.localizedDescription)
|
||||
}
|
||||
|
||||
let service = appEnvironment.subscriptionService
|
||||
let values = ["channelID": channel.id.channelID, "channel": channel.name]
|
||||
if await service.isSubscribed(to: channel.id.channelID) == subscribe {
|
||||
return .success(values)
|
||||
}
|
||||
|
||||
let confirmed = await confirm(
|
||||
title: subscribe
|
||||
? String(localized: "linkAction.confirm.subscribe.title", defaultValue: "Subscribe?")
|
||||
: String(localized: "linkAction.confirm.unsubscribe.title", defaultValue: "Unsubscribe?"),
|
||||
subject: channel.name,
|
||||
detail: nil,
|
||||
confirmTitle: subscribe
|
||||
? String(localized: "linkAction.confirm.subscribe.button", defaultValue: "Subscribe")
|
||||
: String(localized: "linkAction.confirm.unsubscribe.button", defaultValue: "Unsubscribe"),
|
||||
systemImage: subscribe ? "person.badge.plus" : "person.badge.minus"
|
||||
)
|
||||
guard confirmed else { return .cancelled }
|
||||
|
||||
do {
|
||||
if subscribe {
|
||||
try await service.subscribe(to: channel)
|
||||
if appEnvironment.settingsManager.defaultNotificationsForNewChannels {
|
||||
appEnvironment.dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
|
||||
}
|
||||
} else {
|
||||
try await service.unsubscribe(from: channel.id.channelID)
|
||||
}
|
||||
} catch {
|
||||
return .failure(code: subscribe ? "subscribeFailed" : "unsubscribeFailed", message: error.localizedDescription)
|
||||
}
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
subscribe
|
||||
? String(localized: "linkAction.done.subscribe", defaultValue: "Subscribed")
|
||||
: String(localized: "linkAction.done.unsubscribe", defaultValue: "Unsubscribed"),
|
||||
subtitle: channel.name
|
||||
)
|
||||
return .success(values)
|
||||
}
|
||||
|
||||
// MARK: - Player Commands
|
||||
|
||||
private func perform(_ command: LinkAction.PlayerCommand) async -> LinkActionResult {
|
||||
let player = appEnvironment.playerService
|
||||
guard player.state.currentVideo != nil else {
|
||||
return .failure(code: "nothingPlaying", message: String(localized: "linkAction.error.nothingPlaying", defaultValue: "Nothing is playing."))
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .play:
|
||||
player.resume()
|
||||
case .pause:
|
||||
player.pause()
|
||||
case .toggle:
|
||||
player.togglePlayPause()
|
||||
case .next:
|
||||
await player.playNext()
|
||||
case .previous:
|
||||
await player.playPrevious()
|
||||
case .close:
|
||||
appEnvironment.navigationCoordinator.isPlayerExpanded = false
|
||||
player.stop()
|
||||
return .success()
|
||||
case .seek(let time):
|
||||
await player.seek(to: clampedTime(time))
|
||||
case .seekBy(let delta):
|
||||
await player.seek(to: clampedTime(player.state.currentTime + delta))
|
||||
case .rate(let value):
|
||||
guard let rate = PlaybackRate(rawValue: value) else {
|
||||
let supported = PlaybackRate.allCases.map { String($0.rawValue) }.joined(separator: ", ")
|
||||
return .failure(code: "unsupportedRate", message: String(localized: "linkAction.error.unsupportedRate", defaultValue: "Supported rates: \(supported)"))
|
||||
}
|
||||
player.currentBackend?.rate = Float(rate.rawValue)
|
||||
player.state.rate = rate
|
||||
}
|
||||
return .success(playerValues())
|
||||
}
|
||||
|
||||
private func clampedTime(_ time: TimeInterval) -> TimeInterval {
|
||||
let duration = appEnvironment.playerService.state.duration
|
||||
let upper = duration > 0 ? duration : time
|
||||
return min(max(time, 0), upper)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private enum LinkActionFailure: Error {
|
||||
case noSource
|
||||
case notAVideo(String)
|
||||
case loadFailed(String)
|
||||
|
||||
var result: LinkActionResult {
|
||||
switch self {
|
||||
case .noSource:
|
||||
return .failure(code: "noSource", message: String(localized: "deepLink.noInstance.subtitle", defaultValue: "No source is configured for this video."))
|
||||
case .notAVideo(let message):
|
||||
return .failure(code: "notAVideo", message: message)
|
||||
case .loadFailed(let message):
|
||||
return .failure(code: "loadFailed", message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the video a link points at, showing progress while it does.
|
||||
private func resolve(_ url: URL) async -> Result<(Video, [Stream], [Caption], [Storyboard]), LinkActionFailure> {
|
||||
let toastID = appEnvironment.toastManager.show(
|
||||
scopes: [.main, .player],
|
||||
category: .loading,
|
||||
title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"),
|
||||
autoDismissDelay: 30.0
|
||||
)
|
||||
defer { appEnvironment.toastManager.dismiss(id: toastID) }
|
||||
|
||||
do {
|
||||
return .success(try await LinkVideoResolver(appEnvironment: appEnvironment).resolve(url))
|
||||
} catch let error as LinkResolveError {
|
||||
switch error {
|
||||
case .noInstanceAvailable: return .failure(.noSource)
|
||||
case .noYatteeServer, .notAVideo: return .failure(.notAVideo(error.localizedDescription))
|
||||
}
|
||||
} catch {
|
||||
return .failure(.loadFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks first unless the user lets automation links change things on their own.
|
||||
private func confirm(title: String, subject: String?, detail: String?, confirmTitle: String, systemImage: String) async -> Bool {
|
||||
guard !appEnvironment.settingsManager.linkActionsRunWithoutConfirmation else { return true }
|
||||
let confirmation = LinkActionConfirmation(
|
||||
title: title,
|
||||
subject: subject,
|
||||
detail: detail,
|
||||
confirmTitle: confirmTitle,
|
||||
systemImage: systemImage
|
||||
)
|
||||
return await confirmation.ask(using: appEnvironment.navigationCoordinator.presenter)
|
||||
}
|
||||
|
||||
private func values(for video: Video) -> [String: String] {
|
||||
["videoID": video.id.videoID, "title": video.title, "channel": video.author.name]
|
||||
}
|
||||
|
||||
private func playerValues() -> [String: String] {
|
||||
let state = appEnvironment.playerService.state
|
||||
var values = state.currentVideo.map(values(for:)) ?? [:]
|
||||
values["position"] = String(Int(state.currentTime))
|
||||
values["rate"] = String(state.rate.rawValue)
|
||||
return values
|
||||
}
|
||||
|
||||
/// Failures show a toast; callers that report back get the same message.
|
||||
private func report(_ result: LinkActionResult) {
|
||||
guard case .failure(let code, let message) = result else { return }
|
||||
LoggingService.shared.warning("Link action failed: \(code) — \(message)", category: .general)
|
||||
appEnvironment.toastManager.showError(
|
||||
String(localized: "linkAction.error.title", defaultValue: "Couldn't complete the link"),
|
||||
subtitle: message
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ enum AppSheet: Identifiable {
|
||||
case openLink(URL?)
|
||||
/// Continue / start over for a partially watched video.
|
||||
case resumeAction(Video, resumeTime: TimeInterval, onContinue: () -> Void, onStartOver: () -> Void)
|
||||
/// Asks before a URL scheme action changes saved state.
|
||||
case linkActionConfirmation(LinkActionConfirmation)
|
||||
#if !os(tvOS)
|
||||
/// Download quality picker. Streams and captions already loaded by the
|
||||
/// caller (the player) skip the sheet's own fetch.
|
||||
@@ -48,6 +50,8 @@ enum AppSheet: Identifiable {
|
||||
return "queueAction-\(video.id.id)"
|
||||
case .resumeAction(let video, _, _, _):
|
||||
return "resumeAction-\(video.id.id)"
|
||||
case .linkActionConfirmation(let confirmation):
|
||||
return "linkActionConfirmation-\(confirmation.id)"
|
||||
#if !os(tvOS)
|
||||
case .downloadOptions(let video, _, _, _):
|
||||
return "downloadOptions-\(video.id.id)"
|
||||
@@ -150,6 +154,8 @@ private struct AppSheetHost: ViewModifier {
|
||||
QueueActionSheet(video: video, queueSource: queueSource)
|
||||
case .resumeAction(let video, let resumeTime, let onContinue, let onStartOver):
|
||||
ResumeActionSheet(video: video, resumeTime: resumeTime, onContinue: onContinue, onStartOver: onStartOver)
|
||||
case .linkActionConfirmation(let confirmation):
|
||||
LinkActionConfirmationSheet(confirmation: confirmation)
|
||||
#if !os(tvOS)
|
||||
case .downloadOptions(let video, let streams, let captions, let dislikeCount):
|
||||
DownloadQualitySheet(video: video, streams: streams, captions: captions, dislikeCount: dislikeCount)
|
||||
|
||||
@@ -301,6 +301,8 @@ struct URLRouter: Sendable {
|
||||
return nil
|
||||
|
||||
default:
|
||||
// Action hosts (yattee://play, yattee://player/…) are LinkActionParser's
|
||||
if LinkActionParser.hosts.contains(host) { return nil }
|
||||
return parseLegacyScheme(url)
|
||||
}
|
||||
}
|
||||
|
||||
141
Yattee/Views/Components/LinkActionConfirmationSheet.swift
Normal file
141
Yattee/Views/Components/LinkActionConfirmationSheet.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// LinkActionConfirmationSheet.swift
|
||||
// Yattee
|
||||
//
|
||||
// Asks before a URL scheme action changes saved state. Any web page can open
|
||||
// a yattee:// link, so queueing, bookmarking, downloading and subscribing
|
||||
// wait for the user unless Settings allows them to run on their own.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A pending question for `LinkActionConfirmationSheet`. Answered exactly once:
|
||||
/// by a button, or as "cancel" when the sheet goes away any other way.
|
||||
@MainActor
|
||||
final class LinkActionConfirmation {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let subject: String?
|
||||
let detail: String?
|
||||
let confirmTitle: String
|
||||
let systemImage: String
|
||||
|
||||
private var continuation: CheckedContinuation<Bool, Never>?
|
||||
|
||||
init(title: String, subject: String?, detail: String?, confirmTitle: String, systemImage: String) {
|
||||
self.title = title
|
||||
self.subject = subject
|
||||
self.detail = detail
|
||||
self.confirmTitle = confirmTitle
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
|
||||
/// Presents the sheet through the top-most host and waits for the answer.
|
||||
func ask(using presenter: PresentationRouter) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
presenter.present(.linkActionConfirmation(self))
|
||||
}
|
||||
}
|
||||
|
||||
func answer(_ allowed: Bool) {
|
||||
continuation?.resume(returning: allowed)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkActionConfirmationSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let confirmation: LinkActionConfirmation
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onDisappear {
|
||||
// Swiped away or replaced by another sheet: treat as cancel
|
||||
confirmation.answer(false)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var content: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
header
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
Section {
|
||||
Button { respond(true) } label: {
|
||||
Label(confirmation.confirmTitle, systemImage: confirmation.systemImage)
|
||||
}
|
||||
Button(role: .cancel) { respond(false) } label: {
|
||||
Label(String(localized: "common.cancel"), systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
}
|
||||
#else
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) { respond(false) } label: {
|
||||
Text(String(localized: "common.cancel"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button { respond(true) } label: {
|
||||
Label(confirmation.confirmTitle, systemImage: confirmation.systemImage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.padding(20)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(width: 380)
|
||||
#endif
|
||||
.presentationDetents([.height(210)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(confirmation.title)
|
||||
.font(.headline)
|
||||
if let subject = confirmation.subject {
|
||||
Text(subject)
|
||||
.font(.subheadline)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if let detail = confirmation.detail {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func respond(_ allowed: Bool) {
|
||||
confirmation.answer(allowed)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,13 @@ private struct LinkActionSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsFormSection(footer: "settings.behavior.linkActionsWithoutConfirmation.footer") {
|
||||
Toggle(
|
||||
String(localized: "settings.behavior.linkActionsWithoutConfirmation"),
|
||||
isOn: $settings.linkActionsRunWithoutConfirmation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,15 @@ struct YatteeApp: App {
|
||||
.handlesExternalEvents(preferring: ["*"], allowing: ["*"])
|
||||
#endif
|
||||
.onOpenURL { url in
|
||||
if let action = LinkActionParser().parse(url) {
|
||||
if action.dismissesPresentations {
|
||||
appEnvironment.navigationCoordinator.dismissPresentations()
|
||||
}
|
||||
Task {
|
||||
_ = await LinkActionPerformer(appEnvironment: appEnvironment).perform(action)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Leave open sheets alone when the link goes nowhere
|
||||
guard URLRouter().route(url) != nil else {
|
||||
reportUnhandledDeepLink(url)
|
||||
|
||||
@@ -881,3 +881,95 @@ struct ConnectivityMonitorTests {
|
||||
#expect(monitor.isOnline == true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LinkActionParser Tests
|
||||
|
||||
@Suite("LinkActionParser Tests")
|
||||
struct LinkActionParserTests {
|
||||
let parser = LinkActionParser()
|
||||
|
||||
private func parse(_ string: String) -> LinkAction? {
|
||||
parser.parse(URL(string: string)!)
|
||||
}
|
||||
|
||||
@Test("Encoded and raw url= give the same inner link with its own query")
|
||||
func encodedAndRawTargets() {
|
||||
let inner = URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123")!
|
||||
#expect(parse("yattee://queue?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ%26list%3DPL123") == .addToQueue(inner))
|
||||
#expect(parse("yattee://queue?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123") == .addToQueue(inner))
|
||||
}
|
||||
|
||||
@Test("Play takes the action's t, falling back to the link's own")
|
||||
func playStartTime() {
|
||||
#expect(parse("yattee://play?url=https://youtu.be/dQw4w9WgXcQ&t=1m30s")
|
||||
== .play(URL(string: "https://youtu.be/dQw4w9WgXcQ")!, startTime: 90))
|
||||
#expect(parse("yattee://play?url=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ%3Ft%3D42")
|
||||
== .play(URL(string: "https://youtu.be/dQw4w9WgXcQ?t=42")!, startTime: 42))
|
||||
#expect(parse("yattee://play?url=https://youtu.be/dQw4w9WgXcQ")
|
||||
== .play(URL(string: "https://youtu.be/dQw4w9WgXcQ")!, startTime: nil))
|
||||
}
|
||||
|
||||
@Test("x-callback parameters end the raw url")
|
||||
func callbackParametersEndURL() {
|
||||
let action = parse("yattee://bookmark?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&x-success=shortcuts://done")
|
||||
#expect(action == .bookmark(URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ")!))
|
||||
}
|
||||
|
||||
@Test("Every target verb parses", arguments: [
|
||||
("play-next", LinkAction.playNext(URL(string: "https://youtu.be/abc")!)),
|
||||
("download", .download(URL(string: "https://youtu.be/abc")!)),
|
||||
("subscribe", .subscribe(URL(string: "https://youtu.be/abc")!)),
|
||||
("unsubscribe", .unsubscribe(URL(string: "https://youtu.be/abc")!)),
|
||||
])
|
||||
func targetVerbs(_ verb: String, _ expected: LinkAction) {
|
||||
#expect(parse("yattee://\(verb)?url=https://youtu.be/abc") == expected)
|
||||
}
|
||||
|
||||
@Test("Missing, local or non-web targets are rejected")
|
||||
func badTargets() {
|
||||
#expect(parse("yattee://play") == nil)
|
||||
#expect(parse("yattee://play?url=") == nil)
|
||||
#expect(parse("yattee://play?url=file:///etc/hosts") == nil)
|
||||
#expect(parse("yattee://queue?url=javascript:alert(1)") == nil)
|
||||
}
|
||||
|
||||
@Test("Player commands")
|
||||
func playerCommands() {
|
||||
#expect(parse("yattee://player/pause") == .player(.pause))
|
||||
#expect(parse("yattee://player/play") == .player(.play))
|
||||
#expect(parse("yattee://player/toggle") == .player(.toggle))
|
||||
#expect(parse("yattee://player/next") == .player(.next))
|
||||
#expect(parse("yattee://player/previous") == .player(.previous))
|
||||
#expect(parse("yattee://player/close") == .player(.close))
|
||||
#expect(parse("yattee://player/seek?t=90") == .player(.seek(to: 90)))
|
||||
#expect(parse("yattee://player/seek?t=1m") == .player(.seek(to: 60)))
|
||||
#expect(parse("yattee://player/seek?by=-10") == .player(.seekBy(-10)))
|
||||
#expect(parse("yattee://player/rate?value=1.5") == .player(.rate(1.5)))
|
||||
}
|
||||
|
||||
@Test("Invalid player commands are rejected")
|
||||
func badPlayerCommands() {
|
||||
#expect(parse("yattee://player") == nil)
|
||||
#expect(parse("yattee://player/explode") == nil)
|
||||
#expect(parse("yattee://player/seek") == nil)
|
||||
#expect(parse("yattee://player/seek?by=nan") == nil)
|
||||
#expect(parse("yattee://player/rate?value=0") == nil)
|
||||
#expect(parse("yattee://player/rate?value=inf") == nil)
|
||||
}
|
||||
|
||||
@Test("Screen links are not actions, and the router leaves action hosts alone")
|
||||
func separationFromRouter() {
|
||||
#expect(parse("yattee://playlist/PL123") == nil)
|
||||
#expect(parse("yattee://video/abc") == nil)
|
||||
#expect(URLRouter().route(URL(string: "yattee://play?url=https://youtu.be/abc")!) == nil)
|
||||
#expect(URLRouter().route(URL(string: "yattee://player/pause")!) == nil)
|
||||
}
|
||||
|
||||
@Test("Only play dismisses sheets")
|
||||
func actionTraits() {
|
||||
let url = URL(string: "https://youtu.be/abc")!
|
||||
#expect(LinkAction.play(url, startTime: nil).dismissesPresentations)
|
||||
#expect(!LinkAction.player(.pause).dismissesPresentations)
|
||||
#expect(!LinkAction.bookmark(url).dismissesPresentations)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user