diff --git a/Yattee/AppIntents/YatteeIntents.swift b/Yattee/AppIntents/YatteeIntents.swift new file mode 100644 index 00000000..0e30dcf2 --- /dev/null +++ b/Yattee/AppIntents/YatteeIntents.swift @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 & 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" + ) + } +} diff --git a/Yattee/Services/Navigation/LinkAction.swift b/Yattee/Services/Navigation/LinkAction.swift index 66691bb6..9cd8c211 100644 --- a/Yattee/Services/Navigation/LinkAction.swift +++ b/Yattee/Services/Navigation/LinkAction.swift @@ -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] { diff --git a/Yattee/Services/Navigation/LinkActionPerformer.swift b/Yattee/Services/Navigation/LinkActionPerformer.swift index 0f5e1337..c2b9a817 100644 --- a/Yattee/Services/Navigation/LinkActionPerformer.swift +++ b/Yattee/Services/Navigation/LinkActionPerformer.swift @@ -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,