mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Add App Intents for Shortcuts and Siri
Play Video, Add Video to Queue, Download Video, Bookmark Video, Subscribe to / Unsubscribe from Channel, Control Player, Seek, Set Playback Speed, Get Now Playing, Open Section and Search Videos. Each runs the same LinkAction as the matching yattee:// link through LinkActionPerformer, without the confirmation sheet (the person built the shortcut). Failures surface their message in Shortcuts. App Shortcuts: control player, now playing, open section, search.
This commit is contained in:
457
Yattee/AppIntents/YatteeIntents.swift
Normal file
457
Yattee/AppIntents/YatteeIntents.swift
Normal file
@@ -0,0 +1,457 @@
|
||||
//
|
||||
// YatteeIntents.swift
|
||||
// Yattee
|
||||
//
|
||||
// Shortcuts and Siri actions. Each runs the same `LinkAction` as the matching
|
||||
// yattee:// link, without the confirmation sheet: the person built the
|
||||
// shortcut, so no web page can trigger it behind their back.
|
||||
//
|
||||
// Intent type names, AppEnum raw values and App Shortcut phrases are stored
|
||||
// in people's shortcuts: add new ones, never rename or remove shipped ones.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - Running Actions
|
||||
|
||||
/// A failure Shortcuts and Siri show with its own message.
|
||||
enum YatteeIntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case failed(String)
|
||||
case unsupportedLink
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .failed(let message):
|
||||
return "\(message)"
|
||||
case .unsupportedLink:
|
||||
return "Yattee can only open web links."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LinkAction {
|
||||
/// Runs the action and returns its values, or throws its failure.
|
||||
@MainActor
|
||||
fileprivate func performFromIntent() async throws -> [String: String] {
|
||||
let result = await LinkActionPerformer(appEnvironment: .shared, asksBeforeChanges: false).perform(self)
|
||||
switch result {
|
||||
case .success(let values):
|
||||
return values
|
||||
case .failure(_, let message):
|
||||
throw YatteeIntentError.failed(message)
|
||||
case .cancelled:
|
||||
throw YatteeIntentError.failed(String(localized: "linkAction.error.title", defaultValue: "Couldn't complete the link"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkedLink(_ url: URL) throws -> URL {
|
||||
guard LinkActionParser.isAllowedTarget(url) else { throw YatteeIntentError.unsupportedLink }
|
||||
return url
|
||||
}
|
||||
|
||||
// MARK: - Video Actions
|
||||
|
||||
struct PlayVideoIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Play Video"
|
||||
static let description = IntentDescription("Plays a YouTube, PeerTube or direct media link in Yattee.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Link")
|
||||
var link: URL
|
||||
|
||||
@Parameter(title: "Start Time", description: "Seconds from the start. Leave empty to use the link's own time.")
|
||||
var startTime: Double?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Play \(\.$link)") {
|
||||
\.$startTime
|
||||
}
|
||||
}
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> {
|
||||
let link = try checkedLink(link)
|
||||
let start = startTime ?? URLRouter().parseTimestamp(link)
|
||||
let values = try await LinkAction.play(link, startTime: start).performFromIntent()
|
||||
return .result(value: values["title"] ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
enum QueuePosition: String, AppEnum {
|
||||
case next
|
||||
case end
|
||||
|
||||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Queue Position"
|
||||
static let caseDisplayRepresentations: [QueuePosition: DisplayRepresentation] = [
|
||||
.next: "Play Next",
|
||||
.end: "End of Queue",
|
||||
]
|
||||
}
|
||||
|
||||
struct AddToQueueIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Add Video to Queue"
|
||||
static let description = IntentDescription("Adds a video to Yattee's queue, to play next or at the end.")
|
||||
|
||||
@Parameter(title: "Link")
|
||||
var link: URL
|
||||
|
||||
@Parameter(title: "Position", default: .end)
|
||||
var position: QueuePosition
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Add \(\.$link) to queue (\(\.$position))")
|
||||
}
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> {
|
||||
let link = try checkedLink(link)
|
||||
let action: LinkAction = position == .next ? .playNext(link) : .addToQueue(link)
|
||||
let values = try await action.performFromIntent()
|
||||
return .result(value: values["title"] ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
struct DownloadVideoIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Download Video"
|
||||
static let description = IntentDescription("Downloads a video in Yattee at your preferred download quality.")
|
||||
// The quality picker may need to show
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Link")
|
||||
var link: URL
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Download \(\.$link)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> {
|
||||
let values = try await LinkAction.download(try checkedLink(link)).performFromIntent()
|
||||
return .result(value: values["title"] ?? "")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct BookmarkVideoIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Bookmark Video"
|
||||
static let description = IntentDescription("Adds a video to your Yattee bookmarks.")
|
||||
|
||||
@Parameter(title: "Link")
|
||||
var link: URL
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Bookmark \(\.$link)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> {
|
||||
let values = try await LinkAction.bookmark(try checkedLink(link)).performFromIntent()
|
||||
return .result(value: values["title"] ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Channel Actions
|
||||
|
||||
struct SubscribeToChannelIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Subscribe to Channel"
|
||||
static let description = IntentDescription("Subscribes to a channel from its link, in the account you're using in Yattee.")
|
||||
|
||||
@Parameter(title: "Channel Link")
|
||||
var link: URL
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Subscribe to \(\.$link)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> {
|
||||
let values = try await LinkAction.subscribe(try checkedLink(link)).performFromIntent()
|
||||
return .result(value: values["channel"] ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
struct UnsubscribeFromChannelIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Unsubscribe from Channel"
|
||||
static let description = IntentDescription("Unsubscribes from a channel, in the account you're using in Yattee.")
|
||||
|
||||
@Parameter(title: "Channel Link")
|
||||
var link: URL
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Unsubscribe from \(\.$link)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> {
|
||||
let values = try await LinkAction.unsubscribe(try checkedLink(link)).performFromIntent()
|
||||
return .result(value: values["channel"] ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player
|
||||
|
||||
enum PlayerControlCommand: String, AppEnum {
|
||||
case play
|
||||
case pause
|
||||
case toggle
|
||||
case next
|
||||
case previous
|
||||
case close
|
||||
|
||||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Player Command"
|
||||
static let caseDisplayRepresentations: [PlayerControlCommand: DisplayRepresentation] = [
|
||||
.play: "Resume",
|
||||
.pause: "Pause",
|
||||
.toggle: "Play/Pause",
|
||||
.next: "Next Video",
|
||||
.previous: "Previous Video",
|
||||
.close: "Close Player",
|
||||
]
|
||||
|
||||
var command: LinkAction.PlayerCommand {
|
||||
switch self {
|
||||
case .play: .play
|
||||
case .pause: .pause
|
||||
case .toggle: .toggle
|
||||
case .next: .next
|
||||
case .previous: .previous
|
||||
case .close: .close
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlPlayerIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Control Player"
|
||||
static let description = IntentDescription("Resumes, pauses, skips or closes what's playing in Yattee.")
|
||||
|
||||
@Parameter(title: "Command", default: .toggle)
|
||||
var command: PlayerControlCommand
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("\(\.$command) in Yattee")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
_ = try await LinkAction.player(command.command).performFromIntent()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
enum SeekMode: String, AppEnum {
|
||||
case to
|
||||
case forward
|
||||
case backward
|
||||
|
||||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Seek Mode"
|
||||
static let caseDisplayRepresentations: [SeekMode: DisplayRepresentation] = [
|
||||
.to: "To",
|
||||
.forward: "Forward By",
|
||||
.backward: "Back By",
|
||||
]
|
||||
}
|
||||
|
||||
struct SeekIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Seek"
|
||||
static let description = IntentDescription("Jumps to a time in what's playing in Yattee, or forward or back by some seconds.")
|
||||
|
||||
@Parameter(title: "Mode", default: .forward)
|
||||
var mode: SeekMode
|
||||
|
||||
@Parameter(title: "Seconds", default: 10)
|
||||
var seconds: Double
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Seek \(\.$mode) \(\.$seconds) seconds")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let command: LinkAction.PlayerCommand = switch mode {
|
||||
case .to: .seek(to: max(seconds, 0))
|
||||
case .forward: .seekBy(abs(seconds))
|
||||
case .backward: .seekBy(-abs(seconds))
|
||||
}
|
||||
_ = try await LinkAction.player(command).performFromIntent()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
enum PlaybackSpeed: String, AppEnum {
|
||||
case x025 = "0.25"
|
||||
case x05 = "0.5"
|
||||
case x075 = "0.75"
|
||||
case x1 = "1"
|
||||
case x125 = "1.25"
|
||||
case x15 = "1.5"
|
||||
case x175 = "1.75"
|
||||
case x2 = "2"
|
||||
case x25 = "2.5"
|
||||
case x3 = "3"
|
||||
|
||||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Playback Speed"
|
||||
static let caseDisplayRepresentations: [PlaybackSpeed: DisplayRepresentation] = [
|
||||
.x025: "0.25×",
|
||||
.x05: "0.5×",
|
||||
.x075: "0.75×",
|
||||
.x1: "Normal",
|
||||
.x125: "1.25×",
|
||||
.x15: "1.5×",
|
||||
.x175: "1.75×",
|
||||
.x2: "2×",
|
||||
.x25: "2.5×",
|
||||
.x3: "3×",
|
||||
]
|
||||
}
|
||||
|
||||
struct SetPlaybackSpeedIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Set Playback Speed"
|
||||
static let description = IntentDescription("Changes the speed of what's playing in Yattee.")
|
||||
|
||||
@Parameter(title: "Speed", default: .x1)
|
||||
var speed: PlaybackSpeed
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Set playback speed to \(\.$speed)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
_ = try await LinkAction.player(.rate(Double(speed.rawValue) ?? 1)).performFromIntent()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct GetNowPlayingIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Get Now Playing"
|
||||
static let description = IntentDescription("Returns the title of what's playing in Yattee, or nothing when the player is closed.")
|
||||
|
||||
func perform() async throws -> some ReturnsValue<String> & ProvidesDialog {
|
||||
let values = try await LinkAction.player(.state).performFromIntent()
|
||||
guard let title = values["title"] else {
|
||||
return .result(value: "", dialog: "Nothing is playing in Yattee.")
|
||||
}
|
||||
return .result(value: title, dialog: "\(title)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
enum YatteeSection: String, AppEnum {
|
||||
case home
|
||||
case subscriptions
|
||||
case bookmarks
|
||||
case history
|
||||
case playlists
|
||||
case continueWatching
|
||||
case downloads
|
||||
case settings
|
||||
|
||||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Section"
|
||||
static let caseDisplayRepresentations: [YatteeSection: DisplayRepresentation] = [
|
||||
.home: "Home",
|
||||
.subscriptions: "Subscriptions",
|
||||
.bookmarks: "Bookmarks",
|
||||
.history: "History",
|
||||
.playlists: "Playlists",
|
||||
.continueWatching: "Continue Watching",
|
||||
.downloads: "Downloads",
|
||||
.settings: "Settings",
|
||||
]
|
||||
|
||||
var destination: NavigationDestination? {
|
||||
switch self {
|
||||
case .home: nil
|
||||
case .subscriptions: .subscriptionsFeed
|
||||
case .bookmarks: .bookmarks
|
||||
case .history: .history
|
||||
case .playlists: .playlists
|
||||
case .continueWatching: .continueWatching
|
||||
case .downloads: .downloads
|
||||
case .settings: .settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenSectionIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Open Section"
|
||||
static let description = IntentDescription("Opens a section of Yattee.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Section", default: .subscriptions)
|
||||
var section: YatteeSection
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Open \(\.$section)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
let coordinator = AppEnvironment.shared.navigationCoordinator
|
||||
coordinator.dismissPresentations()
|
||||
if let destination = section.destination {
|
||||
coordinator.navigate(to: destination)
|
||||
} else {
|
||||
coordinator.router.select(.home)
|
||||
coordinator.router.popToRoot(.home)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchVideosIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Search Videos"
|
||||
static let description = IntentDescription("Searches for videos in Yattee.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Search For")
|
||||
var query: String
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Search Yattee for \(\.$query)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
let coordinator = AppEnvironment.shared.navigationCoordinator
|
||||
coordinator.dismissPresentations()
|
||||
coordinator.navigate(to: .search(query))
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Siri
|
||||
|
||||
struct YatteeShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: ControlPlayerIntent(),
|
||||
phrases: [
|
||||
"\(\.$command) in \(.applicationName)",
|
||||
"\(\.$command) \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Control Player",
|
||||
systemImageName: "playpause"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: GetNowPlayingIntent(),
|
||||
phrases: [
|
||||
"What's playing in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Now Playing",
|
||||
systemImageName: "play.rectangle"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: OpenSectionIntent(),
|
||||
phrases: [
|
||||
"Open \(\.$section) in \(.applicationName)",
|
||||
"Show my \(\.$section) in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Open Section",
|
||||
systemImageName: "square.grid.2x2"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: SearchVideosIntent(),
|
||||
phrases: [
|
||||
"Search \(.applicationName)",
|
||||
"Search for videos in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Search Videos",
|
||||
systemImageName: "magnifyingglass"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -151,14 +151,16 @@ struct LinkActionParser {
|
||||
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
|
||||
}
|
||||
guard let url = URL(string: string), isAllowedTarget(url) else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
/// Links actions may act on: web links and yattee:// screen links.
|
||||
static func isAllowedTarget(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased() else { return false }
|
||||
return ["http", "https", "yattee"].contains(scheme)
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
|
||||
@@ -11,6 +11,10 @@ import Foundation
|
||||
@MainActor
|
||||
struct LinkActionPerformer {
|
||||
let appEnvironment: AppEnvironment
|
||||
/// Whether state-changing actions show the confirmation sheet (subject to
|
||||
/// the user's setting). Off for App Intents: the person built the shortcut
|
||||
/// themselves, so no web page can run it behind their back.
|
||||
var asksBeforeChanges = true
|
||||
|
||||
func perform(_ action: LinkAction) async -> LinkActionResult {
|
||||
LoggingService.shared.info("Link action: \(action)", category: .general)
|
||||
@@ -306,7 +310,7 @@ struct LinkActionPerformer {
|
||||
|
||||
/// 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 }
|
||||
guard asksBeforeChanges, !appEnvironment.settingsManager.linkActionsRunWithoutConfirmation else { return true }
|
||||
let confirmation = LinkActionConfirmation(
|
||||
title: title,
|
||||
subject: subject,
|
||||
|
||||
Reference in New Issue
Block a user