Improve URL handling

This commit is contained in:
Arkadiusz Fal
2022-06-25 00:48:57 +02:00
parent 321c265a11
commit f3f8466a95
26 changed files with 616 additions and 216 deletions

View File

@@ -76,7 +76,15 @@ struct ContentView: View {
}
)
#if !os(tvOS)
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
.onOpenURL {
OpenURLHandler(
accounts: accounts,
navigation: navigation,
recents: recents,
player: player,
search: search
).handle($0)
}
.background(
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
@@ -110,9 +118,7 @@ struct ContentView: View {
secondaryButton: .cancel()
)
}
.alert(isPresented: $navigation.presentingAlert) {
Alert(title: Text(navigation.alertTitle), message: Text(navigation.alertMessage))
}
.alert(isPresented: $navigation.presentingAlert) { navigation.alert }
}
func openWelcomeScreenIfAccountEmpty() {

View File

@@ -1,8 +1,15 @@
import CoreMedia
import Foundation
import Siesta
struct OpenURLHandler {
static let yatteeProtocol = "yattee://"
var accounts: AccountsModel
var navigation: NavigationModel
var recents: RecentsModel
var player: PlayerModel
var search: SearchModel
func handle(_ url: URL) {
if accounts.current.isNil {
@@ -19,11 +26,69 @@ struct OpenURLHandler {
}
#endif
let parser = VideoURLParser(url: url)
let parser = URLParser(url: urlByRemovingYatteeProtocol(url))
guard let id = parser.id,
id != player.currentVideo?.id
else {
switch parser.destination {
case .video:
handleVideoUrlOpen(parser)
case .playlist:
handlePlaylistUrlOpen(parser)
case .channel:
handleChannelUrlOpen(parser)
case .search:
handleSearchUrlOpen(parser)
case .favorites:
hideViewsAboveBrowser()
navigation.tabSelection = .favorites
#if os(macOS)
focusMainWindow()
#endif
case .subscriptions:
guard accounts.app.supportsSubscriptions, accounts.signedIn else { return }
hideViewsAboveBrowser()
navigation.tabSelection = .subscriptions
#if os(macOS)
focusMainWindow()
#endif
case .popular:
guard accounts.app.supportsPopular else { return }
hideViewsAboveBrowser()
navigation.tabSelection = .popular
#if os(macOS)
focusMainWindow()
#endif
case .trending:
hideViewsAboveBrowser()
navigation.tabSelection = .trending
#if os(macOS)
focusMainWindow()
#endif
default:
navigation.presentAlert(title: "Error", message: "This URL could not be opened")
}
}
private func hideViewsAboveBrowser() {
player.hide()
navigation.presentingChannel = false
navigation.presentingPlaylist = false
}
private func urlByRemovingYatteeProtocol(_ url: URL) -> URL! {
var urlAbsoluteString = url.absoluteString
guard urlAbsoluteString.hasPrefix(Self.yatteeProtocol) else {
return url
}
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Self.yatteeProtocol.count))
return URL(string: urlAbsoluteString)
}
private func handleVideoUrlOpen(_ parser: URLParser) {
guard let id = parser.videoID, id != player.currentVideo?.id else {
navigation.presentAlert(title: "Could not open video", message: "Could not extract video ID")
return
}
@@ -31,11 +96,145 @@ struct OpenURLHandler {
Windows.main.open()
#endif
accounts.api.video(id).load().onSuccess { response in
if let video: Video = response.typedContent() {
self.player.playNow(video, at: parser.time)
self.player.show()
accounts.api.video(id)
.load()
.onSuccess { response in
if let video: Video = response.typedContent() {
let time = parser.time.isNil ? nil : CMTime.secondsInDefaultTimescale(TimeInterval(parser.time!))
self.player.playNow(video, at: time)
self.player.show()
} else {
navigation.presentAlert(title: "Error", message: "This video could not be opened")
}
}
.onFailure { responseError in
navigation.presentAlert(title: "Could not open video", message: responseError.userMessage)
}
}
private func handlePlaylistUrlOpen(_ parser: URLParser) {
#if os(macOS)
if alertIfNoMainWindowOpen() { return }
#endif
guard let playlistID = parser.playlistID else {
navigation.presentAlert(title: "Could not open playlist", message: "Could not extract playlist ID")
return
}
accounts.api.channelPlaylist(playlistID)?
.load()
.onSuccess { response in
if var playlist: ChannelPlaylist = response.typedContent() {
playlist.id = playlistID
DispatchQueue.main.async {
NavigationModel.openChannelPlaylist(
playlist,
player: player,
recents: recents,
navigation: navigation
)
}
} else {
navigation.presentAlert(title: "Could not open playlist", message: "Playlist could not be found")
}
}
.onFailure { responseError in
navigation.presentAlert(title: "Could not open playlist", message: responseError.userMessage)
}
}
private func handleChannelUrlOpen(_ parser: URLParser) {
#if os(macOS)
if alertIfNoMainWindowOpen() { return }
#endif
guard let resource = resourceForChannelUrl(parser) else {
navigation.presentAlert(title: "Could not open channel", message: "Could not extract channel information")
return
}
resource
.load()
.onSuccess { response in
if let channel: Channel = response.typedContent() {
DispatchQueue.main.async {
NavigationModel.openChannel(
channel,
player: player,
recents: recents,
navigation: navigation
)
}
} else {
navigation.presentAlert(title: "Could not open channel", message: "Channel could not be found")
}
}
.onFailure { responseError in
navigation.presentAlert(title: "Could not open channel", message: responseError.userMessage)
}
}
private func resourceForChannelUrl(_ parser: URLParser) -> Resource? {
if let id = parser.channelID {
return accounts.api.channel(id)
}
guard let name = parser.channelName else {
return nil
}
if accounts.app.supportsOpeningChannelsByName {
return accounts.api.channelByName(name)
}
if let instance = InstancesModel.all.first(where: { $0.app.supportsOpeningChannelsByName }) {
return instance.anonymous.channelByName(name)
}
return nil
}
private func handleSearchUrlOpen(_ parser: URLParser) {
#if os(macOS)
if alertIfNoMainWindowOpen() { return }
#endif
NavigationModel.openSearchQuery(
parser.searchQuery,
player: player,
recents: recents,
navigation: navigation,
search: search
)
#if os(macOS)
focusMainWindow()
#endif
}
#if os(macOS)
private func focusMainWindow() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
Windows.main.focus()
}
}
}
private func alertIfNoMainWindowOpen() -> Bool {
guard !Windows.main.isOpen else {
return false
}
navigation.presentAlert(
title: "Restart the app to open this link",
message:
"To open this link in the app you need to close and open it manually to have browser window, " +
"then you can try opening links again.\n\nThis is a limitation of SwiftUI on macOS versions earlier than Ventura."
)
navigation.presentingAlertInVideoPlayer = true
return true
}
#endif
}

View File

@@ -255,8 +255,7 @@ struct CommentView: View {
comment.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
navigation: navigation
)
}
}

