Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe33cc5e3a | ||
|
|
7e7b4e89b5 | ||
|
|
d88292662f | ||
|
|
21b04e21c4 | ||
|
|
a44a61b017 | ||
|
|
1b090fcd51 | ||
|
|
12eb4401b5 | ||
|
|
170f2ee94e | ||
|
|
fe56739211 | ||
|
|
759a942426 | ||
|
|
8d9bbf647a | ||
|
|
eeb7b1f151 | ||
|
|
62bff9283c | ||
|
|
3624c9619a | ||
|
|
f7fc2369e3 | ||
|
|
82ea8733ec | ||
|
|
1f495562fc | ||
|
|
37b99c59e1 | ||
|
|
7f9b53bd1f | ||
|
|
941e6a909d | ||
|
|
5143c4f8ce | ||
|
|
19a3f08336 | ||
|
|
eb537676e6 | ||
|
|
e97daa1944 | ||
|
|
bd59b8e2c3 | ||
|
|
19b146c6ad | ||
|
|
dd995105b5 | ||
|
|
c4b5c7ce41 | ||
|
|
cc2bf90218 | ||
|
|
45c917160e | ||
|
|
9f5e9ea237 | ||
|
|
1c61ad37a9 | ||
|
|
06f7391ad9 | ||
|
|
e61d1dfe2e | ||
|
|
ff83abd103 | ||
|
|
500c55689b | ||
|
|
f60f7a0455 | ||
|
|
52ab162a6c | ||
|
|
c6f077dcd3 | ||
|
|
b5ffa5b267 | ||
|
|
5ef89ac9f4 | ||
|
|
696751e07c |
13
Backports/Backport.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct Backport<Content> {
|
||||
public let content: Content
|
||||
|
||||
public init(_ content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
var backport: Backport<Self> { Backport(self) }
|
||||
}
|
||||
16
Backports/Badge+Backport.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func badge(_ count: Text) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
HStack {
|
||||
content
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Backports/Tint+Backport.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func tint(_ color: Color?) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.tint(color)
|
||||
} else {
|
||||
content.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Extensions/Color+Background.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.underPageBackgroundColor)
|
||||
static let tertiaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
static let tertiaryBackground = Color(UIColor.tertiarySystemBackground)
|
||||
#else
|
||||
static func background(scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? .black : .init(white: 0.8)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
8
Extensions/NSTextField+FocusRingType.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import AppKit
|
||||
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {} // swiftlint:disable:this unused_setter_value
|
||||
}
|
||||
}
|
||||
10
Extensions/String+Format.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
|
||||
guard let range = range(of: target) else {
|
||||
return self
|
||||
}
|
||||
return replacingCharacters(in: range, with: replacement)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,21 @@ extension View {
|
||||
verticalEdgeBorder(.bottom, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderLeading(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.leading, width: width, color: color)
|
||||
}
|
||||
|
||||
func borderTrailing(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
horizontalEdgeBorder(.trailing, width: width, color: color)
|
||||
}
|
||||
|
||||
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
|
||||
overlay(Rectangle().frame(width: nil, height: height, alignment: .top).foregroundColor(color), alignment: edge)
|
||||
overlay(Rectangle().frame(width: nil, height: height, alignment: .top)
|
||||
.foregroundColor(color), alignment: edge)
|
||||
}
|
||||
|
||||
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
|
||||
overlay(Rectangle().frame(width: width, height: nil, alignment: .leading)
|
||||
.foregroundColor(color), alignment: edge)
|
||||
}
|
||||
}
|
||||
|
||||
18
Fixtures/Comment+Fixtures.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
extension Comment {
|
||||
static var fixture: Comment {
|
||||
Comment(
|
||||
id: UUID().uuidString,
|
||||
author: "The Author",
|
||||
authorAvatarURL: "https://pipedproxy-ams-2.kavin.rocks/Si7ZhtmpX84wj6MoJYLs8kwALw2Hm53wzbrPamoU-z3qvCKs2X3zPNYKMSJEvPDLUHzbvTfLcg=s176-c-k-c0x00ffffff-no-rw?host=yt3.ggpht.com",
|
||||
time: "2 months ago",
|
||||
pinned: true,
|
||||
hearted: true,
|
||||
likeCount: 30032,
|
||||
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
|
||||
repliesPage: "some url",
|
||||
channel: .init(id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ extension Video {
|
||||
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
|
||||
live: false,
|
||||
upcoming: false,
|
||||
publishedAt: Date.now,
|
||||
publishedAt: Date(),
|
||||
likes: 37333,
|
||||
dislikes: 30,
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
|
||||
|
||||
@@ -5,6 +5,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environmentObject(AccountsModel())
|
||||
.environmentObject(CommentsModel())
|
||||
.environmentObject(InstancesModel())
|
||||
.environmentObject(invidious)
|
||||
.environmentObject(NavigationModel())
|
||||
|
||||
@@ -27,10 +27,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
self.account = account
|
||||
|
||||
validInstance = false
|
||||
signedIn = false
|
||||
signedIn = !account.anonymous
|
||||
|
||||
configure()
|
||||
validate()
|
||||
}
|
||||
|
||||
func validate() {
|
||||
@@ -257,6 +256,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_: Video.ID, page _: String?) -> Resource? { nil }
|
||||
|
||||
private func searchQuery(_ query: String) -> String {
|
||||
var searchQuery = query
|
||||
|
||||
|
||||
@@ -71,6 +71,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? []
|
||||
let nextPage = details["nextpage"]?.stringValue
|
||||
let disabled = details["disabled"]?.boolValue ?? false
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
@@ -80,9 +89,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
@discardableResult func updateToken() -> Request {
|
||||
func updateToken() {
|
||||
guard !account.anonymous else {
|
||||
return
|
||||
}
|
||||
|
||||
account.token = nil
|
||||
return login.request(
|
||||
|
||||
login.request(
|
||||
.post,
|
||||
json: ["username": account.username, "password": account.password]
|
||||
)
|
||||
@@ -161,6 +175,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(path)"
|
||||
}
|
||||
@@ -395,4 +420,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
private static func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
return Comment(
|
||||
id: details["commentId"]?.stringValue ?? UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.stringValue ?? "",
|
||||
time: details["commentedTime"]?.stringValue ?? "",
|
||||
pinned: details["pinned"]?.boolValue ?? false,
|
||||
hearted: details["hearted"]?.boolValue ?? false,
|
||||
likeCount: details["likeCount"]?.intValue ?? 0,
|
||||
text: details["commentText"]?.stringValue ?? "",
|
||||
repliesPage: details["repliesPage"]?.stringValue,
|
||||
channel: Channel(id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ protocol VideosAPI {
|
||||
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
|
||||
extension VideosAPI {
|
||||
|
||||
@@ -38,4 +38,8 @@ enum VideosApp: String, CaseIterable {
|
||||
var hasFrontendURL: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
}
|
||||
|
||||
16
Model/Comment.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
struct Comment: Identifiable, Equatable {
|
||||
let id: String
|
||||
let author: String
|
||||
let authorAvatarURL: String
|
||||
let time: String
|
||||
let pinned: Bool
|
||||
let hearted: Bool
|
||||
var likeCount: Int
|
||||
let text: String
|
||||
let repliesPage: String?
|
||||
let channel: Channel
|
||||
|
||||
var hasReplies: Bool {
|
||||
!(repliesPage?.isEmpty ?? true)
|
||||
}
|
||||
}
|
||||
110
Model/CommentsModel.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class CommentsModel: ObservableObject {
|
||||
@Published var all = [Comment]()
|
||||
|
||||
@Published var nextPage: String?
|
||||
@Published var firstPage = true
|
||||
|
||||
@Published var loaded = true
|
||||
@Published var disabled = false
|
||||
|
||||
@Published var replies = [Comment]()
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var accounts: AccountsModel!
|
||||
var player: PlayerModel!
|
||||
|
||||
var instance: Instance? {
|
||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||
}
|
||||
|
||||
var api: VideosAPI? {
|
||||
instance.isNil ? nil : PipedAPI(account: instance!.anonymousAccount)
|
||||
}
|
||||
|
||||
static var enabled: Bool {
|
||||
!Defaults[.commentsInstanceID].isNil && !Defaults[.commentsInstanceID]!.isEmpty
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
static var placement: CommentsPlacement {
|
||||
Defaults[.commentsPlacement]
|
||||
}
|
||||
#endif
|
||||
|
||||
var nextPageAvailable: Bool {
|
||||
!(nextPage?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard Self.enabled else {
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
|
||||
guard !instance.isNil,
|
||||
!(player?.currentVideo.isNil ?? true)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all = page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
load(page: nextPage)
|
||||
}
|
||||
|
||||
func loadReplies(page: String) {
|
||||
guard !player.currentVideo.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
if page == repliesPageID {
|
||||
return
|
||||
}
|
||||
|
||||
replies = []
|
||||
repliesPageID = page
|
||||
repliesLoaded = false
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.replies = page.comments
|
||||
}
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
all = []
|
||||
disabled = false
|
||||
firstPage = true
|
||||
nextPage = nil
|
||||
loaded = false
|
||||
replies = []
|
||||
repliesLoaded = false
|
||||
}
|
||||
}
|
||||
7
Model/CommentsPage.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct CommentsPage {
|
||||
var comments = [Comment]()
|
||||
var nextPage: String?
|
||||
var disabled = false
|
||||
}
|
||||
@@ -5,6 +5,11 @@ struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
|
||||
@Default(.favorites) var all
|
||||
@Default(.visibleSections) var visibleSections
|
||||
|
||||
var isEnabled: Bool {
|
||||
visibleSections.contains(.favorites)
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
|
||||
@@ -23,7 +23,7 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection! = .favorites
|
||||
@Published var tabSelection: TabSelection!
|
||||
|
||||
@Published var presentingAddToPlaylist = false
|
||||
@Published var videoToAddToPlaylist: Video!
|
||||
|
||||
@@ -43,8 +43,10 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var restoredSegments = [Segment]()
|
||||
|
||||
var accounts: AccountsModel
|
||||
var comments: CommentsModel
|
||||
|
||||
var composition = AVMutableComposition()
|
||||
var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
private var frequentTimeObserver: Any?
|
||||
@@ -66,8 +68,9 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
}}
|
||||
|
||||
init(accounts: AccountsModel? = nil, instances _: InstancesModel? = nil) {
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.comments = comments ?? CommentsModel()
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
addFrequentTimeObserver()
|
||||
@@ -137,6 +140,7 @@ final class PlayerModel: ObservableObject {
|
||||
playerError = nil
|
||||
resetSegments()
|
||||
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
|
||||
comments.load()
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
@@ -147,9 +151,7 @@ final class PlayerModel: ObservableObject {
|
||||
logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
|
||||
Task {
|
||||
await self.loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
updateCurrentArtwork()
|
||||
@@ -228,46 +230,59 @@ final class PlayerModel: ObservableObject {
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) async {
|
||||
await loadCompositionAsset(stream.audioAsset, type: .audio, of: video)
|
||||
await loadCompositionAsset(stream.videoAsset, type: .video, of: video)
|
||||
|
||||
guard streamSelection == stream else {
|
||||
logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
) {
|
||||
loadedCompositionAssets = []
|
||||
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime)
|
||||
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
private func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
stream: Stream,
|
||||
type: AVMediaType,
|
||||
of video: Video
|
||||
) async {
|
||||
async let assetTracks = asset.loadTracks(withMediaType: type)
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
logger.info("loading \(type.rawValue) track")
|
||||
guard let compositionTrack = composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
guard let assetTrack = assetTracks.first else {
|
||||
self.logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
self.logger.critical("\(type.rawValue) LOADED")
|
||||
|
||||
guard self.streamSelection == stream else {
|
||||
self.logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
self.loadedCompositionAssets.append(type)
|
||||
|
||||
if self.loadedCompositionAssets.count == 2 {
|
||||
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
|
||||
guard let assetTrack = try? await assetTracks.first else {
|
||||
logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
logger.critical("\(type.rawValue) LOADED")
|
||||
}
|
||||
|
||||
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
|
||||
@@ -466,10 +481,9 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
fileprivate func updateNowPlayingInfo() {
|
||||
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
|
||||
let nowPlayingInfo: [String: AnyObject] = [
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
|
||||
MPMediaItemPropertyArtwork: currentArtwork as AnyObject,
|
||||
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
|
||||
@@ -477,6 +491,10 @@ final class PlayerModel: ObservableObject {
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
@@ -505,4 +523,10 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
|
||||
func closeCurrentItem() {
|
||||
addCurrentItemToHistory()
|
||||
currentItem = nil
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playNow(_ video: Video, at time: TimeInterval? = nil) {
|
||||
player.replaceCurrentItem(with: nil)
|
||||
addCurrentItemToHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
@@ -37,6 +38,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
comments.reset()
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
@@ -91,6 +93,7 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
player.replaceCurrentItem(with: nil)
|
||||
addCurrentItemToHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlaylistsProvider: DataProvider {
|
||||
@Published var playlists = [Playlist]()
|
||||
|
||||
let profile = Profile()
|
||||
|
||||
func load(successHandler: @escaping ([Playlist]) -> Void = { _ in }) {
|
||||
let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")])
|
||||
DataProvider.request("auth/playlists", headers: headers).responseJSON { response in
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
self.playlists = JSON(value).arrayValue.map { Playlist($0) }
|
||||
successHandler(self.playlists)
|
||||
case let .failure(error):
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
|
||||
final class RecentsModel: ObservableObject {
|
||||
@Default(.recentlyOpened) var items
|
||||
|
||||
@Default(.saveRecents) var saveRecents
|
||||
func clear() {
|
||||
items = []
|
||||
}
|
||||
@@ -13,6 +13,14 @@ final class RecentsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func add(_ item: RecentItem) {
|
||||
if !saveRecents {
|
||||
clear()
|
||||
|
||||
if item.type != .channel {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let index = items.firstIndex(where: { $0.id == item.id }) {
|
||||
items.remove(at: index)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ final class SearchModel: ObservableObject {
|
||||
@Published var query = SearchQuery()
|
||||
@Published var queryText = ""
|
||||
@Published var querySuggestions = Store<[String]>()
|
||||
@Published var suggestionsText = ""
|
||||
|
||||
@Published var fieldIsFocused = false
|
||||
|
||||
private var previousResource: Resource?
|
||||
private var resource: Resource!
|
||||
@@ -80,9 +83,13 @@ final class SearchModel: ObservableObject {
|
||||
private var suggestionsDebounceTimer: Timer?
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard !query.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
suggestionsDebounceTimer?.invalidate()
|
||||
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
|
||||
let resource = self.accounts.api.searchSuggestions(query: query)
|
||||
|
||||
resource.addObserver(self.querySuggestions)
|
||||
@@ -93,9 +100,11 @@ final class SearchModel: ObservableObject {
|
||||
if let suggestions: [String] = response.typedContent() {
|
||||
self.querySuggestions = Store<[String]>(suggestions)
|
||||
}
|
||||
self.suggestionsText = query
|
||||
}
|
||||
} else {
|
||||
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
|
||||
self.suggestionsText = query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
README.md
@@ -1,14 +1,12 @@
|
||||

|
||||
|
||||
Video player with support for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS 15, tvOS 15 and macOS Monterey.
|
||||
|
||||
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) built for iOS, tvOS and macOS.
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0.en.html)
|
||||

|
||||

|
||||
[](https://github.com/yattee/yattee/issues)
|
||||
[](https://github.com/yattee/yattee/pulls)
|
||||
[](https://matrix.to/#/#yattee:matrix.org)
|
||||
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
@@ -19,7 +17,7 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
|
||||
* Fullscreen playback, Picture in Picture and AirPlay support
|
||||
* Stream quality selection
|
||||
* Favorites: customizable section of channels, playlists, trending, searches and other views
|
||||
* URL Scheme for integrations
|
||||
* `yattee://` URL Scheme for integrations
|
||||
|
||||
### Availability
|
||||
| Feature | Invidious | Piped |
|
||||
@@ -35,81 +33,20 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
|
||||
| Search Suggestions | ✅ | ✅ |
|
||||
| Search Filters | ✅ | 🔴 |
|
||||
| Subtitles | 🔴 | ✅ |
|
||||
| Comments | 🔴 | ✅ |
|
||||
|
||||
## Installation
|
||||
### Requirements
|
||||
Only iOS/tvOS 15 and macOS Monterey are supported.
|
||||
You can browse and use accounts from one app and play videos with another (for example: use Invidious account for subscriptions and use Piped as playback source). Comments can be displayed from Piped even when Invidious is used for browsing/playing.
|
||||
|
||||
### How to install?
|
||||
#### [AltStore](https://altstore.io/)
|
||||
You can sideload IPA files that you can download from Releases page.
|
||||
Alternatively, if you have to access to the beta AltStore version (v1.5), you can add the following repository in `Browse > Sources` screen:
|
||||
## Documentation
|
||||
* [Installation Instructions](https://github.com/yattee/yattee/wiki/Installation-instructions)
|
||||
* [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
* [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
* [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
* [FAQ](https://github.com/yattee/yattee/wiki)
|
||||
* [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||
|
||||
`https://alt.yattee.stream`
|
||||
|
||||
#### Manual installation
|
||||
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program then the applications will require reinstalling every 7 days.
|
||||
|
||||
## Integrations
|
||||
### macOS
|
||||
With [Finicky](https://github.com/johnste/finicky) you can configure your system to open all the video links in the app. Example configuration:
|
||||
```js
|
||||
{
|
||||
match: [
|
||||
finicky.matchDomains(/(.*\.)?youtube.com/),
|
||||
finicky.matchDomains(/(.*\.)?youtu.be/)
|
||||
],
|
||||
browser: "/Applications/Yattee.app"
|
||||
}
|
||||
```
|
||||
|
||||
### Experimental: Safari
|
||||
macOS and iOS apps include Safari extension which will redirect opened YouTube tabs to the app.
|
||||
|
||||
### Expermiental: Firefox
|
||||
You can use [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect) extension to make the videos open in the app. In extension settings put the following URL as Invidious instance:
|
||||
|
||||
`https://r.yatte.stream`
|
||||
|
||||
## Screenshots
|
||||
### iOS
|
||||
| Player | Search | Playlists |
|
||||
| - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/iOS/player.png) | [](https://r.yattee.stream/screenshots/iOS/search-suggestions.png) | [](https://r.yattee.stream/screenshots/iOS/playlists.png) |
|
||||
### iPadOS
|
||||
| Settings | Player | Subscriptions |
|
||||
| - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/iPadOS/settings.png) | [](https://r.yattee.stream/screenshots/iPadOS/player.png) | [](https://r.yattee.stream/screenshots/iPadOS/subscriptions.png) |
|
||||
### tvOS
|
||||
| Player | Popular | Search | Now Playing | Settings |
|
||||
| - | - | - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/tvOS/player.png) | [](https://r.yattee.stream/screenshots/tvOS/popular.png) | [](https://r.yattee.stream/screenshots/tvOS/search.png) | [](https://r.yattee.stream/screenshots/tvOS/now-playing.png) | [](https://r.yattee.stream/screenshots/tvOS/settings.png) |
|
||||
### macOS
|
||||
| Player | Channel | Search | Settings |
|
||||
| - | - | - | - |
|
||||
| [](https://r.yattee.stream/screenshots/macOS/player.png) | [](https://r.yattee.stream/screenshots/macOS/channel.png) | [](https://r.yattee.stream/screenshots/macOS/search.png) | [](https://r.yattee.stream/screenshots/macOS/settings.png) |
|
||||
|
||||
## Tips
|
||||
### Settings
|
||||
* [tvOS] To open settings, press Play/Pause button while hovering over navigation menu or video
|
||||
### Navigation
|
||||
* Use videos context menus to add to queue, open or subscribe channel and add to playlist
|
||||
* [tvOS] Pressing buttons in the app trigger switch to next available option (for example: next account in Settings). If you want to access list of all options, press and hold to open the context menu.
|
||||
* [iOS] Swipe the player/title bar: up to open fullscreen details view, bottom to close fullscreen details or hide player
|
||||
### Favorites
|
||||
* Add more sections using ❤️ button in views channels, playlists, searches, subscriptions and popular
|
||||
* [iOS/macOS] Reorganize with dragging and dropping
|
||||
* [iOS/macOS] Remove section with right click/press and hold on section name
|
||||
* [tvOS] Reorganize and remove from `Settings > Edit Favorites...`
|
||||
### Keyboard shortcuts
|
||||
* `Command+1` - Favorites
|
||||
* `Command+2` - Subscriptions
|
||||
* `Command+3` - Popular
|
||||
* `Command+4` - Trending
|
||||
* `Command+F` - Search
|
||||
* `Command+P` - Play/Pause
|
||||
* `Command+S` - Play Next
|
||||
* `Command+O` - Toggle Player
|
||||
## Contributing
|
||||
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
|
||||
|
||||
## License and Liability
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 617 B After Width: | Height: | Size: 543 B |
|
Before Width: | Height: | Size: 773 B After Width: | Height: | Size: 557 B |
|
Before Width: | Height: | Size: 773 B After Width: | Height: | Size: 557 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1012 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 690 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1000 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1000 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 854 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -2,10 +2,11 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
extension Defaults.Keys {
|
||||
static let kavinPipedInstanceID = "kavin-piped"
|
||||
static let instances = Key<[Instance]>("instances", default: [
|
||||
.init(
|
||||
app: .piped,
|
||||
id: "default-piped-instance",
|
||||
id: kavinPipedInstanceID,
|
||||
name: "Kavin",
|
||||
apiURL: "https://pipedapi.kavin.rocks",
|
||||
frontendURL: "https://piped.kavin.rocks"
|
||||
@@ -32,6 +33,10 @@ extension Defaults.Keys {
|
||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
|
||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
static let commentsInstanceID = Key<Instance.ID?>("commentsInstance", default: kavinPipedInstanceID)
|
||||
#if !os(tvOS)
|
||||
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
|
||||
#endif
|
||||
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
|
||||
@@ -40,12 +45,15 @@ extension Defaults.Keys {
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
|
||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||
|
||||
#if os(iOS)
|
||||
static let tabNavigationSection = Key<TabNavigationSectionSetting>("tabNavigationSection", default: .trending)
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.favorites, .subscriptions, .trending, .playlists])
|
||||
|
||||
#if os(macOS)
|
||||
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -83,8 +91,54 @@ enum PlayerSidebarSetting: String, CaseIterable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
enum TabNavigationSectionSetting: String, Defaults.Serializable {
|
||||
case trending, popular
|
||||
enum VisibleSection: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case favorites, subscriptions, popular, trending, playlists
|
||||
|
||||
static func from(_ string: String) -> VisibleSection {
|
||||
allCases.first { $0.rawValue == string }!
|
||||
}
|
||||
|
||||
var title: String {
|
||||
rawValue.localizedCapitalized
|
||||
}
|
||||
|
||||
var tabSelection: TabSelection {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return TabSelection.favorites
|
||||
case .subscriptions:
|
||||
return TabSelection.subscriptions
|
||||
case .popular:
|
||||
return TabSelection.popular
|
||||
case .trending:
|
||||
return TabSelection.trending
|
||||
case .playlists:
|
||||
return TabSelection.playlists
|
||||
}
|
||||
}
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return 0
|
||||
case .subscriptions:
|
||||
return 1
|
||||
case .popular:
|
||||
return 2
|
||||
case .trending:
|
||||
return 3
|
||||
case .playlists:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
enum CommentsPlacement: String, CaseIterable, Defaults.Serializable {
|
||||
case info, separate
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -66,6 +66,10 @@ struct FavoriteItemView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: accounts.current) { _ in
|
||||
resource?.addObserver(store)
|
||||
resource?.load()
|
||||
}
|
||||
}
|
||||
|
||||
private var isVisible: Bool {
|
||||
|
||||
@@ -29,6 +29,9 @@ struct FavoritesView: View {
|
||||
#else
|
||||
ForEach(favorites) { item in
|
||||
FavoriteItemView(item: item, dragging: $dragging)
|
||||
#if os(macOS)
|
||||
.workaroundForVerticalScrollingBug()
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -42,16 +45,13 @@ struct FavoritesView: View {
|
||||
.redrawOn(change: favoritesChanged)
|
||||
|
||||
#if os(tvOS)
|
||||
.sheet(isPresented: $presentingEditFavorites) {
|
||||
EditFavorites()
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
.onDrop(of: [UTType.text], delegate: DropFavoriteOutside(current: $dragging))
|
||||
.navigationTitle("Favorites")
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.background()
|
||||
.background(Color.tertiaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ struct MenuCommands: Commands {
|
||||
}
|
||||
|
||||
private var navigationMenu: some Commands {
|
||||
CommandMenu("Navigation") {
|
||||
CommandGroup(before: .windowSize) {
|
||||
Button("Favorites") {
|
||||
model.navigation?.tabSelection = .favorites
|
||||
}
|
||||
@@ -19,7 +19,7 @@ struct MenuCommands: Commands {
|
||||
Button("Subscriptions") {
|
||||
model.navigation?.tabSelection = .subscriptions
|
||||
}
|
||||
.disabled(!(model.accounts?.app.supportsSubscriptions ?? true))
|
||||
.disabled(subscriptionsDisabled)
|
||||
.keyboardShortcut("2")
|
||||
|
||||
Button("Popular") {
|
||||
@@ -37,9 +37,17 @@ struct MenuCommands: Commands {
|
||||
model.navigation?.tabSelection = .search
|
||||
}
|
||||
.keyboardShortcut("f")
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
private var subscriptionsDisabled: Bool {
|
||||
!(
|
||||
(model.accounts?.app.supportsSubscriptions ?? false) && model.accounts?.signedIn ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private var playbackMenu: some Commands {
|
||||
CommandMenu("Playback") {
|
||||
Button((model.player?.isPlaying ?? true) ? "Pause" : "Play") {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct UnsubscribeAlertModifier: ViewModifier {
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(unsubscribeAlertTitle, isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
Button("Unsubscribe", role: .destructive) {
|
||||
subscriptions.unsubscribe(channel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var unsubscribeAlertTitle: String {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
return "Unsubscribe from \(channel.name)"
|
||||
}
|
||||
|
||||
return "Unknown channel"
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,25 @@ struct AccountsMenuView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
label
|
||||
.labelStyle(.titleAndIcon)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
label
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(instances.isEmpty)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
|
||||
}
|
||||
|
||||
private var allAccounts: [Account] {
|
||||
accounts + instances.map(\.anonymousAccount)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import Introspect
|
||||
@@ -5,8 +6,19 @@ import SwiftUI
|
||||
|
||||
struct AppSidebarNavigation: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
#if os(iOS)
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@State private var didApplyPrimaryViewWorkAround = false
|
||||
#endif
|
||||
|
||||
@@ -38,15 +50,50 @@ struct AppSidebarNavigation: View {
|
||||
.frame(minWidth: sidebarMinWidth)
|
||||
|
||||
VStack {
|
||||
Image(systemName: "play.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
PlayerControlsView {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(systemName: "play.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#elseif os(macOS)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.frame(minWidth: 1000, minHeight: 750)
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
|
||||
@@ -12,6 +12,7 @@ struct AppSidebarPlaylists: View {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
|
||||
.backport
|
||||
.badge(Text("\(playlist.videos.count)"))
|
||||
}
|
||||
.id(playlist.id)
|
||||
|
||||
@@ -18,7 +18,6 @@ struct AppSidebarSubscriptions: View {
|
||||
navigation.presentUnsubscribeAlert(channel)
|
||||
}
|
||||
}
|
||||
.modifier(UnsubscribeAlertModifier())
|
||||
.id("channel\(channel.id)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,124 +3,111 @@ import SwiftUI
|
||||
|
||||
struct AppTabNavigation: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
|
||||
|
||||
@Default(.tabNavigationSection) private var tabNavigationSection
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: navigation.tabSelectionBinding) {
|
||||
NavigationView {
|
||||
LazyView(FavoritesView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.tag(TabSelection.favorites)
|
||||
|
||||
if subscriptionsVisible {
|
||||
NavigationView {
|
||||
LazyView(SubscriptionsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Subscriptions", systemImage: "star.circle.fill")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.tag(TabSelection.subscriptions)
|
||||
if visibleSections.contains(.favorites) {
|
||||
favoritesNavigationView
|
||||
}
|
||||
|
||||
if subscriptionsVisible {
|
||||
if accounts.app.supportsPopular {
|
||||
if tabNavigationSection == .popular {
|
||||
popularNavigationView
|
||||
} else {
|
||||
trendingNavigationView
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if accounts.app.supportsPopular {
|
||||
popularNavigationView
|
||||
}
|
||||
subscriptionsNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular, visibleSections.count < 5 {
|
||||
popularNavigationView
|
||||
}
|
||||
|
||||
if visibleSections.contains(.trending) {
|
||||
trendingNavigationView
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists {
|
||||
NavigationView {
|
||||
LazyView(PlaylistsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playlists", systemImage: "list.and.film")
|
||||
.accessibility(label: Text("Playlists"))
|
||||
}
|
||||
.tag(TabSelection.playlists)
|
||||
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
|
||||
playlistsNavigationView
|
||||
}
|
||||
|
||||
NavigationView {
|
||||
LazyView(
|
||||
SearchView()
|
||||
.toolbar { toolbarContent }
|
||||
.searchable(text: $search.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
|
||||
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
}
|
||||
}
|
||||
.onChange(of: search.queryText) { query in
|
||||
search.loadSuggestions(query)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
search.changeQuery { query in
|
||||
query.query = search.queryText
|
||||
}
|
||||
|
||||
recents.addQuery(search.queryText)
|
||||
}
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.accessibility(label: Text("Search"))
|
||||
}
|
||||
.tag(TabSelection.search)
|
||||
searchNavigationView
|
||||
}
|
||||
.id(accounts.current?.id ?? "")
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.sheet(isPresented: $navigation.presentingChannel, onDismiss: {
|
||||
if let channel = recents.presentedChannel {
|
||||
recents.close(RecentItem(from: channel))
|
||||
}
|
||||
}) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.background(playerNavigationLink)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingChannel, onDismiss: {
|
||||
if let channel = recents.presentedChannel {
|
||||
recents.close(RecentItem(from: channel))
|
||||
}
|
||||
}) {
|
||||
if let channel = recents.presentedChannel {
|
||||
NavigationView {
|
||||
ChannelVideosView(channel: channel)
|
||||
.environment(\.inChannelView, true)
|
||||
.environment(\.inNavigationView, true)
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingPlaylist, onDismiss: {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
recents.close(RecentItem(from: playlist))
|
||||
}
|
||||
}) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.inNavigationView, true)
|
||||
.background(playerNavigationLink)
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylist, onDismiss: {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
recents.close(RecentItem(from: playlist))
|
||||
}
|
||||
}) {
|
||||
if let playlist = recents.presentedPlaylist {
|
||||
NavigationView {
|
||||
ChannelPlaylistView(playlist: playlist)
|
||||
.environment(\.inNavigationView, true)
|
||||
.environmentObject(subscriptions)
|
||||
.background(playerNavigationLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
videoPlayer
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var favoritesNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(FavoritesView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
.tag(TabSelection.favorites)
|
||||
}
|
||||
|
||||
private var subscriptionsNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(SubscriptionsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Subscriptions", systemImage: "star.circle.fill")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
.tag(TabSelection.subscriptions)
|
||||
}
|
||||
|
||||
private var subscriptionsVisible: Bool {
|
||||
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
|
||||
visibleSections.contains(.subscriptions) &&
|
||||
accounts.app.supportsSubscriptions && !(accounts.current?.anonymous ?? true)
|
||||
}
|
||||
|
||||
private var popularNavigationView: some View {
|
||||
@@ -129,7 +116,7 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Popular", systemImage: "chart.bar")
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
.tag(TabSelection.popular)
|
||||
@@ -141,12 +128,36 @@ struct AppTabNavigation: View {
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
.tag(TabSelection.trending)
|
||||
}
|
||||
|
||||
private var playlistsNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(PlaylistsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playlists", systemImage: "list.and.film")
|
||||
.accessibility(label: Text("Playlists"))
|
||||
}
|
||||
.tag(TabSelection.playlists)
|
||||
}
|
||||
|
||||
private var searchNavigationView: some View {
|
||||
NavigationView {
|
||||
LazyView(SearchView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.accessibility(label: Text("Search"))
|
||||
}
|
||||
.tag(TabSelection.search)
|
||||
}
|
||||
|
||||
private var playerNavigationLink: some View {
|
||||
NavigationLink(isActive: $player.playerNavigationLinkActive, destination: {
|
||||
VideoPlayerView()
|
||||
@@ -156,6 +167,19 @@ struct AppTabNavigation: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
#if os(iOS)
|
||||
Group {
|
||||
|
||||
@@ -8,6 +8,7 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var accounts = AccountsModel()
|
||||
@StateObject private var comments = CommentsModel()
|
||||
@StateObject private var instances = InstancesModel()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var player = PlayerModel()
|
||||
@@ -24,7 +25,7 @@ struct ContentView: View {
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if horizontalSizeClass == .compact {
|
||||
AppTabNavigation()
|
||||
@@ -40,6 +41,7 @@ struct ContentView: View {
|
||||
.onAppear(perform: configure)
|
||||
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
@@ -49,59 +51,45 @@ struct ContentView: View {
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
|
||||
.sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.sheet(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
.frame(minWidth: 900, minHeight: 800)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
#endif
|
||||
// iOS 14 has problem with multiple sheets in one view
|
||||
// but it's ok when it's in background
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingWelcomeScreen) {
|
||||
WelcomeScreen()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(navigation)
|
||||
}
|
||||
)
|
||||
#if !os(tvOS)
|
||||
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
|
||||
.onOpenURL(perform: handleOpenedURL)
|
||||
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
}
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
|
||||
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(playlists)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $navigation.presentingSettings, onDismiss: openWelcomeScreenIfAccountEmpty) {
|
||||
SettingsView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(instances)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func configure() {
|
||||
SiestaLog.Category.enabled = .common
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "net.yattee.app")
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
#endif
|
||||
@@ -117,18 +105,37 @@ struct ContentView: View {
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
player.accounts = accounts
|
||||
playlists.accounts = accounts
|
||||
search.accounts = accounts
|
||||
subscriptions.accounts = accounts
|
||||
|
||||
comments.accounts = accounts
|
||||
comments.player = player
|
||||
|
||||
menu.accounts = accounts
|
||||
menu.navigation = navigation
|
||||
menu.player = player
|
||||
|
||||
player.accounts = accounts
|
||||
player.comments = comments
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.loadHistoryDetails()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
recents.clear()
|
||||
}
|
||||
|
||||
var section = Defaults[.visibleSections].min()?.tabSelection
|
||||
|
||||
#if os(macOS)
|
||||
if section == .playlists {
|
||||
section = .search
|
||||
}
|
||||
#endif
|
||||
|
||||
navigation.tabSelection = section ?? .search
|
||||
}
|
||||
|
||||
func openWelcomeScreenIfAccountEmpty() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Sidebar: View {
|
||||
@@ -6,6 +7,8 @@ struct Sidebar: View {
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scrollView in
|
||||
List {
|
||||
@@ -16,11 +19,11 @@ struct Sidebar: View {
|
||||
.id("recentlyOpened")
|
||||
|
||||
if accounts.api.signedIn {
|
||||
if accounts.app.supportsSubscriptions {
|
||||
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions {
|
||||
AppSidebarSubscriptions()
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists {
|
||||
if visibleSections.contains(.playlists), accounts.app.supportsUserPlaylists {
|
||||
AppSidebarPlaylists()
|
||||
}
|
||||
}
|
||||
@@ -46,28 +49,34 @@ struct Sidebar: View {
|
||||
}
|
||||
|
||||
var mainNavigationLinks: some View {
|
||||
Section("Videos") {
|
||||
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
Section(header: Text("Videos")) {
|
||||
if visibleSections.contains(.favorites) {
|
||||
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
|
||||
Label("Favorites", systemImage: "heart")
|
||||
.accessibility(label: Text("Favorites"))
|
||||
}
|
||||
}
|
||||
if accounts.app.supportsSubscriptions && accounts.signedIn {
|
||||
if visibleSections.contains(.subscriptions),
|
||||
accounts.app.supportsSubscriptions && accounts.signedIn
|
||||
{
|
||||
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
|
||||
Label("Subscriptions", systemImage: "star.circle")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
}
|
||||
|
||||
if accounts.app.supportsPopular {
|
||||
if visibleSections.contains(.popular), accounts.app.supportsPopular {
|
||||
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
|
||||
Label("Popular", systemImage: "chart.bar")
|
||||
Label("Popular", systemImage: "arrow.up.right.circle")
|
||||
.accessibility(label: Text("Popular"))
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
|
||||
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
|
||||
.accessibility(label: Text("Trending"))
|
||||
if visibleSections.contains(.trending) {
|
||||
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
|
||||
Label("Trending", systemImage: "chart.bar")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(SearchView()), tag: TabSelection.search, selection: $navigation.tabSelection) {
|
||||
@@ -78,7 +87,7 @@ struct Sidebar: View {
|
||||
}
|
||||
}
|
||||
|
||||
func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
|
||||
private func scrollScrollViewToItem(scrollView: ScrollViewProxy, for selection: TabSelection) {
|
||||
if case .recentlyOpened = selection {
|
||||
scrollView.scrollTo("recentlyOpened")
|
||||
} else if case let .playlist(id) = selection {
|
||||
|
||||
251
Shared/Player/CommentView.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import SDWebImageSwiftUI
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CommentView: View {
|
||||
let comment: Comment
|
||||
@Binding var repliesID: Comment.ID?
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
authorAvatar
|
||||
|
||||
#if os(iOS)
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
HStack(spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
likes
|
||||
statusIcons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
|
||||
#else
|
||||
HStack(spacing: 20) {
|
||||
authorAndTime
|
||||
|
||||
Spacer()
|
||||
|
||||
statusIcons
|
||||
likes
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Group {
|
||||
commentText
|
||||
|
||||
if comment.hasReplies {
|
||||
HStack(spacing: repliesButtonStackSpacing) {
|
||||
repliesButton
|
||||
|
||||
ProgressView()
|
||||
.scaleEffect(progressViewScale, anchor: .center)
|
||||
.opacity(repliesID == comment.id && !comments.repliesLoaded ? 1 : 0)
|
||||
.frame(maxHeight: 0)
|
||||
}
|
||||
|
||||
if comment.id == repliesID {
|
||||
repliesList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 20)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var authorAvatar: some View {
|
||||
WebImage(url: URL(string: comment.authorAvatarURL)!)
|
||||
.resizable()
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(false)
|
||||
.indicator(.activity)
|
||||
.mask(RoundedRectangle(cornerRadius: 60))
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
.contextMenu {
|
||||
Button(action: openChannelAction) {
|
||||
Label("\(comment.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var authorAndTime: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(comment.author)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text(comment.time)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private var statusIcons: some View {
|
||||
HStack(spacing: 15) {
|
||||
if comment.pinned {
|
||||
Image(systemName: "pin.fill")
|
||||
}
|
||||
if comment.hearted {
|
||||
Image(systemName: "heart.fill")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private var likes: some View {
|
||||
Group {
|
||||
if comment.likeCount > 0 {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
Text("\(comment.likeCount.formattedAsAbbreviation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private var repliesButton: some View {
|
||||
Button {
|
||||
repliesID = repliesID == comment.id ? nil : comment.id
|
||||
|
||||
guard !repliesID.isNil, !comment.repliesPage.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
comments.loadReplies(page: comment.repliesPage!)
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: repliesID == comment.id ? "arrow.turn.left.up" : "arrow.turn.right.down")
|
||||
Text("Replies")
|
||||
}
|
||||
#if os(tvOS)
|
||||
.padding(10)
|
||||
#endif
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 2)
|
||||
#if os(tvOS)
|
||||
.padding(.leading, 5)
|
||||
#else
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var repliesButtonStackSpacing: Double {
|
||||
#if os(tvOS)
|
||||
24
|
||||
#elseif os(iOS)
|
||||
4
|
||||
#else
|
||||
2
|
||||
#endif
|
||||
}
|
||||
|
||||
private var progressViewScale: Double {
|
||||
#if os(macOS)
|
||||
0.4
|
||||
#else
|
||||
0.8
|
||||
#endif
|
||||
}
|
||||
|
||||
private var repliesList: some View {
|
||||
Group {
|
||||
let last = comments.replies.last
|
||||
ForEach(comments.replies) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 22)
|
||||
}
|
||||
|
||||
private var commentText: some View {
|
||||
Group {
|
||||
let text = Text(comment.text)
|
||||
#if os(macOS)
|
||||
.font(.system(size: 14))
|
||||
#elseif os(iOS)
|
||||
.font(.system(size: 15))
|
||||
#endif
|
||||
.lineSpacing(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
text
|
||||
#if !os(tvOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openChannelAction() {
|
||||
player.presentingPlayer = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let recent = RecentItem(from: comment.channel)
|
||||
recents.add(recent)
|
||||
navigation.presentingChannel = true
|
||||
|
||||
if navigationStyle == .sidebar {
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentView_Previews: PreviewProvider {
|
||||
static var fixture: Comment {
|
||||
Comment.fixture
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
CommentView(comment: fixture, repliesID: .constant(fixture.id))
|
||||
}
|
||||
}
|
||||
91
Shared/Player/CommentsView.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentsView: View {
|
||||
@State private var repliesID: Comment.ID?
|
||||
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if comments.disabled {
|
||||
Text("Comments are disabled for this video")
|
||||
.foregroundColor(.secondary)
|
||||
} else if comments.loaded && comments.all.isEmpty {
|
||||
Text("No comments")
|
||||
.foregroundColor(.secondary)
|
||||
} else if !comments.loaded {
|
||||
progressView
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading) {
|
||||
let last = comments.all.last
|
||||
ForEach(comments.all) { comment in
|
||||
CommentView(comment: comment, repliesID: $repliesID)
|
||||
|
||||
if comment != last {
|
||||
Divider()
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
if comments.nextPageAvailable {
|
||||
Button {
|
||||
repliesID = nil
|
||||
comments.loadNextPage()
|
||||
} label: {
|
||||
Label("Show more", systemImage: "arrow.turn.down.right")
|
||||
}
|
||||
}
|
||||
|
||||
if !comments.firstPage {
|
||||
Button {
|
||||
repliesID = nil
|
||||
comments.load(page: nil)
|
||||
} label: {
|
||||
Label("Show first", systemImage: "arrow.turn.down.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.onAppear {
|
||||
if !comments.loaded {
|
||||
comments.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
CommentsView()
|
||||
.previewInterfaceOrientation(.landscapeRight)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
|
||||
CommentsView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackBar: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@@ -15,13 +16,10 @@ struct PlaybackBar: View {
|
||||
if player.currentItem != nil {
|
||||
HStack {
|
||||
Text(playbackStatus)
|
||||
|
||||
Text("•")
|
||||
|
||||
rateMenu
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -58,24 +56,26 @@ struct PlaybackBar: View {
|
||||
#endif
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption2)
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.alert(player.playerError?.localizedDescription ?? "", isPresented: $player.presentingErrorDetails) {
|
||||
Button("OK") {}
|
||||
.foregroundColor(colorScheme == .dark ? .gray : .black)
|
||||
.alert(isPresented: $player.presentingErrorDetails) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(player.playerError?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.padding(4)
|
||||
.background(.black)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} label: {
|
||||
Label(
|
||||
"Close",
|
||||
@@ -105,10 +105,18 @@ struct PlaybackBar: View {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds)
|
||||
let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened)
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(timeFinishAtString)"
|
||||
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
||||
}
|
||||
|
||||
private func formattedTimeFinishAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .none
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private var rateMenu: some View {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Player: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@@ -18,6 +19,7 @@ struct Player: UIViewControllerRepresentable {
|
||||
|
||||
let controller = PlayerViewController()
|
||||
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
player.controller = controller
|
||||
|
||||
@@ -49,11 +49,6 @@ struct PlayerQueueView: View {
|
||||
removeButton(item, history: false)
|
||||
removeAllButton(history: false)
|
||||
}
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
removeButton(item, history: false)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,11 +63,6 @@ struct PlayerQueueView: View {
|
||||
removeButton(item, history: true)
|
||||
removeAllButton(history: true)
|
||||
}
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
removeButton(item, history: true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,28 +90,28 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
|
||||
private func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View {
|
||||
Button(role: .destructive) {
|
||||
if history {
|
||||
player.removeHistory(item)
|
||||
} else {
|
||||
player.remove(item)
|
||||
}
|
||||
Button {
|
||||
removeButtonAction(item, history: history)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeButtonAction(_ item: PlayerQueueItem, history: Bool) {
|
||||
_ = history ? player.removeHistory(item) : player.remove(item)
|
||||
}
|
||||
|
||||
private func removeAllButton(history: Bool) -> some View {
|
||||
Button(role: .destructive) {
|
||||
if history {
|
||||
player.removeHistoryItems()
|
||||
} else {
|
||||
player.removeQueueItems()
|
||||
}
|
||||
Button {
|
||||
removeAllButtonAction(history: history)
|
||||
} label: {
|
||||
Label("Remove All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAllButtonAction(history: Bool) {
|
||||
_ = history ? player.removeHistoryItems() : player.removeQueueItems()
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerQueueView_Previews: PreviewProvider {
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
final class PlayerViewController: UIViewController {
|
||||
var playerLoaded = false
|
||||
var commentsModel: CommentsModel!
|
||||
var navigationModel: NavigationModel!
|
||||
var playerModel: PlayerModel!
|
||||
var playerViewController = AVPlayerViewController()
|
||||
@@ -44,10 +45,16 @@ final class PlayerViewController: UIViewController {
|
||||
|
||||
#if os(tvOS)
|
||||
playerModel.avPlayerViewController = playerViewController
|
||||
playerViewController.customInfoViewControllers = [
|
||||
var infoViewControllers = [UIHostingController<AnyView>]()
|
||||
if CommentsModel.enabled {
|
||||
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
|
||||
}
|
||||
infoViewControllers.append(contentsOf: [
|
||||
infoViewController([.related], title: "Related"),
|
||||
infoViewController([.playingNext, .playedPreviously], title: "Playing Next")
|
||||
]
|
||||
])
|
||||
|
||||
playerViewController.customInfoViewControllers = infoViewControllers
|
||||
#else
|
||||
embedViewController()
|
||||
#endif
|
||||
@@ -62,6 +69,7 @@ final class PlayerViewController: UIViewController {
|
||||
AnyView(
|
||||
NowPlayingView(sections: sections, inInfoViewController: true)
|
||||
.frame(maxHeight: 600)
|
||||
.environmentObject(commentsModel)
|
||||
.environmentObject(playerModel)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,18 @@ struct RelatedView: View {
|
||||
Section(header: Text("Related")) {
|
||||
ForEach(player.currentVideo!.related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: .constant(false))
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
} label: {
|
||||
Label("Play Next", systemImage: "text.insert")
|
||||
}
|
||||
Button {
|
||||
player.enqueueVideo(video)
|
||||
} label: {
|
||||
Label("Play Last", systemImage: "text.append")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ import SwiftUI
|
||||
|
||||
struct VideoDetails: View {
|
||||
enum Page {
|
||||
case details, queue, related
|
||||
case info, queue, related, comments
|
||||
}
|
||||
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
|
||||
@State private var subscribed = false
|
||||
@State private var confirmationShown = false
|
||||
@State private var presentingUnsubscribeAlert = false
|
||||
@State private var presentingAddToPlaylist = false
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
|
||||
@State private var currentPage = Page.details
|
||||
@State private var currentPage = Page.info
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@@ -65,7 +65,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if !sidebarQueue {
|
||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
||||
pagePicker
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -82,14 +82,14 @@ struct VideoDetails: View {
|
||||
if fullScreen {
|
||||
fullScreen = false
|
||||
} else {
|
||||
self.dismiss()
|
||||
self.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
switch currentPage {
|
||||
case .details:
|
||||
case .info:
|
||||
ScrollView(.vertical) {
|
||||
detailsPage
|
||||
}
|
||||
@@ -100,6 +100,9 @@ struct VideoDetails: View {
|
||||
case .related:
|
||||
RelatedView()
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
case .comments:
|
||||
CommentsView()
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top, inNavigationView && fullScreen ? 10 : 0)
|
||||
@@ -116,7 +119,7 @@ struct VideoDetails: View {
|
||||
.onChange(of: sidebarQueue) { queue in
|
||||
if queue {
|
||||
if currentPage == .queue {
|
||||
currentPage = .details
|
||||
currentPage = .info
|
||||
}
|
||||
} else if video.isNil {
|
||||
currentPage = .queue
|
||||
@@ -131,7 +134,20 @@ struct VideoDetails: View {
|
||||
if video != nil {
|
||||
Text(video!.title)
|
||||
.onAppear {
|
||||
currentPage = .details
|
||||
currentPage = .info
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.closeCurrentItem()
|
||||
if !sidebarQueue {
|
||||
currentPage = .queue
|
||||
} else {
|
||||
currentPage = .info
|
||||
}
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark.circle")
|
||||
}
|
||||
.disabled(player.currentItem.isNil)
|
||||
}
|
||||
|
||||
.font(.title2.bold())
|
||||
@@ -184,19 +200,26 @@ struct VideoDetails: View {
|
||||
Section {
|
||||
if subscribed {
|
||||
Button("Unsubscribe") {
|
||||
confirmationShown = true
|
||||
presentingUnsubscribeAlert = true
|
||||
}
|
||||
#if os(iOS)
|
||||
.backport
|
||||
.tint(.gray)
|
||||
#endif
|
||||
.confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) {
|
||||
Button("Unsubscribe") {
|
||||
subscriptions.unsubscribe(video!.channel.id)
|
||||
.alert(isPresented: $presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you you want to unsubscribe from \(video!.channel.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptions.unsubscribe(video!.channel.id)
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
@@ -206,12 +229,12 @@ struct VideoDetails: View {
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.buttonStyle(.borderless)
|
||||
.buttonBorderShape(.roundedRectangle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,15 +244,23 @@ struct VideoDetails: View {
|
||||
var pagePicker: some View {
|
||||
Picker("Page", selection: $currentPage) {
|
||||
if !video.isNil {
|
||||
Text("Details").tag(Page.details)
|
||||
Text("Related").tag(Page.related)
|
||||
Text("Info").tag(Page.info)
|
||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
||||
Text("Comments")
|
||||
.tag(Page.comments)
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Related").tag(Page.related)
|
||||
}
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Queue").tag(Page.queue)
|
||||
}
|
||||
Text("Queue").tag(Page.queue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.onDisappear {
|
||||
currentPage = .details
|
||||
currentPage = .info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,13 +272,13 @@ struct VideoDetails: View {
|
||||
Text(published)
|
||||
}
|
||||
|
||||
if let publishedAt = video.publishedAt {
|
||||
if let date = video.publishedAt {
|
||||
if video.publishedDate != nil {
|
||||
Text("•")
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.3)
|
||||
}
|
||||
Text(publishedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
Text(formattedPublishedAt(date))
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
@@ -257,6 +288,15 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
|
||||
func formattedPublishedAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .none
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
var countsSection: some View {
|
||||
Group {
|
||||
if let video = player.currentVideo {
|
||||
@@ -270,19 +310,19 @@ struct VideoDetails: View {
|
||||
Spacer()
|
||||
|
||||
if let views = video.viewsCount {
|
||||
videoDetail(label: "Views", value: views, symbol: "eye.fill")
|
||||
videoDetail(label: "Views", value: views, symbol: "eye")
|
||||
}
|
||||
|
||||
if let likes = video.likesCount {
|
||||
Divider()
|
||||
|
||||
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup.circle.fill")
|
||||
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup")
|
||||
}
|
||||
|
||||
if let dislikes = video.dislikesCount {
|
||||
Divider()
|
||||
|
||||
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown.circle.fill")
|
||||
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -302,17 +342,21 @@ struct VideoDetails: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $presentingAddToPlaylist) {
|
||||
if let video = video {
|
||||
AddToPlaylistView(video: video)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $presentingAddToPlaylist) {
|
||||
if let video = video {
|
||||
AddToPlaylistView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $presentingShareSheet) {
|
||||
if let shareURL = shareURL {
|
||||
ShareSheet(activityItems: [shareURL])
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $presentingShareSheet) {
|
||||
if let shareURL = shareURL {
|
||||
ShareSheet(activityItems: [shareURL])
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -322,58 +366,81 @@ struct VideoDetails: View {
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
if let video = player.currentItem?.video {
|
||||
Group {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
Group {
|
||||
if let video = player.currentItem?.video {
|
||||
Group {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
countsSection
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
countsSection
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
Text(description)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.caption)
|
||||
.padding(.bottom, 4)
|
||||
} else {
|
||||
Text("No description")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if showKeywords {
|
||||
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
|
||||
HStack {
|
||||
ForEach(video.keywords, id: \.self) { keyword in
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Text("#")
|
||||
.font(.system(size: 11).bold())
|
||||
|
||||
Text(keyword)
|
||||
.frame(maxWidth: 500)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color("VideoDetailLikesSymbolColor"))
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
Text(description)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(description)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.system(size: 14))
|
||||
.lineSpacing(3)
|
||||
} else {
|
||||
Text("No description")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if showKeywords {
|
||||
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
|
||||
HStack {
|
||||
ForEach(video.keywords, id: \.self) { keyword in
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Text("#")
|
||||
.font(.system(size: 11).bold())
|
||||
|
||||
Text(keyword)
|
||||
.frame(maxWidth: 500)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color("VideoDetailLikesSymbolColor"))
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !video.isNil, CommentsModel.placement == .info {
|
||||
Divider()
|
||||
#if os(macOS)
|
||||
.padding(.bottom, 20)
|
||||
#else
|
||||
.padding(.vertical, 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Group {
|
||||
if !video.isNil, CommentsModel.placement == .info {
|
||||
CommentsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
func videoDetail(label: String, value: String, symbol: String) -> some View {
|
||||
|
||||
@@ -16,8 +16,10 @@ struct VideoPlayerView: View {
|
||||
@State private var playerSize: CGSize = .zero
|
||||
@State private var fullScreen = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#endif
|
||||
@@ -82,11 +84,11 @@ struct VideoPlayerView: View {
|
||||
fullScreen = true
|
||||
}
|
||||
},
|
||||
down: { dismiss() }
|
||||
down: { presentationMode.wrappedValue.dismiss() }
|
||||
)
|
||||
#endif
|
||||
|
||||
.background(.black)
|
||||
.background(Color.black)
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
@@ -98,13 +100,14 @@ struct VideoPlayerView: View {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
#endif
|
||||
}
|
||||
.background()
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio, fullScreen: fullScreen))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 650)
|
||||
.frame(minWidth: 650)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
@@ -114,7 +117,7 @@ struct VideoPlayerView: View {
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
.frame(minWidth: 250)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var selectedPlaylistID: Playlist.ID = ""
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
var body: some View {
|
||||
@@ -37,7 +38,7 @@ struct AddToPlaylistView: View {
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(.thickMaterial)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.padding(.vertical)
|
||||
#endif
|
||||
@@ -70,7 +71,7 @@ struct AddToPlaylistView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
@@ -155,7 +156,7 @@ struct AddToPlaylistView: View {
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
model.addVideo(playlistID: id, videoID: video.videoID) {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,8 @@ struct PlaylistFormView: View {
|
||||
@State private var valid = false
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@@ -22,77 +21,68 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS) || os(iOS)
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
Text(editing ? "Edit Playlist" : "Create Playlist")
|
||||
.font(.title2.bold())
|
||||
Group {
|
||||
#if os(macOS) || os(iOS)
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
Text(editing ? "Edit Playlist" : "Create Playlist")
|
||||
.font(.title2.bold())
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}.keyboardShortcut(.cancelAction)
|
||||
Button("Cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Form {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
.padding(.leading, 10)
|
||||
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
if editing {
|
||||
deletePlaylistButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Form {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
.padding(.leading, 10)
|
||||
.focused($focused)
|
||||
|
||||
visibilityFormItem
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
if editing {
|
||||
deletePlaylistButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.onChange(of: name) { _ in validate() }
|
||||
.onAppear(perform: initializeForm)
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
VStack {
|
||||
Group {
|
||||
header
|
||||
form
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#endif
|
||||
|
||||
#else
|
||||
VStack {
|
||||
Group {
|
||||
header
|
||||
form
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
}
|
||||
.onAppear {
|
||||
guard editing else {
|
||||
return
|
||||
}
|
||||
|
||||
self.name = self.playlist.title
|
||||
self.visibility = self.playlist.visibility
|
||||
|
||||
validate()
|
||||
}
|
||||
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(.thickMaterial)
|
||||
#endif
|
||||
}
|
||||
.onChange(of: name) { _ in validate() }
|
||||
.onAppear(perform: initializeForm)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -152,16 +142,16 @@ struct PlaylistFormView: View {
|
||||
#endif
|
||||
|
||||
func initializeForm() {
|
||||
focused = true
|
||||
|
||||
guard editing else {
|
||||
return
|
||||
}
|
||||
|
||||
name = playlist.title
|
||||
visibility = playlist.visibility
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
name = playlist.title
|
||||
visibility = playlist.visibility
|
||||
|
||||
validate()
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
@@ -182,7 +172,7 @@ struct PlaylistFormView: View {
|
||||
|
||||
playlists.load(force: true)
|
||||
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +184,7 @@ struct PlaylistFormView: View {
|
||||
#if os(macOS)
|
||||
Picker("Visibility", selection: $visibility) {
|
||||
ForEach(Playlist.Visibility.allCases) { visibility in
|
||||
Text(visibility.name)
|
||||
Text(visibility.name).tag(visibility)
|
||||
}
|
||||
}
|
||||
#else
|
||||
@@ -216,9 +206,10 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
var deletePlaylistButton: some View {
|
||||
Button("Delete", role: .destructive) {
|
||||
Button("Delete") {
|
||||
showingDeleteConfirmation = true
|
||||
}.alert(isPresented: $showingDeleteConfirmation) {
|
||||
}
|
||||
.alert(isPresented: $showingDeleteConfirmation) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to delete playlist?"),
|
||||
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
|
||||
@@ -226,16 +217,14 @@ struct PlaylistFormView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
#if os(macOS)
|
||||
.foregroundColor(.red)
|
||||
#endif
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,30 +58,39 @@ struct PlaylistsView: View {
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
#else
|
||||
.sheet(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
.sheet(isPresented: $showingEditPlaylist, onDismiss: selectEditedPlaylist) {
|
||||
PlaylistFormView(playlist: $editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
.background(
|
||||
EmptyView()
|
||||
.sheet(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
)
|
||||
.background(
|
||||
EmptyView()
|
||||
.sheet(isPresented: $showingEditPlaylist, onDismiss: selectEditedPlaylist) {
|
||||
PlaylistFormView(playlist: $editedPlaylist)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
#if !os(iOS)
|
||||
if !model.isEmpty {
|
||||
selectPlaylistButton
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
if #available(macOS 12.0, *) {
|
||||
selectPlaylistButton
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
} else {
|
||||
selectPlaylistButton
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
#endif
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
|
||||
|
||||
newPlaylistButton
|
||||
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@@ -99,6 +108,8 @@ struct PlaylistsView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
newPlaylistButton
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
@@ -168,7 +179,7 @@ struct PlaylistsView: View {
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
#if os(macOS)
|
||||
.background()
|
||||
.background(Color.tertiaryBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
99
Shared/Search/SearchField.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchTextField: View {
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
@Binding var favoriteItem: FavoriteItem?
|
||||
|
||||
init(favoriteItem: Binding<FavoriteItem?>? = nil) {
|
||||
_favoriteItem = favoriteItem ?? .constant(nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
#if os(macOS)
|
||||
fieldBorder
|
||||
#endif
|
||||
|
||||
HStack(spacing: 0) {
|
||||
#if os(macOS)
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 12, height: 12)
|
||||
.padding(.horizontal, 8)
|
||||
.opacity(0.8)
|
||||
#endif
|
||||
TextField("Search...", text: $state.queryText) {
|
||||
state.changeQuery { query in query.query = state.queryText }
|
||||
recents.addQuery(state.queryText)
|
||||
}
|
||||
.onChange(of: state.queryText) { _ in
|
||||
if state.query.query.compare(state.queryText, options: .caseInsensitive) == .orderedSame {
|
||||
state.fieldIsFocused = true
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 190)
|
||||
.textFieldStyle(.plain)
|
||||
#else
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.leading)
|
||||
.padding(.trailing, 15)
|
||||
#endif
|
||||
|
||||
if !self.state.queryText.isEmpty {
|
||||
#if os(iOS)
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(.trailing)
|
||||
#endif
|
||||
clearButton
|
||||
} else {
|
||||
#if os(macOS)
|
||||
clearButton
|
||||
.opacity(0)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, navigationStyle == .tab ? 10 : 0)
|
||||
}
|
||||
|
||||
private var fieldBorder: some View {
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.fill(Color.background)
|
||||
.frame(width: 250, height: 32)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.stroke(
|
||||
state.fieldIsFocused ? Color.blue.opacity(0.7) : Color.gray.opacity(0.4),
|
||||
lineWidth: state.fieldIsFocused ? 3 : 1
|
||||
)
|
||||
.frame(width: 250, height: 31)
|
||||
)
|
||||
}
|
||||
|
||||
private var clearButton: some View {
|
||||
Button(action: {
|
||||
self.state.queryText = ""
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
#if os(macOS)
|
||||
.frame(width: 14, height: 14)
|
||||
#else
|
||||
.frame(width: 18, height: 18)
|
||||
#endif
|
||||
.padding(.trailing, 3)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(.trailing, 10)
|
||||
.opacity(0.7)
|
||||
}
|
||||
}
|
||||
82
Shared/Search/SearchSuggestions.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchSuggestions: View {
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Button {
|
||||
state.changeQuery { query in
|
||||
query.query = state.queryText
|
||||
state.fieldIsFocused = false
|
||||
}
|
||||
|
||||
recents.addQuery(state.queryText)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Text(state.queryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
|
||||
ForEach(visibleSuggestions, id: \.self) { suggestion in
|
||||
Button {
|
||||
state.queryText = suggestion
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.left.circle")
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 0) {
|
||||
Text(state.suggestionsText)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(querySuffix(suggestion))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.onHover(perform: onHover(_:))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.link)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var visibleSuggestions: [String] {
|
||||
state.querySuggestions.collection.filter {
|
||||
$0.compare(state.queryText, options: .caseInsensitive) != .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
private func querySuffix(_ suggestion: String) -> String {
|
||||
suggestion.replacingFirstOccurrence(of: state.suggestionsText.lowercased(), with: "")
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func onHover(_ inside: Bool) {
|
||||
if inside {
|
||||
NSCursor.pointingHand.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct SearchSuggestions_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SearchSuggestions()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ struct SearchView: View {
|
||||
@State private var searchDate = SearchQuery.Date.any
|
||||
@State private var searchDuration = SearchQuery.Duration.any
|
||||
|
||||
@State private var presentingClearConfirmation = false
|
||||
@State private var recentsChanged = false
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -24,6 +23,9 @@ struct SearchView: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
private var favorites = FavoritesModel.shared
|
||||
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
|
||||
private var videos = [Video]()
|
||||
|
||||
@@ -31,59 +33,46 @@ struct SearchView: View {
|
||||
state.store.collection.sorted { $0 < $1 }
|
||||
}
|
||||
|
||||
init(_ query: SearchQuery? = nil, videos: [Video] = [Video]()) {
|
||||
init(_ query: SearchQuery? = nil, videos: [Video] = []) {
|
||||
self.query = query
|
||||
self.videos = videos
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
VStack {
|
||||
if showRecentQueries {
|
||||
recentQueries
|
||||
} else {
|
||||
#if os(tvOS)
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
if accounts.app.supportsSearchFilters {
|
||||
filtersHorizontalStack
|
||||
}
|
||||
#if os(iOS)
|
||||
VStack {
|
||||
SearchTextField(favoriteItem: $favoriteItem)
|
||||
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: 25))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalCells(items: items)
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
#endif
|
||||
|
||||
if noResults {
|
||||
Text("No results")
|
||||
|
||||
if searchFiltersActive {
|
||||
Button("Reset search filters", action: resetFilters)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
if state.query.query != state.queryText, !state.queryText.isEmpty, !state.querySuggestions.collection.isEmpty {
|
||||
SearchSuggestions()
|
||||
} else {
|
||||
results
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
ZStack {
|
||||
results
|
||||
|
||||
#if !os(tvOS)
|
||||
if state.query.query != state.queryText, !state.queryText.isEmpty, !state.querySuggestions.collection.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
SearchSuggestions()
|
||||
.borderLeading(width: 1, color: Color("ControlsBorderColor"))
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.toolbar {
|
||||
#if !os(tvOS)
|
||||
ToolbarItemGroup(placement: toolbarPlacement) {
|
||||
#if os(macOS)
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
}
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
#endif
|
||||
|
||||
if accounts.app.supportsSearchFilters {
|
||||
@@ -104,20 +93,13 @@ struct SearchView: View {
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
Spacer()
|
||||
|
||||
if let favoriteItem = favoriteItem {
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem.id)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
#endif
|
||||
|
||||
if accounts.app.supportsSearchFilters {
|
||||
filtersMenu
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
SearchTextField()
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -132,10 +114,11 @@ struct SearchView: View {
|
||||
state.store.replace(ContentItem.array(of: videos))
|
||||
}
|
||||
}
|
||||
.searchable(text: $state.queryText, placement: searchFieldPlacement) {
|
||||
ForEach(state.querySuggestions.collection, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
.onChange(of: state.query.query) { newQuery in
|
||||
if newQuery.isEmpty {
|
||||
favoriteItem = nil
|
||||
} else {
|
||||
updateFavoriteItem()
|
||||
}
|
||||
}
|
||||
.onChange(of: state.queryText) { newQuery in
|
||||
@@ -161,11 +144,7 @@ struct SearchView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
state.changeQuery { query in query.query = state.queryText }
|
||||
recents.addQuery(state.queryText)
|
||||
updateFavoriteItem()
|
||||
}
|
||||
|
||||
.onChange(of: searchSortOrder) { order in
|
||||
state.changeQuery { query in
|
||||
query.sortBy = order
|
||||
@@ -184,46 +163,88 @@ struct SearchView: View {
|
||||
updateFavoriteItem()
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("Search")
|
||||
#endif
|
||||
}
|
||||
|
||||
var searchFieldPlacement: SearchFieldPlacement {
|
||||
#if os(iOS)
|
||||
.navigationBarDrawer(displayMode: .always)
|
||||
#if os(tvOS)
|
||||
.searchable(text: $state.queryText) {
|
||||
ForEach(state.querySuggestions.collection, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
}
|
||||
}
|
||||
#else
|
||||
.automatic
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.navigationTitle("Search")
|
||||
#endif
|
||||
}
|
||||
|
||||
var toolbarPlacement: ToolbarItemPlacement {
|
||||
#if os(iOS)
|
||||
.bottomBar
|
||||
#else
|
||||
.automatic
|
||||
.navigationBarHidden(!Defaults[.visibleSections].isEmpty || navigationStyle == .sidebar)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
fileprivate var showRecentQueries: Bool {
|
||||
navigationStyle == .tab && state.queryText.isEmpty
|
||||
private var results: some View {
|
||||
VStack {
|
||||
if showRecentQueries {
|
||||
recentQueries
|
||||
} else {
|
||||
#if os(tvOS)
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
if accounts.app.supportsSearchFilters {
|
||||
filtersHorizontalStack
|
||||
}
|
||||
|
||||
FavoriteButton(item: favoriteItem)
|
||||
.id(favoriteItem?.id)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: 25))
|
||||
}
|
||||
|
||||
HorizontalCells(items: items)
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#else
|
||||
VerticalCells(items: items)
|
||||
#endif
|
||||
|
||||
if noResults {
|
||||
Text("No results")
|
||||
|
||||
if searchFiltersActive {
|
||||
Button("Reset search filters", action: resetFilters)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var filtersActive: Bool {
|
||||
private var toolbarPlacement: ToolbarItemPlacement {
|
||||
#if os(iOS)
|
||||
accounts.app.supportsSearchFilters || favorites.isEnabled ? .bottomBar : .automatic
|
||||
#else
|
||||
.automatic
|
||||
#endif
|
||||
}
|
||||
|
||||
private var showRecentQueries: Bool {
|
||||
navigationStyle == .tab && saveRecents && state.queryText.isEmpty
|
||||
}
|
||||
|
||||
private var filtersActive: Bool {
|
||||
searchDuration != .any || searchDate != .any
|
||||
}
|
||||
|
||||
fileprivate func resetFilters() {
|
||||
private func resetFilters() {
|
||||
searchSortOrder = .relevance
|
||||
searchDate = .any
|
||||
searchDuration = .any
|
||||
}
|
||||
|
||||
fileprivate var noResults: Bool {
|
||||
private var noResults: Bool {
|
||||
items.isEmpty && !state.isLoading && !state.query.isEmpty
|
||||
}
|
||||
|
||||
var recentQueries: some View {
|
||||
private var recentQueries: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section(header: Text("Recents")) {
|
||||
@@ -237,22 +258,13 @@ struct SearchView: View {
|
||||
state.changeQuery { query in query.query = item.title }
|
||||
updateFavoriteItem()
|
||||
}
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing) {
|
||||
deleteButton(item)
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
.contextMenu {
|
||||
deleteButton(item)
|
||||
deleteAllButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.redrawOn(change: recentsChanged)
|
||||
|
||||
if !recentItems.isEmpty {
|
||||
clearAllButton
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
@@ -260,37 +272,33 @@ struct SearchView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
func deleteButton(_ item: RecentItem) -> some View {
|
||||
Button(role: .destructive) {
|
||||
recents.close(item)
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var clearAllButton: some View {
|
||||
Button("Clear All", role: .destructive) {
|
||||
presentingClearConfirmation = true
|
||||
}
|
||||
.confirmationDialog("Clear All", isPresented: $presentingClearConfirmation) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
recents.clearQueries()
|
||||
}
|
||||
private func deleteButton(_ item: RecentItem) -> some View {
|
||||
Button {
|
||||
recents.close(item)
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
var searchFiltersActive: Bool {
|
||||
private var deleteAllButton: some View {
|
||||
Button {
|
||||
recents.clearQueries()
|
||||
recentsChanged.toggle()
|
||||
} label: {
|
||||
Label("Delete All", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private var searchFiltersActive: Bool {
|
||||
searchDate != .any || searchDuration != .any
|
||||
}
|
||||
|
||||
var recentItems: [RecentItem] {
|
||||
private var recentItems: [RecentItem] {
|
||||
Defaults[.recentlyOpened].filter { $0.type == .query }.reversed()
|
||||
}
|
||||
|
||||
var searchSortOrderPicker: some View {
|
||||
private var searchSortOrderPicker: some View {
|
||||
Picker("Sort", selection: $searchSortOrder) {
|
||||
ForEach(SearchQuery.SortOrder.allCases) { sortOrder in
|
||||
Text(sortOrder.name).tag(sortOrder)
|
||||
@@ -299,7 +307,7 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
var searchSortOrderButton: some View {
|
||||
private var searchSortOrderButton: some View {
|
||||
Button(action: { self.searchSortOrder = self.searchSortOrder.next() }) { Text(self.searchSortOrder.name)
|
||||
.font(.system(size: 30))
|
||||
.padding(.horizontal)
|
||||
@@ -315,7 +323,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var searchDateButton: some View {
|
||||
private var searchDateButton: some View {
|
||||
Button(action: { self.searchDate = self.searchDate.next() }) {
|
||||
Text(self.searchDate.name)
|
||||
.font(.system(size: 30))
|
||||
@@ -332,7 +340,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var searchDurationButton: some View {
|
||||
private var searchDurationButton: some View {
|
||||
Button(action: { self.searchDuration = self.searchDuration.next() }) {
|
||||
Text(self.searchDuration.name)
|
||||
.font(.system(size: 30))
|
||||
@@ -349,7 +357,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var filtersHorizontalStack: some View {
|
||||
private var filtersHorizontalStack: some View {
|
||||
HStack {
|
||||
HStack(spacing: 30) {
|
||||
Text("Sort")
|
||||
@@ -375,7 +383,7 @@ struct SearchView: View {
|
||||
.font(.system(size: 30))
|
||||
}
|
||||
#else
|
||||
var filtersMenu: some View {
|
||||
private var filtersMenu: some View {
|
||||
Menu(filtersActive ? "Filter: active" : "Filter") {
|
||||
Picker(selection: $searchDuration, label: Text("Duration")) {
|
||||
ForEach(SearchQuery.Duration.allCases) { duration in
|
||||
@@ -15,9 +15,8 @@ struct AccountForm: View {
|
||||
@State private var validationError: String?
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -32,7 +31,7 @@ struct AccountForm: View {
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(.thickMaterial)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.frame(width: 400, height: 145)
|
||||
#endif
|
||||
@@ -46,7 +45,7 @@ struct AccountForm: View {
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
@@ -68,7 +67,6 @@ struct AccountForm: View {
|
||||
formFields
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: initializeForm)
|
||||
.onChange(of: username) { _ in validate() }
|
||||
.onChange(of: password) { _ in validate() }
|
||||
}
|
||||
@@ -76,24 +74,23 @@ struct AccountForm: View {
|
||||
var formFields: some View {
|
||||
Group {
|
||||
if !instance.app.accountsUsePassword {
|
||||
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
|
||||
.focused($focused)
|
||||
TextField("Name", text: $name)
|
||||
}
|
||||
|
||||
TextField("Username", text: $username, prompt: usernamePrompt)
|
||||
TextField(usernamePrompt, text: $username)
|
||||
|
||||
if instance.app.accountsUsePassword {
|
||||
SecureField("Password", text: $password, prompt: Text("Password"))
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var usernamePrompt: Text {
|
||||
var usernamePrompt: String {
|
||||
switch instance.app {
|
||||
case .invidious:
|
||||
return Text("SID Cookie")
|
||||
return "SID Cookie"
|
||||
default:
|
||||
return Text("Username")
|
||||
return "Username"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,10 +118,6 @@ struct AccountForm: View {
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func initializeForm() {
|
||||
focused = true
|
||||
}
|
||||
|
||||
private func validate() {
|
||||
isValid = false
|
||||
validationDebounce.invalidate()
|
||||
@@ -151,7 +144,7 @@ struct AccountForm: View {
|
||||
let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
|
||||
selectedAccount?.wrappedValue = account
|
||||
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
|
||||
private var validator: AccountValidator {
|
||||
|
||||
31
Shared/Settings/AccountsNavigationLink.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountsNavigationLink: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
var instance: Instance
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(instance.longDescription) {
|
||||
InstanceSettings(instanceID: instance.id)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
removeInstanceButton(instance)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInstanceButton(_ instance: Instance) -> some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
return Button("Remove", role: .destructive) { removeAction(instance) }
|
||||
} else {
|
||||
return Button("Remove") { removeAction(instance) }
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAction(_ instance: Instance) {
|
||||
if accounts.current?.instance == instance {
|
||||
accounts.setCurrent(nil)
|
||||
}
|
||||
InstancesModel.remove(instance)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountsSettings: View {
|
||||
let instanceID: Instance.ID?
|
||||
|
||||
@State private var accountsChanged = false
|
||||
@State private var presentingAccountForm = false
|
||||
|
||||
@State private var frontendURL = ""
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var model
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
var instance: Instance! {
|
||||
InstancesModel.find(instanceID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if instance.app.hasFrontendURL {
|
||||
Section(header: Text("Frontend URL")) {
|
||||
TextField(
|
||||
"Frontend URL",
|
||||
text: $frontendURL,
|
||||
prompt: Text("To enable videos, channels and playlists sharing")
|
||||
)
|
||||
.onAppear {
|
||||
frontendURL = instance.frontendURL ?? ""
|
||||
}
|
||||
.onChange(of: frontendURL) { newValue in
|
||||
InstancesModel.setFrontendURL(instance, newValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Accounts"), footer: sectionFooter) {
|
||||
if instance.app.supportsAccounts {
|
||||
accounts
|
||||
} else {
|
||||
Text("Accounts are not supported for the application of this instance")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#endif
|
||||
|
||||
.navigationTitle(instance.description)
|
||||
}
|
||||
|
||||
var accounts: some View {
|
||||
Group {
|
||||
ForEach(InstancesModel.accounts(instanceID), id: \.self) { account in
|
||||
#if os(tvOS)
|
||||
Button(account.description) {}
|
||||
.contextMenu {
|
||||
Button("Remove", role: .destructive) { removeAccount(account) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#else
|
||||
Text(account.description)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button("Remove", role: .destructive) { removeAccount(account) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.redrawOn(change: accountsChanged)
|
||||
|
||||
Button("Add account...") {
|
||||
presentingAccountForm = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
||||
AccountForm(instance: instance)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sectionFooter: some View {
|
||||
if !instance.app.supportsAccounts {
|
||||
return Text("")
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
return Text("Swipe to remove account")
|
||||
#else
|
||||
return Text("Tap and hold to remove account")
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func removeAccount(_ account: Account) {
|
||||
AccountsModel.remove(account)
|
||||
accountsChanged.toggle()
|
||||
}
|
||||
}
|
||||
@@ -4,42 +4,102 @@ import SwiftUI
|
||||
struct BrowsingSettings: View {
|
||||
@Default(.channelOnThumbnail) private var channelOnThumbnail
|
||||
@Default(.timeOnThumbnail) private var timeOnThumbnail
|
||||
#if os(iOS)
|
||||
@Default(.tabNavigationSection) private var tabNavigationSection
|
||||
#endif
|
||||
@Default(.saveRecents) private var saveRecents
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.visibleSections) private var visibleSections
|
||||
|
||||
var body: some View {
|
||||
Section(header: SettingsHeader(text: "Browsing"), footer: footer) {
|
||||
Toggle("Display channel names on thumbnails", isOn: $channelOnThumbnail)
|
||||
Toggle("Display video length on thumbnails", isOn: $timeOnThumbnail)
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Browsing")) {
|
||||
Toggle("Show channel name on thumbnail", isOn: $channelOnThumbnail)
|
||||
Toggle("Show video length on thumbnail", isOn: $timeOnThumbnail)
|
||||
Toggle("Save recent queries and channels", isOn: $saveRecents)
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
}
|
||||
Section(header: SettingsHeader(text: "Sections")) {
|
||||
#if os(macOS)
|
||||
let list = ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
preferredTabPicker
|
||||
#endif
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
}
|
||||
|
||||
var footer: some View {
|
||||
#if os(iOS)
|
||||
Text("This tab will be displayed when there is no space to display all tabs")
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var preferredTabPicker: some View {
|
||||
Picker("Preferred tab", selection: $tabNavigationSection) {
|
||||
Text("Trending").tag(TabNavigationSectionSetting.trending)
|
||||
Text("Popular").tag(TabNavigationSectionSetting.popular)
|
||||
Spacer()
|
||||
}
|
||||
#else
|
||||
ForEach(VisibleSection.allCases, id: \.self) { section in
|
||||
VisibleSectionSelectionRow(
|
||||
title: section.title,
|
||||
selected: visibleSections.contains(section)
|
||||
) { value in
|
||||
toggleSection(section, value: value)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func toggleSection(_ section: VisibleSection, value: Bool) {
|
||||
if value {
|
||||
visibleSections.insert(section)
|
||||
} else {
|
||||
visibleSections.remove(section)
|
||||
}
|
||||
}
|
||||
|
||||
struct VisibleSectionSelectionRow: View {
|
||||
let title: String
|
||||
let selected: Bool
|
||||
var action: (Bool) -> Void
|
||||
|
||||
@State private var toggleChecked = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: { action(!selected) }) {
|
||||
HStack {
|
||||
#if os(macOS)
|
||||
Toggle(isOn: $toggleChecked) {
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
}
|
||||
.onAppear {
|
||||
toggleChecked = selected
|
||||
}
|
||||
.onChange(of: toggleChecked) { new in
|
||||
action(new)
|
||||
}
|
||||
#else
|
||||
Text(self.title)
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
#if os(iOS)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowsingSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -13,9 +13,8 @@ struct InstanceForm: View {
|
||||
@State private var validationError: String?
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@FocusState private var nameFieldFocused: Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -30,12 +29,11 @@ struct InstanceForm: View {
|
||||
}
|
||||
.onChange(of: app) { _ in validate() }
|
||||
.onChange(of: url) { _ in validate() }
|
||||
.onAppear(perform: initializeForm)
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#elseif os(tvOS)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(.thickMaterial)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.frame(width: 400, height: 190)
|
||||
#endif
|
||||
@@ -49,7 +47,7 @@ struct InstanceForm: View {
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
@@ -79,11 +77,11 @@ struct InstanceForm: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
|
||||
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
|
||||
.focused($nameFieldFocused)
|
||||
TextField("Name", text: $name)
|
||||
|
||||
TextField("API URL", text: $url, prompt: Text("https://invidious.home.net"))
|
||||
TextField("API URL", text: $url)
|
||||
|
||||
#if !os(macOS)
|
||||
.autocapitalization(.none)
|
||||
@@ -138,10 +136,6 @@ struct InstanceForm: View {
|
||||
}
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
nameFieldFocused = true
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard isValid else {
|
||||
return
|
||||
@@ -149,7 +143,7 @@ struct InstanceForm: View {
|
||||
|
||||
savedInstanceID = InstancesModel.add(app: app, name: name, url: url).id
|
||||
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
Shared/Settings/InstanceSettings.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceSettings: View {
|
||||
let instanceID: Instance.ID?
|
||||
|
||||
@State private var accountsChanged = false
|
||||
@State private var presentingAccountForm = false
|
||||
|
||||
@State private var frontendURL = ""
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var model
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
var instance: Instance! {
|
||||
InstancesModel.find(instanceID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if instance.app.hasFrontendURL {
|
||||
Section(header: Text("Frontend URL")) {
|
||||
TextField(
|
||||
"Frontend URL",
|
||||
text: $frontendURL
|
||||
)
|
||||
.onAppear {
|
||||
frontendURL = instance.frontendURL ?? ""
|
||||
}
|
||||
.onChange(of: frontendURL) { newValue in
|
||||
InstancesModel.setFrontendURL(instance, newValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Accounts"), footer: sectionFooter) {
|
||||
if instance.app.supportsAccounts {
|
||||
ForEach(InstancesModel.accounts(instanceID), id: \.self) { account in
|
||||
#if os(tvOS)
|
||||
Button(account.description) {}
|
||||
.contextMenu {
|
||||
Button("Remove") { removeAccount(account) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#else
|
||||
ZStack {
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
EmptyView()
|
||||
}
|
||||
.disabled(true)
|
||||
.hidden()
|
||||
|
||||
HStack {
|
||||
Text(account.description)
|
||||
Spacer()
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Remove") { removeAccount(account) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.redrawOn(change: accountsChanged)
|
||||
|
||||
Button("Add account...") {
|
||||
presentingAccountForm = true
|
||||
}
|
||||
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
||||
AccountForm(instance: instance)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
} else {
|
||||
Text("Accounts are not supported for the application of this instance")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: 1000)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
|
||||
.navigationTitle(instance.description)
|
||||
}
|
||||
|
||||
private var sectionFooter: some View {
|
||||
if !instance.app.supportsAccounts {
|
||||
return Text("")
|
||||
}
|
||||
|
||||
return Text("Tap and hold to remove account")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private func removeAccount(_ account: Account) {
|
||||
AccountsModel.remove(account)
|
||||
accountsChanged.toggle()
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct InstancesSettings: View {
|
||||
@Default(.instances) private var instances
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
|
||||
@State private var selectedInstanceID: Instance.ID?
|
||||
@State private var selectedAccount: Account?
|
||||
|
||||
@State private var presentingInstanceForm = false
|
||||
@State private var savedFormInstanceID: Instance.ID?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
Section(header: SettingsHeader(text: "Instances")) {
|
||||
ForEach(instances) { instance in
|
||||
Group {
|
||||
NavigationLink(instance.longDescription) {
|
||||
AccountsSettings(instanceID: instance.id)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
removeInstanceButton(instance)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#else
|
||||
.contextMenu {
|
||||
removeInstanceButton(instance)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
addInstanceButton
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $presentingInstanceForm) {
|
||||
InstanceForm(savedInstanceID: $savedFormInstanceID)
|
||||
}
|
||||
}
|
||||
|
||||
private var addInstanceButton: some View {
|
||||
Button("Add Instance...") {
|
||||
presentingInstanceForm = true
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInstanceButton(_ instance: Instance) -> some View {
|
||||
Button("Remove", role: .destructive) {
|
||||
if accounts.current?.instance == instance {
|
||||
accounts.setCurrent(nil)
|
||||
}
|
||||
InstancesModel.remove(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InstancesSettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
InstancesSettings()
|
||||
}
|
||||
.frame(width: 400, height: 270)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
|
||||
keywordsToggle
|
||||
saveHistoryToggle
|
||||
}
|
||||
#else
|
||||
Section(header: SettingsHeader(text: "Source")) {
|
||||
@@ -45,7 +44,6 @@ struct PlaybackSettings: View {
|
||||
#endif
|
||||
|
||||
keywordsToggle
|
||||
saveHistoryToggle
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -59,7 +57,7 @@ struct PlaybackSettings: View {
|
||||
Text("Best available stream").tag(String?.none)
|
||||
|
||||
ForEach(instances) { instance in
|
||||
Text(instance.longDescription).tag(Optional(instance.id))
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
@@ -109,10 +107,6 @@ struct PlaybackSettings: View {
|
||||
private var keywordsToggle: some View {
|
||||
Toggle("Show video keywords", isOn: $showKeywords)
|
||||
}
|
||||
|
||||
private var saveHistoryToggle: some View {
|
||||
Toggle("Save history of played videos", isOn: $saveHistory)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -4,13 +4,25 @@ import SwiftUI
|
||||
struct ServicesSettings: View {
|
||||
@Default(.sponsorBlockInstance) private var sponsorBlockInstance
|
||||
@Default(.sponsorBlockCategories) private var sponsorBlockCategories
|
||||
@Default(.commentsInstanceID) private var commentsInstanceID
|
||||
|
||||
#if !os(tvOS)
|
||||
@Default(.commentsPlacement) private var commentsPlacement
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Section(header: SettingsHeader(text: "Comments")) {
|
||||
commentsInstancePicker
|
||||
#if !os(tvOS)
|
||||
commentsPlacementPicker
|
||||
.disabled(!CommentsModel.enabled)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: SettingsHeader(text: "SponsorBlock API")) {
|
||||
TextField(
|
||||
"SponsorBlock API Instance",
|
||||
text: $sponsorBlockInstance,
|
||||
prompt: Text("SponsorBlock API URL, leave blank to disable")
|
||||
text: $sponsorBlockInstance
|
||||
)
|
||||
.labelsHidden()
|
||||
#if !os(macOS)
|
||||
@@ -21,7 +33,7 @@ struct ServicesSettings: View {
|
||||
|
||||
Section(header: SettingsHeader(text: "Categories to Skip")) {
|
||||
#if os(macOS)
|
||||
List(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
let list = ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
SponsorBlockCategorySelectionRow(
|
||||
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
|
||||
selected: sponsorBlockCategories.contains(category)
|
||||
@@ -29,7 +41,16 @@ struct ServicesSettings: View {
|
||||
toggleCategory(category, value: value)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
|
||||
Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
#else
|
||||
ForEach(SponsorBlockAPI.categories, id: \.self) { category in
|
||||
@@ -44,6 +65,35 @@ struct ServicesSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var commentsInstancePicker: some View {
|
||||
Picker("Source", selection: $commentsInstanceID) {
|
||||
Text("Disabled").tag(Optional(""))
|
||||
|
||||
ForEach(InstancesModel.all.filter { $0.app.supportsComments }) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#elseif os(tvOS)
|
||||
.pickerStyle(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var commentsPlacementPicker: some View {
|
||||
Picker("Placement", selection: $commentsPlacement) {
|
||||
Text("Below video description").tag(CommentsPlacement.info)
|
||||
Text("Separate tab").tag(CommentsPlacement.separate)
|
||||
}
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
func toggleCategory(_ category: String, value: Bool) {
|
||||
if let index = sponsorBlockCategories.firstIndex(where: { $0 == category }), !value {
|
||||
sponsorBlockCategories.remove(at: index)
|
||||
|
||||
@@ -5,16 +5,23 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
#if os(macOS)
|
||||
private enum Tabs: Hashable {
|
||||
case instances, browsing, playback, services
|
||||
case instances, browsing, playback, services, updates
|
||||
}
|
||||
#endif
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
|
||||
@State private var presentingInstanceForm = false
|
||||
@State private var savedFormInstanceID: Instance.ID?
|
||||
|
||||
@Default(.instances) private var instances
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
TabView {
|
||||
@@ -39,7 +46,7 @@ struct SettingsView: View {
|
||||
PlaybackSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playback", systemImage: "play.rectangle.on.rectangle.fill")
|
||||
Label("Playback", systemImage: "play.rectangle")
|
||||
}
|
||||
.tag(Tabs.playback)
|
||||
|
||||
@@ -47,9 +54,17 @@ struct SettingsView: View {
|
||||
ServicesSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Services", systemImage: "puzzlepiece.extension")
|
||||
Label("Services", systemImage: "puzzlepiece")
|
||||
}
|
||||
.tag(Tabs.services)
|
||||
|
||||
Form {
|
||||
UpdatesSettings()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Updates", systemImage: "gearshape.2")
|
||||
}
|
||||
.tag(Tabs.updates)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 400, height: 380)
|
||||
@@ -65,8 +80,14 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
InstancesSettings()
|
||||
.environmentObject(accounts)
|
||||
|
||||
Section(header: Text("Instances")) {
|
||||
ForEach(instances) { instance in
|
||||
AccountsNavigationLink(instance: instance)
|
||||
}
|
||||
addInstanceButton
|
||||
}
|
||||
|
||||
BrowsingSettings()
|
||||
PlaybackSettings()
|
||||
ServicesSettings()
|
||||
@@ -76,7 +97,7 @@ struct SettingsView: View {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
#if !os(tvOS)
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
@@ -87,11 +108,20 @@ struct SettingsView: View {
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $presentingInstanceForm) {
|
||||
InstanceForm(savedInstanceID: $savedFormInstanceID)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.background(.black)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private var addInstanceButton: some View {
|
||||
Button("Add Instance...") {
|
||||
presentingInstanceForm = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
|
||||
@@ -9,51 +9,34 @@ struct TrendingCountry: View {
|
||||
@State private var query: String = ""
|
||||
@State private var selection: Country?
|
||||
|
||||
@FocusState var countryIsFocused
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
#if os(macOS)
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
TextField("Country", text: $query, prompt: Text(TrendingCountry.prompt))
|
||||
.focused($countryIsFocused)
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
TextField("Country", text: $query, prompt: Text(TrendingCountry.prompt))
|
||||
} else {
|
||||
TextField(TrendingCountry.prompt, text: $query)
|
||||
}
|
||||
|
||||
Button("Done") { selectCountryAndDismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding([.horizontal, .top])
|
||||
|
||||
countriesList
|
||||
#else
|
||||
NavigationView {
|
||||
countriesList
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button("Done") { selectCountryAndDismiss() }
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitle("Trending Country", displayMode: .automatic)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
countriesList
|
||||
}
|
||||
.onAppear {
|
||||
countryIsFocused = true
|
||||
}
|
||||
.onSubmit { selectCountryAndDismiss() }
|
||||
#if !os(macOS)
|
||||
.searchable(text: $query, placement: searchPlacement, prompt: Text(TrendingCountry.prompt))
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
.background(.thinMaterial)
|
||||
.searchable(text: $query, placement: .automatic, prompt: Text(TrendingCountry.prompt))
|
||||
.background(Color.black)
|
||||
#endif
|
||||
}
|
||||
|
||||
var countriesList: some View {
|
||||
ScrollViewReader { _ in
|
||||
let list = ScrollViewReader { _ in
|
||||
List(store.collection, selection: $selection) { country in
|
||||
#if os(macOS)
|
||||
Text(country.name)
|
||||
@@ -71,29 +54,29 @@ struct TrendingCountry: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
.padding(.bottom, 5)
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
var searchPlacement: SearchFieldPlacement {
|
||||
#if os(iOS)
|
||||
.navigationBarDrawer(displayMode: .always)
|
||||
return Group {
|
||||
#if os(macOS)
|
||||
if #available(macOS 12.0, *) {
|
||||
list
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
} else {
|
||||
list
|
||||
}
|
||||
#else
|
||||
.automatic
|
||||
list
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.padding(.bottom, 5)
|
||||
#endif
|
||||
}
|
||||
|
||||
func selectCountryAndDismiss(_ country: Country? = nil) {
|
||||
if let selected = country ?? selection {
|
||||
selectedCountry = selected
|
||||
}
|
||||
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,11 +172,12 @@ struct TrendingView: View {
|
||||
}
|
||||
|
||||
#else
|
||||
Picker("Category", selection: $category) {
|
||||
Picker(category.controlLabel, selection: $category) {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Text(category.controlLabel).tag(category)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ struct VerticalCells: View {
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
#if os(macOS)
|
||||
.background()
|
||||
.background(Color.tertiaryBackground)
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct VideoCell: View {
|
||||
@Environment(\.horizontalCells) private var horizontalCells
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnails
|
||||
|
||||
@@ -38,7 +39,13 @@ struct VideoCell: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.contextMenu { VideoContextMenuView(video: video, playerNavigationLinkActive: $player.playerNavigationLinkActive) }
|
||||
.contextMenu {
|
||||
VideoContextMenuView(
|
||||
video: video,
|
||||
playerNavigationLinkActive: $player.playerNavigationLinkActive
|
||||
)
|
||||
.environmentObject(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
@@ -55,7 +62,7 @@ struct VideoCell: View {
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.background()
|
||||
.background(Color.tertiaryBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ struct ChannelPlaylistView: View {
|
||||
|
||||
@StateObject private var store = Store<ChannelPlaylist>()
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
#endif
|
||||
@@ -83,7 +85,7 @@ struct ChannelPlaylistView: View {
|
||||
.navigationTitle(playlist.title)
|
||||
|
||||
#else
|
||||
.background(.thickMaterial)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ struct ChannelVideosView: View {
|
||||
|
||||
@StateObject private var store = Store<Channel>()
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
#if os(iOS)
|
||||
@@ -43,7 +44,7 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack {
|
||||
let content = VStack {
|
||||
#if os(tvOS)
|
||||
HStack {
|
||||
Text(navigationTitle)
|
||||
@@ -65,40 +66,46 @@ struct ChannelVideosView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
#endif
|
||||
|
||||
VerticalCells(items: videos)
|
||||
|
||||
#if !os(iOS)
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
#if os(iOS)
|
||||
VerticalCells(items: videos)
|
||||
#else
|
||||
if #available(macOS 12.0, *) {
|
||||
VerticalCells(items: videos)
|
||||
.prefersDefaultFocus(in: focusNamespace)
|
||||
} else {
|
||||
VerticalCells(items: videos)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.environment(\.inChannelView, true)
|
||||
#if !os(iOS)
|
||||
.focusScope(focusNamespace)
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
HStack {
|
||||
Text("**\(store.item?.subscriptionsString ?? "loading")** subscribers")
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
ShareButton(
|
||||
contentItem: contentItem,
|
||||
presentingShareSheet: $presentingShareSheet,
|
||||
shareURL: $shareURL
|
||||
)
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
HStack {
|
||||
HStack(spacing: 3) {
|
||||
Text("\(store.item?.subscriptionsString ?? "loading")")
|
||||
.fontWeight(.bold)
|
||||
Text(" subscribers")
|
||||
}
|
||||
.allowsTightening(true)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
|
||||
|
||||
subscriptionToggleButton
|
||||
subscriptionToggleButton
|
||||
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
.background(.thickMaterial)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $presentingShareSheet) {
|
||||
@@ -107,7 +114,6 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.modifier(UnsubscribeAlertModifier())
|
||||
.onAppear {
|
||||
if store.item.isNil {
|
||||
resource.addObserver(store)
|
||||
@@ -115,6 +121,20 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
|
||||
return Group {
|
||||
if #available(macOS 12.0, *) {
|
||||
content
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#endif
|
||||
#if !os(iOS)
|
||||
.focusScope(focusNamespace)
|
||||
#endif
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resource: Resource {
|
||||
|
||||
@@ -25,9 +25,20 @@ struct DetailBadge: View {
|
||||
}
|
||||
|
||||
struct DefaultStyleModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(.thinMaterial)
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content
|
||||
.background(.thinMaterial)
|
||||
} else {
|
||||
content
|
||||
#if os(tvOS)
|
||||
.background(Color.background(scheme: colorScheme))
|
||||
#else
|
||||
.background(Color.background.opacity(0.95))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct FavoriteButton: View {
|
||||
let item: FavoriteItem
|
||||
let item: FavoriteItem!
|
||||
let favorites = FavoritesModel.shared
|
||||
|
||||
@State private var isFavorite = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
favorites.toggle(item)
|
||||
isFavorite.toggle()
|
||||
} label: {
|
||||
if isFavorite {
|
||||
Label("Remove from Favorites", systemImage: "heart.fill")
|
||||
Group {
|
||||
if favorites.isEnabled {
|
||||
Button {
|
||||
guard !item.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
favorites.toggle(item)
|
||||
isFavorite.toggle()
|
||||
} label: {
|
||||
if isFavorite {
|
||||
Label("Remove from Favorites", systemImage: "heart.fill")
|
||||
} else {
|
||||
Label("Add to Favorites", systemImage: "heart")
|
||||
}
|
||||
}
|
||||
.disabled(item.isNil)
|
||||
.onAppear {
|
||||
isFavorite = item.isNil ? false : favorites.contains(item)
|
||||
}
|
||||
} else {
|
||||
Label("Add to Favorites", systemImage: "heart")
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isFavorite = favorites.contains(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpenSettingsButton: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
#if !os(macOS)
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
let button = Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
|
||||
#if os(macOS)
|
||||
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
||||
@@ -19,7 +19,13 @@ struct OpenSettingsButton: View {
|
||||
} label: {
|
||||
Label("Open Settings", systemImage: "gearshape.2")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
button
|
||||
.buttonStyle(.borderedProminent)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ struct PlayerControlsView<Content: View>: View {
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
HStack {
|
||||
let controls = HStack {
|
||||
Button(action: {
|
||||
model.presentingPlayer.toggle()
|
||||
}) {
|
||||
@@ -36,7 +36,7 @@ struct PlayerControlsView<Content: View>: View {
|
||||
.foregroundColor(model.currentItem.isNil ? .secondary : .accentColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(model.currentItem?.video?.author ?? "Yattee v\(appVersion)")
|
||||
Text(model.currentItem?.video?.author ?? "Yattee v\(appVersion) (build \(appBuild))")
|
||||
.fontWeight(model.currentItem.isNil ? .light : .bold)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
@@ -92,20 +92,35 @@ struct PlayerControlsView<Content: View>: View {
|
||||
.padding(.horizontal)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 55)
|
||||
.padding(.vertical, 0)
|
||||
.background(.ultraThinMaterial)
|
||||
.borderTop(height: 0.4, color: Color("PlayerControlsBorderColor"))
|
||||
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("PlayerControlsBorderColor"))
|
||||
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
|
||||
#if !os(tvOS)
|
||||
.onSwipeGesture(up: {
|
||||
model.presentingPlayer = true
|
||||
})
|
||||
#endif
|
||||
|
||||
return Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
controls
|
||||
.background(Material.ultraThinMaterial)
|
||||
} else {
|
||||
controls
|
||||
#if !os(tvOS)
|
||||
.background(Color.tertiaryBackground)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private var appBuild: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private var progressViewValue: Double {
|
||||
[model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ struct SignInRequiredView<Content: View>: View {
|
||||
|
||||
Group {
|
||||
if instances.isEmpty {
|
||||
Text("You need to create an instance and accounts\nto access **\(title)** section")
|
||||
Text("You need to create an instance and accounts\nto access \(title) section")
|
||||
} else {
|
||||
Text("You need to select an account\nto access **\(title)** section")
|
||||
Text("You need to select an account\nto access \(title) section")
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -31,9 +31,6 @@ struct SubscriptionsView: View {
|
||||
FavoriteButton(item: FavoriteItem(section: .subscriptions))
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
loadResources(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func loadResources(force: Bool = false) {
|
||||
|
||||
@@ -113,7 +113,7 @@ struct VideoContextMenuView: View {
|
||||
private var subscriptionButton: some View {
|
||||
Group {
|
||||
if subscriptions.isSubscribing(video.channel.id) {
|
||||
Button(role: .destructive) {
|
||||
Button {
|
||||
#if os(tvOS)
|
||||
subscriptions.unsubscribe(video.channel.id)
|
||||
#else
|
||||
@@ -143,7 +143,7 @@ struct VideoContextMenuView: View {
|
||||
}
|
||||
|
||||
func removeFromPlaylistButton(playlistID: String) -> some View {
|
||||
Button(role: .destructive) {
|
||||
Button {
|
||||
playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID)
|
||||
} label: {
|
||||
Label("Remove from playlist", systemImage: "text.badge.minus")
|
||||
|
||||
@@ -2,14 +2,14 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct WelcomeScreen: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
|
||||
@Default(.accounts) private var allAccounts
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
let welcomeScreen = VStack {
|
||||
Spacer()
|
||||
|
||||
Text("Welcome")
|
||||
@@ -26,7 +26,7 @@ struct WelcomeScreen: View {
|
||||
AccountSelectionView(showHeader: false)
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} label: {
|
||||
Text("Start")
|
||||
}
|
||||
@@ -36,7 +36,7 @@ struct WelcomeScreen: View {
|
||||
#else
|
||||
AccountsMenuView()
|
||||
.onChange(of: accounts.current) { _ in
|
||||
dismiss()
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 280)
|
||||
@@ -50,10 +50,16 @@ struct WelcomeScreen: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
welcomeScreen
|
||||
.interactiveDismissDisabled()
|
||||
} else {
|
||||
welcomeScreen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
struct YatteeApp: App {
|
||||
#if os(macOS)
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var updater = UpdaterModel()
|
||||
#endif
|
||||
|
||||
@StateObject private var menu = MenuModel()
|
||||
@@ -18,7 +19,16 @@ struct YatteeApp: App {
|
||||
.handlesExternalEvents(matching: Set(["*"]))
|
||||
.commands {
|
||||
SidebarCommands()
|
||||
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
|
||||
#if os(macOS)
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesView()
|
||||
.environmentObject(updater)
|
||||
}
|
||||
#endif
|
||||
|
||||
MenuCommands(model: Binding<MenuModel>(get: { menu }, set: { _ in }))
|
||||
}
|
||||
#endif
|
||||
@@ -28,6 +38,7 @@ struct YatteeApp: App {
|
||||
SettingsView()
|
||||
.environmentObject(AccountsModel())
|
||||
.environmentObject(InstancesModel())
|
||||
.environmentObject(updater)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"repositoryURL": "https://github.com/sindresorhus/Defaults",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "63d93f97ad545c8bceb125a8a36175ea705f7cf5",
|
||||
"version": "5.0.0"
|
||||
"revision": "55f3302c3ab30a8760f10042d0ebc0a6907f865a",
|
||||
"version": "6.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -33,7 +33,7 @@
|
||||
"repositoryURL": "https://github.com/pinterest/PINCache",
|
||||
"state": {
|
||||
"branch": "master",
|
||||
"revision": "a16dae6cec4897a7a9710239e0cb50b6de935e7b",
|
||||
"revision": "046f67609085a7d73d27105d2be91729d139208f",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
@@ -91,6 +91,15 @@
|
||||
"version": "1.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Sparkle",
|
||||
"repositoryURL": "https://github.com/sparkle-project/Sparkle",
|
||||
"state": {
|
||||
"branch": "2.x",
|
||||
"revision": "831e9b4eb7e871a9c072469fb14049614fc92810",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "swift-log",
|
||||
"repositoryURL": "https://github.com/apple/swift-log.git",
|
||||
|
||||