Improve URL handling

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

View File

@ -1,12 +1,12 @@
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
disabled_rules: disabled_rules:
- conditional_returns_on_newline
- identifier_name - identifier_name
- opening_brace - opening_brace
- number_separator - number_separator
- multiline_arguments - multiline_arguments
opt_in_rules: opt_in_rules:
- conditional_returns_on_newline
- implicit_return - implicit_return
excluded: excluded:
- Vendor - Vendor
@ -14,9 +14,6 @@ excluded:
- Tests iOS - Tests iOS
- Tests macOS - Tests macOS
conditional_returns_on_newline:
if_only: true
implicit_return: implicit_return:
included: included:
- function - function

View File

@ -211,6 +211,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
} }
func channelByName(_: String) -> Resource? {
nil
}
func channelVideos(_ id: String) -> Resource { func channelVideos(_ id: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest")) resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
} }

View File

@ -40,6 +40,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
self.extractChannel(from: content.json) self.extractChannel(from: content.json)
} }
configureTransformer(pathPattern("c/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
self.extractChannelPlaylist(from: content.json) self.extractChannelPlaylist(from: content.json)
} }
@ -125,6 +129,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: "channel/\(id)") resource(baseURL: account.url, path: "channel/\(id)")
} }
func channelByName(_ name: String) -> Resource? {
resource(baseURL: account.url, path: "c/\(name)")
}
func channelVideos(_ id: String) -> Resource { func channelVideos(_ id: String) -> Resource {
channel(id) channel(id)
} }
@ -362,14 +370,12 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? { func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue let details = json.dictionaryValue
let id = details["url"]?.string?.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]() var videos = [Video]()
if let relatedStreams = details["relatedStreams"] { if let relatedStreams = details["relatedStreams"] {
videos = extractVideos(from: relatedStreams) videos = extractVideos(from: relatedStreams)
} }
return ChannelPlaylist( return ChannelPlaylist(
id: id,
title: details["name"]?.string ?? "", title: details["name"]?.string ?? "",
thumbnailURL: thumbnailURL, thumbnailURL: thumbnailURL,
channel: extractChannel(from: json), channel: extractChannel(from: json),

View File

@ -7,6 +7,7 @@ protocol VideosAPI {
var signedIn: Bool { get } var signedIn: Bool { get }
func channel(_ id: String) -> Resource func channel(_ id: String) -> Resource
func channelByName(_ name: String) -> Resource?
func channelVideos(_ id: String) -> Resource func channelVideos(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery, page: String?) -> Resource func search(_ query: SearchQuery, page: String?) -> Resource

View File

@ -58,4 +58,8 @@ enum VideosApp: String, CaseIterable {
var searchUsesIndexedPages: Bool { var searchUsesIndexedPages: Bool {
self == .invidious self == .invidious
} }
var supportsOpeningChannelsByName: Bool {
self == .piped
}
} }

View File

@ -66,23 +66,23 @@ final class NavigationModel: ObservableObject {
@Published var presentingSettings = false @Published var presentingSettings = false
@Published var presentingWelcomeScreen = false @Published var presentingWelcomeScreen = false
@Published var alert = Alert(title: Text("Error"))
@Published var presentingAlert = false @Published var presentingAlert = false
@Published var alertTitle = "" #if os(macOS)
@Published var alertMessage = "" @Published var presentingAlertInVideoPlayer = false
#endif
static func openChannel( static func openChannel(
_ channel: Channel, _ channel: Channel,
player: PlayerModel, player: PlayerModel,
recents: RecentsModel, recents: RecentsModel,
navigation: NavigationModel, navigation: NavigationModel
navigationStyle: NavigationStyle,
delay: Bool = true
) { ) {
guard channel.id != Video.fixtureChannelID else { guard channel.id != Video.fixtureChannelID else {
return return
} }
player.presentingPlayer = false player.hide()
navigation.presentingChannel = false navigation.presentingChannel = false
let recent = RecentItem(from: channel) let recent = RecentItem(from: channel)
@ -92,23 +92,11 @@ final class NavigationModel: ObservableObject {
player.hide() player.hide()
#endif #endif
let openRecent = { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
recents.add(recent) 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.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag) navigation.tabSelection = .recentlyOpened(recent.tag)
navigation.presentingChannel = true
} }
} }
@ -116,10 +104,9 @@ final class NavigationModel: ObservableObject {
_ playlist: ChannelPlaylist, _ playlist: ChannelPlaylist,
player: PlayerModel, player: PlayerModel,
recents: RecentsModel, recents: RecentsModel,
navigation: NavigationModel, navigation: NavigationModel
navigationStyle: NavigationStyle,
delay: Bool = false
) { ) {
navigation.presentingChannel = false
navigation.presentingPlaylist = false navigation.presentingPlaylist = false
let recent = RecentItem(from: playlist) let recent = RecentItem(from: playlist)
@ -129,26 +116,43 @@ final class NavigationModel: ObservableObject {
player.hide() player.hide()
#endif #endif
let openRecent = { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
recents.add(recent) 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.sidebarSectionChanged.toggle()
navigation.tabSelection = .recentlyOpened(recent.tag) 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<TabSelection> { var tabSelectionBinding: Binding<TabSelection> {
Binding<TabSelection>( Binding<TabSelection>(
get: { get: {
@ -187,8 +191,7 @@ final class NavigationModel: ObservableObject {
} }
func presentAlert(title: String, message: String) { func presentAlert(title: String, message: String) {
alertTitle = title alert = Alert(title: Text(title), message: Text(message))
alertMessage = message
presentingAlert = true presentingAlert = true
} }
} }

View File

@ -16,7 +16,7 @@ final class ThumbnailsModel: ObservableObject {
} }
func best(_ video: Video) -> URL? { func best(_ video: Video) -> URL? {
let qualities = [Thumbnail.Quality.default] let qualities = [Thumbnail.Quality.maxresdefault, .medium, .default]
for quality in qualities { for quality in qualities {
let url = video.thumbnailURL(quality: quality) let url = video.thumbnailURL(quality: quality)

View File

@ -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
)
}
}
}

View File

@ -76,7 +76,15 @@ struct ContentView: View {
} }
) )
#if !os(tvOS) #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( .background(
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) { EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist) AddToPlaylistView(video: navigation.videoToAddToPlaylist)
@ -110,9 +118,7 @@ struct ContentView: View {
secondaryButton: .cancel() secondaryButton: .cancel()
) )
} }
.alert(isPresented: $navigation.presentingAlert) { .alert(isPresented: $navigation.presentingAlert) { navigation.alert }
Alert(title: Text(navigation.alertTitle), message: Text(navigation.alertMessage))
}
} }
func openWelcomeScreenIfAccountEmpty() { func openWelcomeScreenIfAccountEmpty() {

View File

@ -1,8 +1,15 @@
import CoreMedia
import Foundation import Foundation
import Siesta
struct OpenURLHandler { struct OpenURLHandler {
static let yatteeProtocol = "yattee://"
var accounts: AccountsModel var accounts: AccountsModel
var navigation: NavigationModel
var recents: RecentsModel
var player: PlayerModel var player: PlayerModel
var search: SearchModel
func handle(_ url: URL) { func handle(_ url: URL) {
if accounts.current.isNil { if accounts.current.isNil {
@ -19,11 +26,69 @@ struct OpenURLHandler {
} }
#endif #endif
let parser = VideoURLParser(url: url) let parser = URLParser(url: urlByRemovingYatteeProtocol(url))
guard let id = parser.id, switch parser.destination {
id != player.currentVideo?.id case .video:
else { 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 return
} }
@ -31,11 +96,145 @@ struct OpenURLHandler {
Windows.main.open() Windows.main.open()
#endif #endif
accounts.api.video(id).load().onSuccess { response in accounts.api.video(id)
.load()
.onSuccess { response in
if let video: Video = response.typedContent() { if let video: Video = response.typedContent() {
self.player.playNow(video, at: parser.time) let time = parser.time.isNil ? nil : CMTime.secondsInDefaultTimescale(TimeInterval(parser.time!))
self.player.playNow(video, at: time)
self.player.show() 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, comment.channel,
player: player, player: player,
recents: recents, recents: recents,
navigation: navigation, navigation: navigation
navigationStyle: navigationStyle
) )
} }
} }

View File

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

View File

@ -48,7 +48,10 @@ struct VideoPlayerView: View {
#endif #endif
@EnvironmentObject<AccountsModel> private var accounts @EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player @EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<ThumbnailsModel> private var thumbnails @EnvironmentObject<ThumbnailsModel> private var thumbnails
init() { init() {
@ -67,7 +70,16 @@ struct VideoPlayerView: View {
return HSplitView { return HSplitView {
content 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) .frame(minWidth: 950, minHeight: 700)
#else #else
return GeometryReader { geometry in return GeometryReader { geometry in

View File

@ -322,9 +322,7 @@ struct SearchView: View {
channel, channel,
player: player, player: player,
recents: recents, recents: recents,
navigation: navigation, navigation: navigation
navigationStyle: navigationStyle,
delay: false
) )
case .playlist: case .playlist:
guard let playlist = item.playlist else { guard let playlist = item.playlist else {
@ -335,9 +333,7 @@ struct SearchView: View {
playlist, playlist,
player: player, player: player,
recents: recents, recents: recents,
navigation: navigation, navigation: navigation
navigationStyle: navigationStyle,
delay: false
) )
} }
} label: { } 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, video.channel,
player: player, player: player,
recents: recents, recents: recents,
navigation: navigation, navigation: navigation
navigationStyle: navigationStyle
) )
} label: { } label: {
if badge { if badge {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
)
}
}
}

View File

@ -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
)
}
}
}

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3712643A2865FF4500D77974"
BuildableName = "Shared Tests.xctest"
BlueprintName = "Shared Tests"
ReferencedContainer = "container:Yattee.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -17,6 +17,10 @@ enum Windows: String, CaseIterable {
} }
} }
var isOpen: Bool {
!window.isNil
}
func focus() { func focus() {
window?.makeKeyAndOrderFront(self) window?.makeKeyAndOrderFront(self)
} }