View File

@@ -238,8 +238,7 @@ struct VideoDetails: View {
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
navigation: navigation
)
}) {
Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")

View File

@@ -48,7 +48,10 @@ struct VideoPlayerView: View {
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<ThumbnailsModel> private var thumbnails
init() {
@@ -67,7 +70,16 @@ struct VideoPlayerView: View {
return HSplitView {
content
}
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
.alert(isPresented: $navigation.presentingAlertInVideoPlayer) { navigation.alert }
.onOpenURL {
OpenURLHandler(
accounts: accounts,
navigation: navigation,
recents: recents,
player: player,
search: search
).handle($0)
}
.frame(minWidth: 950, minHeight: 700)
#else
return GeometryReader { geometry in

View File

@@ -322,9 +322,7 @@ struct SearchView: View {
channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle,
delay: false
navigation: navigation
)
case .playlist:
guard let playlist = item.playlist else {
@@ -335,9 +333,7 @@ struct SearchView: View {
playlist,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle,
delay: false
navigation: navigation
)
}
} label: {

155
Shared/URLParser.swift Normal file
View File

@@ -0,0 +1,155 @@
import CoreMedia
import Foundation
struct URLParser {
static let prefixes: [Destination: [String]] = [
.playlist: ["/playlist", "playlist"],
.channel: ["/c", "c", "/channel", "channel"],
.search: ["/results", "search"]
]
enum Destination {
case video, playlist, channel, search
case favorites, subscriptions, popular, trending
}
var destination: Destination? {
if hasAnyOfPrefixes(path, ["favorites"]) { return .favorites }
if hasAnyOfPrefixes(path, ["subscriptions"]) { return .subscriptions }
if hasAnyOfPrefixes(path, ["popular"]) { return .popular }
if hasAnyOfPrefixes(path, ["trending"]) { return .trending }
if hasAnyOfPrefixes(path, Self.prefixes[.playlist]!) || queryItemValue("v") == "playlist" {
return .playlist
} else if hasAnyOfPrefixes(path, Self.prefixes[.channel]!) {
return .channel
} else if hasAnyOfPrefixes(path, Self.prefixes[.search]!) {
return .search
}
guard let id = videoID, !id.isEmpty else {
return nil
}
return .video
}
var url: URL
var videoID: String? {
if host == "youtu.be", !path.isEmpty {
return String(path.suffix(from: path.index(path.startIndex, offsetBy: 1)))
}
return queryItemValue("v")
}
var time: Int? {
guard let time = queryItemValue("t") else {
return nil
}
let timeComponents = parseTime(time)
guard !timeComponents.isEmpty,
let hours = Int(timeComponents["hours"] ?? "0"),
let minutes = Int(timeComponents["minutes"] ?? "0"),
let seconds = Int(timeComponents["seconds"] ?? "0")
else {
return Int(time)
}
return Int(seconds + (minutes * 60) + (hours * 60 * 60))
}
var playlistID: String? {
guard destination == .playlist else { return nil }
return queryItemValue("list")
}
var searchQuery: String? {
guard destination == .search else { return nil }
return queryItemValue("search_query")?.replacingOccurrences(of: "+", with: " ")
}
var channelName: String? {
guard destination == .channel else { return nil }
return removePrefixes(path, Self.prefixes[.channel]!.map { [$0, "/"].joined() })
}
var channelID: String? {
guard destination == .channel else { return nil }
return removePrefixes(path, Self.prefixes[.channel]!.map { [$0, "/"].joined() })
}
private var host: String {
urlComponents?.host ?? ""
}
private var path: String {
removePrefixes(urlComponents?.path ?? "", ["www.youtube.com", "youtube.com"])
}
private func hasAnyOfPrefixes(_ value: String, _ prefixes: [String]) -> Bool {
return prefixes.contains { value.hasPrefix($0) }
}
private func removePrefixes(_ value: String, _ prefixes: [String]) -> String {
var value = value
prefixes.forEach { prefix in
if value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}
}
return value
}
private var queryItems: [URLQueryItem] {
urlComponents?.queryItems ?? []
}
private func queryItemValue(_ name: String) -> String? {
queryItems.first { $0.name == name }?.value
}
private var urlComponents: URLComponents? {
URLComponents(url: url, resolvingAgainstBaseURL: false)
}
private func parseTime(_ time: String) -> [String: String] {
let results = timeRegularExpression.matches(
in: time,
range: NSRange(time.startIndex..., in: time)
)
guard let match = results.first else {
return [:]
}
var components: [String: String] = [:]
for name in ["hours", "minutes", "seconds"] {
let matchRange = match.range(withName: name)
if let substringRange = Range(matchRange, in: time) {
let capture = String(time[substringRange])
components[name] = capture
}
}
return components
}
private var timeRegularExpression: NSRegularExpression {
try! NSRegularExpression(
pattern: "(?:(?<hours>[0-9+])+h)?(?:(?<minutes>[0-9]+)m)?(?:(?<seconds>[0-9]*)s)?",
options: .caseInsensitive
)
}
}

View File

@@ -1,79 +0,0 @@
import CoreMedia
import Foundation
struct VideoURLParser {
let url: URL
var id: String? {
if urlComponents?.host == "youtu.be", let path = urlComponents?.path {
return String(path.suffix(from: path.index(path.startIndex, offsetBy: 1)))
}
return queryItemValue("v")
}
var time: CMTime? {
guard let time = queryItemValue("t") else {
return nil
}
let timeComponents = parseTime(time)
guard !timeComponents.isEmpty,
let hours = TimeInterval(timeComponents["hours"] ?? "0"),
let minutes = TimeInterval(timeComponents["minutes"] ?? "0"),
let seconds = TimeInterval(timeComponents["seconds"] ?? "0")
else {
if let time = TimeInterval(time) {
return .secondsInDefaultTimescale(time)
}
return nil
}
return .secondsInDefaultTimescale(seconds + (minutes * 60) + (hours * 60 * 60))
}
func queryItemValue(_ name: String) -> String? {
queryItems.first { $0.name == name }?.value
}
private var queryItems: [URLQueryItem] {
urlComponents?.queryItems ?? []
}
private var urlComponents: URLComponents? {
URLComponents(url: url, resolvingAgainstBaseURL: false)
}
private func parseTime(_ time: String) -> [String: String] {
let results = timeRegularExpression.matches(
in: time,
range: NSRange(time.startIndex..., in: time)
)
guard let match = results.first else {
return [:]
}
var components: [String: String] = [:]
for name in ["hours", "minutes", "seconds"] {
let matchRange = match.range(withName: name)
if let substringRange = Range(matchRange, in: time) {
let capture = String(time[substringRange])
components[name] = capture
}
}
return components
}
private var timeRegularExpression: NSRegularExpression {
try! NSRegularExpression(
pattern: "(?:(?<hours>[0-9+])+h)?(?:(?<minutes>[0-9]+)m)?(?:(?<seconds>[0-9]*)s)?",
options: .caseInsensitive
)
}
}

View File

@@ -307,8 +307,7 @@ struct VideoCell: View {
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
navigation: navigation
)
} label: {
if badge {

View File

@@ -15,6 +15,7 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
@ViewBuilder content: @escaping () -> Content
) {
self.content = content()
toolbar()
}
init(

View File

@@ -17,8 +17,7 @@ struct ChannelCell: View {
channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
navigation: navigation
)
} label: {
content

View File

@@ -8,8 +8,6 @@ struct ControlsBar: View {
case details, controls
}
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerControlsModel> private var playerControls
@@ -36,7 +34,7 @@ struct ControlsBar: View {
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: 0.4, color: Color("ControlsBorderColor"))
.modifier(ControlBackgroundModifier(edgesIgnoringSafeArea: .bottom))
}
@@ -153,8 +151,7 @@ struct ControlsBar: View {
video.channel,
player: model,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
navigation: navigation
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")

View File

@@ -191,8 +191,7 @@ struct VideoContextMenuView: View {
video.channel,
player: player,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
navigation: navigation
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")

View File

@@ -132,6 +132,7 @@ struct YatteeApp: App {
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(search)
.environmentObject(subscriptions)
.environmentObject(thumbnails)
.handlesExternalEvents(preferring: Set(["player", "*"]), allowing: Set(["player", "*"]))