diff --git a/.swiftlint.yml b/.swiftlint.yml index b0dd1c15..274d32a3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,12 +1,12 @@ parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml disabled_rules: + - conditional_returns_on_newline - identifier_name - opening_brace - number_separator - multiline_arguments opt_in_rules: - - conditional_returns_on_newline - implicit_return excluded: - Vendor @@ -14,9 +14,6 @@ excluded: - Tests iOS - Tests macOS -conditional_returns_on_newline: - if_only: true - implicit_return: included: - function diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 78852641..ac4999b2 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -211,6 +211,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) } + func channelByName(_: String) -> Resource? { + nil + } + func channelVideos(_ id: String) -> Resource { resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest")) } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index c214c863..fe93377d 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -40,6 +40,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { self.extractChannel(from: content.json) } + configureTransformer(pathPattern("c/*")) { (content: Entity) -> Channel? in + self.extractChannel(from: content.json) + } + configureTransformer(pathPattern("playlists/*")) { (content: Entity) -> ChannelPlaylist? in self.extractChannelPlaylist(from: content.json) } @@ -125,6 +129,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: "channel/\(id)") } + func channelByName(_ name: String) -> Resource? { + resource(baseURL: account.url, path: "c/\(name)") + } + func channelVideos(_ id: String) -> Resource { channel(id) } @@ -362,14 +370,12 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? { let details = json.dictionaryValue - let id = details["url"]?.string?.components(separatedBy: "?list=").last ?? UUID().uuidString let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url var videos = [Video]() if let relatedStreams = details["relatedStreams"] { videos = extractVideos(from: relatedStreams) } return ChannelPlaylist( - id: id, title: details["name"]?.string ?? "", thumbnailURL: thumbnailURL, channel: extractChannel(from: json), diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index 21ef4403..98e6500f 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -7,6 +7,7 @@ protocol VideosAPI { var signedIn: Bool { get } func channel(_ id: String) -> Resource + func channelByName(_ name: String) -> Resource? func channelVideos(_ id: String) -> Resource func trending(country: Country, category: TrendingCategory?) -> Resource func search(_ query: SearchQuery, page: String?) -> Resource diff --git a/Model/Applications/VideosApp.swift b/Model/Applications/VideosApp.swift index 9692dcc8..c3721768 100644 --- a/Model/Applications/VideosApp.swift +++ b/Model/Applications/VideosApp.swift @@ -58,4 +58,8 @@ enum VideosApp: String, CaseIterable { var searchUsesIndexedPages: Bool { self == .invidious } + + var supportsOpeningChannelsByName: Bool { + self == .piped + } } diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index 67da0a18..72c05c31 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -66,23 +66,23 @@ final class NavigationModel: ObservableObject { @Published var presentingSettings = false @Published var presentingWelcomeScreen = false + @Published var alert = Alert(title: Text("Error")) @Published var presentingAlert = false - @Published var alertTitle = "" - @Published var alertMessage = "" + #if os(macOS) + @Published var presentingAlertInVideoPlayer = false + #endif static func openChannel( _ channel: Channel, player: PlayerModel, recents: RecentsModel, - navigation: NavigationModel, - navigationStyle: NavigationStyle, - delay: Bool = true + navigation: NavigationModel ) { guard channel.id != Video.fixtureChannelID else { return } - player.presentingPlayer = false + player.hide() navigation.presentingChannel = false let recent = RecentItem(from: channel) @@ -92,23 +92,11 @@ final class NavigationModel: ObservableObject { player.hide() #endif - let openRecent = { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { recents.add(recent) - navigation.presentingChannel = true - } - - if navigationStyle == .tab { - if delay { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - openRecent() - } - } else { - openRecent() - } - } else if navigationStyle == .sidebar { - openRecent() navigation.sidebarSectionChanged.toggle() navigation.tabSelection = .recentlyOpened(recent.tag) + navigation.presentingChannel = true } } @@ -116,10 +104,9 @@ final class NavigationModel: ObservableObject { _ playlist: ChannelPlaylist, player: PlayerModel, recents: RecentsModel, - navigation: NavigationModel, - navigationStyle: NavigationStyle, - delay: Bool = false + navigation: NavigationModel ) { + navigation.presentingChannel = false navigation.presentingPlaylist = false let recent = RecentItem(from: playlist) @@ -129,26 +116,43 @@ final class NavigationModel: ObservableObject { player.hide() #endif - let openRecent = { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { recents.add(recent) - navigation.presentingPlaylist = true - } - - if navigationStyle == .tab { - if delay { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - openRecent() - } - } else { - openRecent() - } - } else if navigationStyle == .sidebar { - openRecent() navigation.sidebarSectionChanged.toggle() navigation.tabSelection = .recentlyOpened(recent.tag) + navigation.presentingPlaylist = true } } + static func openSearchQuery( + _ searchQuery: String?, + player: PlayerModel, + recents: RecentsModel, + navigation: NavigationModel, + search: SearchModel + ) { + player.hide() + navigation.presentingChannel = false + navigation.presentingPlaylist = false + navigation.tabSelection = .search + + if let searchQuery = searchQuery { + let recent = RecentItem(from: searchQuery) + recents.add(recent) + + DispatchQueue.main.async { + search.queryText = searchQuery + search.changeQuery { query in query.query = searchQuery } + } + } + + #if os(macOS) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Windows.main.focus() + } + #endif + } + var tabSelectionBinding: Binding { Binding( get: { @@ -187,8 +191,7 @@ final class NavigationModel: ObservableObject { } func presentAlert(title: String, message: String) { - alertTitle = title - alertMessage = message + alert = Alert(title: Text(title), message: Text(message)) presentingAlert = true } } diff --git a/Model/ThumbnailsModel.swift b/Model/ThumbnailsModel.swift index 26532c24..8421db49 100644 --- a/Model/ThumbnailsModel.swift +++ b/Model/ThumbnailsModel.swift @@ -16,7 +16,7 @@ final class ThumbnailsModel: ObservableObject { } func best(_ video: Video) -> URL? { - let qualities = [Thumbnail.Quality.default] + let qualities = [Thumbnail.Quality.maxresdefault, .medium, .default] for quality in qualities { let url = video.thumbnailURL(quality: quality) diff --git a/Shared Tests/URLParserTests.swift b/Shared Tests/URLParserTests.swift new file mode 100644 index 00000000..9d7055ed --- /dev/null +++ b/Shared Tests/URLParserTests.swift @@ -0,0 +1,102 @@ +import XCTest + +final class URLParserTests: XCTestCase { + private static let videos: [String: String] = [ + "https://www.youtube.com/watch?v=_E0PWQvW-14&list=WL&index=4&t=155s": "_E0PWQvW-14", + "https://youtu.be/IRsc57nK8mg?t=20": "IRsc57nK8mg", + "https://www.youtube-nocookie.com/watch?index=4&v=cE1PSQrWc11&list=WL&t=155s": "cE1PSQrWc11", + "https://invidious.snopyta.org/watch?v=XpowfENlJAw": "XpowfENlJAw", + "/watch?v=VQ_f5RymW70": "VQ_f5RymW70", + "watch?v=IUTGFQpKaPU&t=30s": "IUTGFQpKaPU" + ] + + private static let channelsByName: [String: String] = [ + "https://www.youtube.com/c/tennistv": "tennistv", + "youtube.com/c/MKBHD": "MKBHD", + "c/ABCDE": "ABCDE" + ] + + private static let channelsByID: [String: String] = [ + "https://piped.kavin.rocks/channel/UCbcxFkd6B9xUU54InHv4Tig": "UCbcxFkd6B9xUU54InHv4Tig", + "youtube.com/channel/UCbcxFkd6B9xUU54InHv4Tig": "UCbcxFkd6B9xUU54InHv4Tig", + "channel/ABCDE": "ABCDE" + ] + + private static let playlists: [String: String] = [ + "https://www.youtube.com/playlist?list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU", + "https://www.youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU", + "youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU", + "/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU", + "watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU", + "playlist?list=ABCDE": "ABCDE" + ] + + private static let searches: [String: String] = [ + "https://www.youtube.com/results?search_query=my+query+text": "my query text", + "https://piped.kavin.rocks/results?search_query=query+text": "query text", + "https://www.youtube.com/results?search_query=my+query+text&sp=EgIQAg%253D%253D": "my query text", + "https://www.youtube.com/results?search_query=encoded+%22query+text%22+@%23%252": "encoded \"query text\" @#%2", + "https://www.youtube.com/results?search_query=a%2Bb%3Dc": "a b=c", + "www.youtube.com/results?search_query=my+query+text&sp=EgIQAg%253D%253D": "my query text", + "/results?search_query=a+b%3Dcde": "a b=cde", + "search?search_query=a+b%3Dcde": "a b=cde" + ] + + func testVideosParsing() throws { + Self.videos.forEach { url, id in + let parser = URLParser(url: URL(string: url)!) + XCTAssertEqual(parser.destination, .video) + XCTAssertEqual(parser.videoID, id) + } + } + + func testChannelsByNameParsing() throws { + Self.channelsByName.forEach { url, name in + let parser = URLParser(url: URL(string: url)!) + XCTAssertEqual(parser.destination, .channel) + XCTAssertEqual(parser.channelName, name) + } + } + + func testChannelsByIdParsing() throws { + Self.channelsByID.forEach { url, id in + let parser = URLParser(url: URL(string: url)!) + XCTAssertEqual(parser.destination, .channel) + XCTAssertEqual(parser.channelID, id) + } + } + + func testPlaylistsParsing() throws { + Self.playlists.forEach { url, id in + let parser = URLParser(url: URL(string: url)!) + XCTAssertEqual(parser.destination, .playlist) + XCTAssertEqual(parser.playlistID, id) + } + } + + func testSearchesParsing() throws { + Self.searches.forEach { url, query in + let parser = URLParser(url: URL(string: url)!) + XCTAssertEqual(parser.destination, .search) + XCTAssertEqual(parser.searchQuery, query) + } + } + + func testTimeParsing() throws { + let samples: [String: Int?] = [ + "https://www.youtube.com/watch?v=_E0PWQvW-14&list=WL&index=4&t=155s": 155, + "https://youtu.be/IRsc57nK8mg?t=20m10s": 1210, + "https://youtu.be/IRsc57nK8mg?t=3x4z": nil, + "https://www.youtube-nocookie.com/watch?index=4&v=cE1PSQrWc11&list=WL&t=2H3m5s": 7385, + "https://youtu.be/VQ_f5RymW70?t=378": 378, + "watch?v=IUTGFQpKaPU&t=30s": 30 + ] + + samples.forEach { url, time in + XCTAssertEqual( + URLParser(url: URL(string: url)!).time, + time + ) + } + } +} diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 0431fd2d..5954cabc 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -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() { diff --git a/Shared/OpenURLHandler.swift b/Shared/OpenURLHandler.swift index 582addee..0b38cfbb 100644 --- a/Shared/OpenURLHandler.swift +++ b/Shared/OpenURLHandler.swift @@ -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 } diff --git a/Shared/Player/CommentView.swift b/Shared/Player/CommentView.swift index e4efea30..f0e942d6 100644 --- a/Shared/Player/CommentView.swift +++ b/Shared/Player/CommentView.swift @@ -255,8 +255,7 @@ struct CommentView: View { comment.channel, player: player, recents: recents, - navigation: navigation, - navigationStyle: navigationStyle + navigation: navigation ) } } diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index 31cf4c4a..e874c4cb 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -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") diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index bb8d0171..afbd3286 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -48,7 +48,10 @@ struct VideoPlayerView: View { #endif @EnvironmentObject private var accounts + @EnvironmentObject private var navigation @EnvironmentObject private var player + @EnvironmentObject private var recents + @EnvironmentObject private var search @EnvironmentObject 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 diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index 6de8c815..d345b001 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -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: { diff --git a/Shared/URLParser.swift b/Shared/URLParser.swift new file mode 100644 index 00000000..46a172b2 --- /dev/null +++ b/Shared/URLParser.swift @@ -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: "(?:(?[0-9+])+h)?(?:(?[0-9]+)m)?(?:(?[0-9]*)s)?", + options: .caseInsensitive + ) + } +} diff --git a/Shared/VideoURLParser.swift b/Shared/VideoURLParser.swift deleted file mode 100644 index 2036317d..00000000 --- a/Shared/VideoURLParser.swift +++ /dev/null @@ -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: "(?:(?[0-9+])+h)?(?:(?[0-9]+)m)?(?:(?[0-9]*)s)?", - options: .caseInsensitive - ) - } -} diff --git a/Shared/Videos/VideoCell.swift b/Shared/Videos/VideoCell.swift index 49510c28..b40f9b96 100644 --- a/Shared/Videos/VideoCell.swift +++ b/Shared/Videos/VideoCell.swift @@ -307,8 +307,7 @@ struct VideoCell: View { video.channel, player: player, recents: recents, - navigation: navigation, - navigationStyle: navigationStyle + navigation: navigation ) } label: { if badge { diff --git a/Shared/Views/BrowserPlayerControls.swift b/Shared/Views/BrowserPlayerControls.swift index 17025d0b..8ad43157 100644 --- a/Shared/Views/BrowserPlayerControls.swift +++ b/Shared/Views/BrowserPlayerControls.swift @@ -15,6 +15,7 @@ struct BrowserPlayerControls: View { @ViewBuilder content: @escaping () -> Content ) { self.content = content() + toolbar() } init( diff --git a/Shared/Views/ChannelCell.swift b/Shared/Views/ChannelCell.swift index 371a0e91..24665c25 100644 --- a/Shared/Views/ChannelCell.swift +++ b/Shared/Views/ChannelCell.swift @@ -17,8 +17,7 @@ struct ChannelCell: View { channel, player: player, recents: recents, - navigation: navigation, - navigationStyle: navigationStyle + navigation: navigation ) } label: { content diff --git a/Shared/Views/ControlsBar.swift b/Shared/Views/ControlsBar.swift index e635b166..2fb9d956 100644 --- a/Shared/Views/ControlsBar.swift +++ b/Shared/Views/ControlsBar.swift @@ -8,8 +8,6 @@ struct ControlsBar: View { case details, controls } - @Environment(\.navigationStyle) private var navigationStyle - @EnvironmentObject private var accounts @EnvironmentObject private var navigation @EnvironmentObject 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") diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 2c8237be..e7ff4731 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -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") diff --git a/Shared/YatteeApp.swift b/Shared/YatteeApp.swift index db9761f4..0edafb3b 100644 --- a/Shared/YatteeApp.swift +++ b/Shared/YatteeApp.swift @@ -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", "*"])) diff --git a/Tests macOS/InstancesModelTests.swift b/Tests macOS/InstancesModelTests.swift deleted file mode 100644 index 08c28f3f..00000000 --- a/Tests macOS/InstancesModelTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -import XCTest - -final class InstancesModelTests: XCTestCase { - func testStandardizedURL() throws { - let samples: [String: String] = [ - "https://www.youtube.com/": "https://www.youtube.com", - "https://www.youtube.com": "https://www.youtube.com", - ] - - samples.forEach { url, standardized in - XCTAssertEqual( - InstancesModel.standardizedURL(url), - standardized - ) - } - } -} diff --git a/Tests macOS/VideoURLParserTests.swift b/Tests macOS/VideoURLParserTests.swift deleted file mode 100644 index 1aa1cedb..00000000 --- a/Tests macOS/VideoURLParserTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -import XCTest - -final class VideoURLParserTests: XCTestCase { - func testIDParsing() throws { - let samples: [String: String] = [ - "https://www.youtube.com/watch?v=_E0PWQvW-14&list=WL&index=4&t=155s": "_E0PWQvW-14", - "https://youtu.be/IRsc57nK8mg?t=20": "IRsc57nK8mg", - "https://www.youtube-nocookie.com/watch?index=4&v=cE1PSQrWc11&list=WL&t=155s": "cE1PSQrWc11", - "https://invidious.snopyta.org/watch?v=XpowfENlJAw" : "XpowfENlJAw", - "/watch?v=VQ_f5RymW70" : "VQ_f5RymW70", - "watch?v=IUTGFQpKaPU&t=30s": "IUTGFQpKaPU" - ] - - samples.forEach { url, id in - XCTAssertEqual( - VideoURLParser(url: URL(string: url)!).id, - id - ) - } - } - - func testTimeParsing() throws { - let samples: [String: TimeInterval?] = [ - "https://www.youtube.com/watch?v=_E0PWQvW-14&list=WL&index=4&t=155s": 155, - "https://youtu.be/IRsc57nK8mg?t=20m10s": 1210, - "https://youtu.be/IRsc57nK8mg?t=3x4z": nil, - "https://www.youtube-nocookie.com/watch?index=4&v=cE1PSQrWc11&list=WL&t=2H3m5s": 7385, - "https://youtu.be/VQ_f5RymW70?t=378": 378, - "watch?v=IUTGFQpKaPU&t=30s": 30 - ] - - samples.forEach { url, time in - XCTAssertEqual( - VideoURLParser(url: URL(string: url)!).time, - time - ) - } - } -} diff --git a/Yattee.xcodeproj/xcshareddata/xcschemes/Shared Tests.xcscheme b/Yattee.xcodeproj/xcshareddata/xcschemes/Shared Tests.xcscheme new file mode 100644 index 00000000..2af43e64 --- /dev/null +++ b/Yattee.xcodeproj/xcshareddata/xcschemes/Shared Tests.xcscheme @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/macOS/Windows.swift b/macOS/Windows.swift index c6181750..0bd0bae1 100644 --- a/macOS/Windows.swift +++ b/macOS/Windows.swift @@ -17,6 +17,10 @@ enum Windows: String, CaseIterable { } } + var isOpen: Bool { + !window.isNil + } + func focus() { window?.makeKeyAndOrderFront(self) }