mirror of
https://github.com/yattee/yattee.git
synced 2024-12-12 21:30:32 +05:30
Improve URL handling
This commit is contained in:
parent
321c265a11
commit
f3f8466a95
@ -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
|
||||
|
@ -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"))
|
||||
}
|
||||
|
@ -40,6 +40,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
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
|
||||
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),
|
||||
|
@ -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
|
||||
|
@ -58,4 +58,8 @@ enum VideosApp: String, CaseIterable {
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningChannelsByName: Bool {
|
||||
self == .piped
|
||||
}
|
||||
}
|
||||
|
@ -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<TabSelection> {
|
||||
Binding<TabSelection>(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
102
Shared Tests/URLParserTests.swift
Normal file
102
Shared Tests/URLParserTests.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -255,8 +255,7 @@ struct CommentView: View {
|
||||
comment.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
navigation: navigation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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
155
Shared/URLParser.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -307,8 +307,7 @@ struct VideoCell: View {
|
||||
video.channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
navigation: navigation
|
||||
)
|
||||
} label: {
|
||||
if badge {
|
||||
|
@ -15,6 +15,7 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
toolbar()
|
||||
}
|
||||
|
||||
init(
|
||||
|
@ -17,8 +17,7 @@ struct ChannelCell: View {
|
||||
channel,
|
||||
player: player,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
navigation: navigation
|
||||
)
|
||||
} label: {
|
||||
content
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
@ -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", "*"]))
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -17,6 +17,10 @@ enum Windows: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
var isOpen: Bool {
|
||||
!window.isNil
|
||||
}
|
||||
|
||||
func focus() {
|
||||
window?.makeKeyAndOrderFront(self)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user