mirror of
https://github.com/yattee/yattee.git
synced 2024-12-23 05:53:41 +00:00
Improve URL handling
This commit is contained in:
parent
ba1115fe2a
commit
786418f82e
@ -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
|
||||||
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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
|
||||||
|
@ -58,4 +58,8 @@ enum VideosApp: String, CaseIterable {
|
|||||||
var searchUsesIndexedPages: Bool {
|
var searchUsesIndexedPages: Bool {
|
||||||
self == .invidious
|
self == .invidious
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportsOpeningChannelsByName: Bool {
|
||||||
|
self == .piped
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
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)
|
#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() {
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
||||||
|
@ -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
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,
|
video.channel,
|
||||||
player: player,
|
player: player,
|
||||||
recents: recents,
|
recents: recents,
|
||||||
navigation: navigation,
|
navigation: navigation
|
||||||
navigationStyle: navigationStyle
|
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
if badge {
|
if badge {
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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")
|
||||||
|
@ -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", "*"]))
|
||||||
|
@ -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() {
|
func focus() {
|
||||||
window?.makeKeyAndOrderFront(self)
|
window?.makeKeyAndOrderFront(self)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user