mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Improve URL handling
This commit is contained in:
@@ -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", "*"]))
|
||||
|
Reference in New Issue
Block a user