mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 18:54:11 +00:00
Settings for iOS/macOS
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.416",
|
||||
"green" : "0.256",
|
||||
"red" : "0.837"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
@@ -5,6 +5,8 @@ extension Defaults.Keys {
|
||||
static let layout = Key<ListingLayout>("listingLayout", default: .cells)
|
||||
#endif
|
||||
|
||||
static let instances = Key<[Instance]>("instances", default: [])
|
||||
|
||||
static let searchSortOrder = Key<SearchQuery.SortOrder>("searchSortOrder", default: .relevance)
|
||||
static let searchDate = Key<SearchQuery.Date?>("searchDate")
|
||||
static let searchDuration = Key<SearchQuery.Duration?>("searchDuration")
|
||||
@@ -14,6 +16,7 @@ extension Defaults.Keys {
|
||||
static let videoIDToAddToPlaylist = Key<String?>("videoIDToAddToPlaylist")
|
||||
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
static let quality = Key<Stream.ResolutionSetting>("quality", default: .hd720pFirstThenBest)
|
||||
}
|
||||
|
||||
enum ListingLayout: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
|
19
Shared/Modifiers/RedrawOnViewModifier.swift
Normal file
19
Shared/Modifiers/RedrawOnViewModifier.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RedrawOnViewModifier: ViewModifier {
|
||||
@State private var changeFlag: Bool
|
||||
|
||||
init(changeFlag: Bool) {
|
||||
self.changeFlag = changeFlag
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.opacity(changeFlag ? 1 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func redrawOn(change flag: Bool) -> some View {
|
||||
modifier(RedrawOnViewModifier(changeFlag: flag))
|
||||
}
|
||||
}
|
@@ -2,13 +2,13 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct UnsubscribeAlertModifier: ViewModifier {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<Subscriptions> private var subscriptions
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(unsubscribeAlertTitle, isPresented: $navigationState.presentingUnsubscribeAlert) {
|
||||
if let channel = navigationState.channelToUnsubscribe {
|
||||
.alert(unsubscribeAlertTitle, isPresented: $navigation.presentingUnsubscribeAlert) {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
Button("Unsubscribe", role: .destructive) {
|
||||
subscriptions.unsubscribe(channel.id)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ struct UnsubscribeAlertModifier: ViewModifier {
|
||||
}
|
||||
|
||||
var unsubscribeAlertTitle: String {
|
||||
if let channel = navigationState.channelToUnsubscribe {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
return "Unsubscribe from \(channel.name)"
|
||||
}
|
||||
|
||||
|
33
Shared/Navigation/AccountsMenuView.swift
Normal file
33
Shared/Navigation/AccountsMenuView.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AccountsMenuView: View {
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
@Default(.instances) private var instances
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(instances, id: \.self) { instance in
|
||||
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
|
||||
api.setAccount(instance.anonymousAccount)
|
||||
}
|
||||
|
||||
ForEach(instance.accounts, id: \.self) { account in
|
||||
Button(accountButtonTitle(instance: instance, account: account)) {
|
||||
api.setAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(api.account?.name ?? "Accounts", systemImage: "person.crop.circle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.disabled(instances.isEmpty)
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
func accountButtonTitle(instance: Instance, account: Instance.Account) -> String {
|
||||
instances.count > 1 ? "\(account.description) — \(instance.shortDescription)" : account.description
|
||||
}
|
||||
}
|
@@ -12,16 +12,18 @@ struct AppSidebarNavigation: View {
|
||||
}
|
||||
}
|
||||
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<Playlists> private var playlists
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<Recents> private var recents
|
||||
@EnvironmentObject<SearchState> private var searchState
|
||||
@EnvironmentObject<Subscriptions> private var subscriptions
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@State private var didApplyPrimaryViewWorkAround = false
|
||||
|
||||
var selection: Binding<TabSelection?> {
|
||||
navigationState.tabSelectionOptionalBinding
|
||||
navigation.tabSelectionOptionalBinding
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -30,11 +32,10 @@ struct AppSidebarNavigation: View {
|
||||
// workaround for an empty supplementary view on launch
|
||||
// the supplementary view is determined by the default selection inside the
|
||||
// primary view, but the primary view is not loaded so its selection is not read
|
||||
// We work around that by briefly showing the primary view.
|
||||
// We work around that by showing the primary view
|
||||
if !didApplyPrimaryViewWorkAround, let splitVC = viewController.children.first as? UISplitViewController {
|
||||
UIView.performWithoutAnimation {
|
||||
splitVC.show(.primary)
|
||||
splitVC.hide(.primary)
|
||||
}
|
||||
didApplyPrimaryViewWorkAround = true
|
||||
}
|
||||
@@ -44,31 +45,33 @@ struct AppSidebarNavigation: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
let sidebarMinWidth: Double = 280
|
||||
|
||||
var content: some View {
|
||||
NavigationView {
|
||||
sidebar
|
||||
.frame(minWidth: 180)
|
||||
.toolbar { toolbarContent }
|
||||
.frame(minWidth: sidebarMinWidth)
|
||||
|
||||
Text("Select section")
|
||||
}
|
||||
.environment(\.navigationStyle, .sidebar)
|
||||
.searchable(text: $searchState.queryText, placement: .sidebar) {
|
||||
ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in
|
||||
.searchable(text: $search.queryText, placement: .sidebar) {
|
||||
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
}
|
||||
}
|
||||
.onChange(of: searchState.queryText) { query in
|
||||
searchState.loadQuerySuggestions(query)
|
||||
.onChange(of: search.queryText) { query in
|
||||
search.loadSuggestions(query)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
searchState.changeQuery { query in
|
||||
query.query = searchState.queryText
|
||||
search.changeQuery { query in
|
||||
query.query = search.queryText
|
||||
}
|
||||
recents.open(RecentItem(from: search.queryText))
|
||||
|
||||
recents.open(RecentItem(from: searchState.queryText))
|
||||
|
||||
navigationState.tabSelection = .search
|
||||
navigation.tabSelection = .search
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +83,8 @@ struct AppSidebarNavigation: View {
|
||||
.id(group)
|
||||
}
|
||||
|
||||
.onChange(of: navigationState.sidebarSectionChanged) { _ in
|
||||
scrollScrollViewToItem(scrollView: scrollView, for: navigationState.tabSelection)
|
||||
.onChange(of: navigation.sidebarSectionChanged) { _ in
|
||||
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -93,13 +96,7 @@ struct AppSidebarNavigation: View {
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
ToolbarItemGroup {
|
||||
Button(action: toggleSidebar) {
|
||||
Image(systemName: "sidebar.left").help("Toggle Sidebar")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
toolbarContent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,25 +112,27 @@ struct AppSidebarNavigation: View {
|
||||
|
||||
AppSidebarRecents(selection: selection)
|
||||
.id("recentlyOpened")
|
||||
AppSidebarSubscriptions(selection: selection)
|
||||
AppSidebarPlaylists(selection: selection)
|
||||
|
||||
if api.signedIn {
|
||||
AppSidebarSubscriptions(selection: selection)
|
||||
AppSidebarPlaylists(selection: selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mainNavigationLinks: some View {
|
||||
Section("Videos") {
|
||||
NavigationLink(tag: TabSelection.watchNow, selection: selection) {
|
||||
WatchNowView()
|
||||
}
|
||||
label: {
|
||||
NavigationLink(destination: LazyView(WatchNowView()), tag: TabSelection.watchNow, selection: selection) {
|
||||
Label("Watch Now", systemImage: "play.circle")
|
||||
.accessibility(label: Text("Watch Now"))
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: selection) {
|
||||
Label("Subscriptions", systemImage: "star.circle")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
if api.signedIn {
|
||||
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: selection) {
|
||||
Label("Subscriptions", systemImage: "star.circle")
|
||||
.accessibility(label: Text("Subscriptions"))
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: selection) {
|
||||
@@ -145,11 +144,6 @@ struct AppSidebarNavigation: View {
|
||||
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
|
||||
.accessibility(label: Text("Trending"))
|
||||
}
|
||||
|
||||
NavigationLink(destination: LazyView(PlaylistsView()), tag: TabSelection.playlists, selection: selection) {
|
||||
Label("Playlists", systemImage: "list.and.film")
|
||||
.accessibility(label: Text("Playlists"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +159,36 @@ struct AppSidebarNavigation: View {
|
||||
}
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button(action: { navigation.presentingSettings = true }) {
|
||||
Image(systemName: "gearshape.2")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
ToolbarItem(placement: accountsMenuToolbarItemPlacement) {
|
||||
AccountsMenuView()
|
||||
.help(
|
||||
"Switch Instances and Accounts\n" +
|
||||
"Current Instance: \n" +
|
||||
"\(api.account?.url ?? "Not Set")\n" +
|
||||
"Current User: \(api.account?.description ?? "Not set")"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accountsMenuToolbarItemPlacement: ToolbarItemPlacement {
|
||||
#if os(iOS)
|
||||
return .bottomBar
|
||||
#else
|
||||
return .automatic
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func toggleSidebar() {
|
||||
NSApp.keyWindow?.contentViewController?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
|
||||
@@ -177,6 +201,6 @@ struct AppSidebarNavigation: View {
|
||||
|
||||
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
|
||||
|
||||
return "\(symbolName).square"
|
||||
return "\(symbolName).circle"
|
||||
}
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppSidebarPlaylists: View {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<Playlists> private var playlists
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
|
||||
@Binding var selection: TabSelection?
|
||||
|
||||
var body: some View {
|
||||
Section(header: Text("Playlists")) {
|
||||
ForEach(playlists.all) { playlist in
|
||||
ForEach(playlists.playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }) { playlist in
|
||||
NavigationLink(tag: TabSelection.playlist(playlist.id), selection: $selection) {
|
||||
LazyView(PlaylistVideosView(playlist))
|
||||
} label: {
|
||||
@@ -18,7 +18,7 @@ struct AppSidebarPlaylists: View {
|
||||
.id(playlist.id)
|
||||
.contextMenu {
|
||||
Button("Edit") {
|
||||
navigationState.presentEditPlaylistForm(playlists.find(id: playlist.id))
|
||||
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,14 @@ struct AppSidebarPlaylists: View {
|
||||
newPlaylistButton
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.onAppear {
|
||||
playlists.load()
|
||||
}
|
||||
}
|
||||
|
||||
var newPlaylistButton: some View {
|
||||
Button(action: { navigationState.presentNewPlaylistForm() }) {
|
||||
Label("New Playlist", systemImage: "plus.square")
|
||||
Button(action: { navigation.presentNewPlaylistForm() }) {
|
||||
Label("New Playlist", systemImage: "plus.circle")
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.buttonStyle(.plain)
|
||||
|
@@ -4,7 +4,7 @@ import SwiftUI
|
||||
struct AppSidebarRecents: View {
|
||||
@Binding var selection: TabSelection?
|
||||
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<Recents> private var recents
|
||||
|
||||
@Default(.recentlyOpened) private var recentItems
|
||||
@@ -18,7 +18,7 @@ struct AppSidebarRecents: View {
|
||||
switch recent.type {
|
||||
case .channel:
|
||||
RecentNavigationLink(recent: recent, selection: $selection) {
|
||||
LazyView(ChannelVideosView(Channel(id: recent.id, name: recent.title)))
|
||||
LazyView(ChannelVideosView(channel: Channel(id: recent.id, name: recent.title)))
|
||||
}
|
||||
case .query:
|
||||
RecentNavigationLink(recent: recent, selection: $selection, systemImage: "magnifyingglass") {
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AppSidebarSubscriptions: View {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<Subscriptions> private var subscriptions
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Binding var selection: TabSelection?
|
||||
|
||||
@@ -10,22 +11,25 @@ struct AppSidebarSubscriptions: View {
|
||||
Section(header: Text("Subscriptions")) {
|
||||
ForEach(subscriptions.all) { channel in
|
||||
NavigationLink(tag: TabSelection.channel(channel.id), selection: $selection) {
|
||||
LazyView(ChannelVideosView(channel))
|
||||
LazyView(ChannelVideosView(channel: channel))
|
||||
} label: {
|
||||
Label(channel.name, systemImage: AppSidebarNavigation.symbolSystemImage(channel.name))
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Unsubscribe") {
|
||||
navigationState.presentUnsubscribeAlert(channel)
|
||||
navigation.presentUnsubscribeAlert(channel)
|
||||
}
|
||||
}
|
||||
.modifier(UnsubscribeAlertModifier())
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
subscriptions.load()
|
||||
}
|
||||
}
|
||||
|
||||
var unsubscribeAlertTitle: String {
|
||||
if let channel = navigationState.channelToUnsubscribe {
|
||||
if let channel = navigation.channelToUnsubscribe {
|
||||
return "Unsubscribe from \(channel.name)"
|
||||
}
|
||||
|
||||
|
@@ -2,14 +2,15 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AppTabNavigation: View {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<SearchState> private var searchState
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@EnvironmentObject<Recents> private var recents
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $navigationState.tabSelection) {
|
||||
TabView(selection: $navigation.tabSelection) {
|
||||
NavigationView {
|
||||
WatchNowView()
|
||||
LazyView(WatchNowView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Watch Now", systemImage: "play.circle")
|
||||
@@ -18,7 +19,8 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.watchNow)
|
||||
|
||||
NavigationView {
|
||||
SubscriptionsView()
|
||||
LazyView(SubscriptionsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Subscriptions", systemImage: "star.circle.fill")
|
||||
@@ -29,7 +31,8 @@ struct AppTabNavigation: View {
|
||||
// TODO: reenable with settings
|
||||
// ============================
|
||||
// NavigationView {
|
||||
// PopularView()
|
||||
// LazyView(PopularView())
|
||||
// .toolbar { toolbarContent }
|
||||
// }
|
||||
// .tabItem {
|
||||
// Label("Popular", systemImage: "chart.bar")
|
||||
@@ -38,7 +41,8 @@ struct AppTabNavigation: View {
|
||||
// .tag(TabSelection.popular)
|
||||
|
||||
NavigationView {
|
||||
TrendingView()
|
||||
LazyView(TrendingView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
|
||||
@@ -47,7 +51,8 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.trending)
|
||||
|
||||
NavigationView {
|
||||
PlaylistsView()
|
||||
LazyView(PlaylistsView())
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playlists", systemImage: "list.and.film")
|
||||
@@ -56,25 +61,28 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.playlists)
|
||||
|
||||
NavigationView {
|
||||
SearchView()
|
||||
.searchable(text: $searchState.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
|
||||
ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in
|
||||
Text(suggestion)
|
||||
.searchCompletion(suggestion)
|
||||
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: searchState.queryText) { query in
|
||||
searchState.loadQuerySuggestions(query)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
searchState.changeQuery { query in
|
||||
query.query = searchState.queryText
|
||||
.onChange(of: search.queryText) { query in
|
||||
search.loadSuggestions(query)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
search.changeQuery { query in
|
||||
query.query = search.queryText
|
||||
}
|
||||
|
||||
recents.open(RecentItem(from: searchState.queryText))
|
||||
recents.open(RecentItem(from: search.queryText))
|
||||
|
||||
navigationState.tabSelection = .search
|
||||
}
|
||||
navigation.tabSelection = .search
|
||||
}
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
@@ -83,7 +91,7 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.search)
|
||||
}
|
||||
.environment(\.navigationStyle, .tab)
|
||||
.sheet(isPresented: $navigationState.isChannelOpen, onDismiss: {
|
||||
.sheet(isPresented: $navigation.isChannelOpen, onDismiss: {
|
||||
if let channel = recents.presentedChannel {
|
||||
let recent = RecentItem(from: channel)
|
||||
recents.close(recent)
|
||||
@@ -91,10 +99,26 @@ struct AppTabNavigation: View {
|
||||
}) {
|
||||
if recents.presentedChannel != nil {
|
||||
NavigationView {
|
||||
ChannelVideosView(recents.presentedChannel!)
|
||||
ChannelVideosView(channel: recents.presentedChannel!)
|
||||
.environment(\.inNavigationView, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
#if os(iOS)
|
||||
Group {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button(action: { navigation.presentingSettings = true }) {
|
||||
Image(systemName: "gearshape.2")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
AccountsMenuView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var navigationState = NavigationState()
|
||||
@StateObject private var playbackState = PlaybackState()
|
||||
@StateObject private var playlists = Playlists()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var playback = PlaybackModel()
|
||||
@StateObject private var recents = Recents()
|
||||
@StateObject private var searchState = SearchState()
|
||||
@StateObject private var subscriptions = Subscriptions()
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@@ -26,29 +27,29 @@ struct ContentView: View {
|
||||
TVNavigationView()
|
||||
#endif
|
||||
}
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(playback)
|
||||
.environmentObject(recents)
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $navigationState.showingVideo) {
|
||||
if let video = navigationState.video {
|
||||
.sheet(isPresented: $navigation.showingVideo) {
|
||||
if let video = navigation.video {
|
||||
VideoPlayerView(video)
|
||||
|
||||
#if !os(iOS)
|
||||
.frame(minWidth: 550, minHeight: 720)
|
||||
.onExitCommand {
|
||||
navigationState.showingVideo = false
|
||||
navigation.showingVideo = false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $navigationState.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigationState.editedPlaylist)
|
||||
.sheet(isPresented: $navigation.presentingPlaylistForm) {
|
||||
PlaylistFormView(playlist: $navigation.editedPlaylist)
|
||||
}
|
||||
.sheet(isPresented: $navigation.presentingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
#endif
|
||||
.environmentObject(navigationState)
|
||||
.environmentObject(playbackState)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(searchState)
|
||||
.environmentObject(subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,15 +1,50 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PearvidiousApp: App {
|
||||
@StateObject private var api = InvidiousAPI()
|
||||
@StateObject private var instances = InstancesModel()
|
||||
@StateObject private var playlists = PlaylistsModel()
|
||||
@StateObject private var search = SearchModel()
|
||||
@StateObject private var subscriptions = SubscriptionsModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear(perform: configureAPI)
|
||||
.environmentObject(api)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(search)
|
||||
.environmentObject(subscriptions)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.commands {
|
||||
SidebarCommands()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
SettingsView()
|
||||
.onAppear(perform: configureAPI)
|
||||
.environmentObject(api)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(subscriptions)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
fileprivate func configureAPI() {
|
||||
subscriptions.api = api
|
||||
playlists.api = api
|
||||
|
||||
guard api.account == nil, instances.defaultAccount != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
api.setAccount(instances.defaultAccount)
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ struct PlaybackBar: View {
|
||||
let video: Video
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var playbackState: PlaybackState
|
||||
@EnvironmentObject private var playback: PlaybackModel
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -18,7 +18,7 @@ struct PlaybackBar: View {
|
||||
.frame(minWidth: 60, maxWidth: .infinity)
|
||||
|
||||
VStack {
|
||||
if playbackState.stream != nil {
|
||||
if playback.stream != nil {
|
||||
Text(currentStreamString)
|
||||
} else {
|
||||
if video.live {
|
||||
@@ -38,19 +38,19 @@ struct PlaybackBar: View {
|
||||
}
|
||||
|
||||
var currentStreamString: String {
|
||||
playbackState.stream != nil ? "\(playbackState.stream!.resolution.height)p" : ""
|
||||
playback.stream != nil ? "\(playback.stream!.resolution.height)p" : ""
|
||||
}
|
||||
|
||||
var playbackStatus: String {
|
||||
guard playbackState.time != nil else {
|
||||
if playbackState.live {
|
||||
guard playback.time != nil else {
|
||||
if playback.live {
|
||||
return "LIVE"
|
||||
} else {
|
||||
return "loading..."
|
||||
}
|
||||
}
|
||||
|
||||
let remainingSeconds = video.length - playbackState.time!.seconds
|
||||
let remainingSeconds = video.length - playback.time!.seconds
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Player: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<PlaybackState> private var playbackState
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<PlaybackModel> private var playback
|
||||
|
||||
var video: Video?
|
||||
|
||||
@@ -9,7 +11,10 @@ struct Player: UIViewControllerRepresentable {
|
||||
let controller = PlayerViewController()
|
||||
|
||||
controller.video = video
|
||||
controller.playbackState = playbackState
|
||||
controller.playback = playback
|
||||
controller.api = api
|
||||
|
||||
controller.resolution = Defaults[.quality]
|
||||
|
||||
return controller
|
||||
}
|
||||
|
@@ -5,11 +5,13 @@ import SwiftUI
|
||||
final class PlayerViewController: UIViewController {
|
||||
var video: Video!
|
||||
|
||||
var api: InvidiousAPI!
|
||||
var playerLoaded = false
|
||||
var player = AVPlayer()
|
||||
var playerState: PlayerState!
|
||||
var playbackState: PlaybackState!
|
||||
var playerModel: PlayerModel!
|
||||
var playback: PlaybackModel!
|
||||
var playerViewController = AVPlayerViewController()
|
||||
var resolution: Stream.ResolutionSetting!
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
@@ -22,7 +24,7 @@ final class PlayerViewController: UIViewController {
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
#if os(iOS)
|
||||
if !playerState.playingOutsideViewController {
|
||||
if !playerModel.playingOutsideViewController {
|
||||
playerViewController.player?.replaceCurrentItem(with: nil)
|
||||
playerViewController.player = nil
|
||||
|
||||
@@ -34,15 +36,15 @@ final class PlayerViewController: UIViewController {
|
||||
}
|
||||
|
||||
func loadPlayer() {
|
||||
playerState = PlayerState(playbackState: playbackState)
|
||||
playerModel = PlayerModel(playback: playback, api: api, resolution: resolution)
|
||||
|
||||
guard !playerLoaded else {
|
||||
return
|
||||
}
|
||||
|
||||
playerState.player = player
|
||||
playerViewController.player = playerState.player
|
||||
playerState.loadVideo(video)
|
||||
playerModel.player = player
|
||||
playerViewController.player = playerModel.player
|
||||
playerModel.loadVideo(video)
|
||||
|
||||
#if os(tvOS)
|
||||
present(playerViewController, animated: false)
|
||||
@@ -95,7 +97,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
|
||||
playerState.playingOutsideViewController = false
|
||||
playerModel.playingOutsideViewController = false
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
_: AVPlayerViewController,
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
playerState.playingOutsideViewController = true
|
||||
playerModel.playingOutsideViewController = true
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
@@ -112,7 +114,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
) {
|
||||
coordinator.animate(alongsideTransition: nil) { context in
|
||||
if !context.isCancelled {
|
||||
self.playerState.playingOutsideViewController = false
|
||||
self.playerModel.playingOutsideViewController = false
|
||||
|
||||
#if os(iOS)
|
||||
if self.traitCollection.verticalSizeClass == .compact {
|
||||
@@ -124,10 +126,10 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
|
||||
}
|
||||
|
||||
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
|
||||
playerState.playingOutsideViewController = true
|
||||
playerModel.playingOutsideViewController = true
|
||||
}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
|
||||
playerState.playingOutsideViewController = false
|
||||
playerModel.playingOutsideViewController = false
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VideoDetails: View {
|
||||
@EnvironmentObject<Subscriptions> private var subscriptions
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@State private var subscribed = false
|
||||
@State private var confirmationShown = false
|
||||
@@ -186,6 +186,6 @@ struct VideoDetails: View {
|
||||
struct VideoDetails_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VideoDetails(video: Video.fixture)
|
||||
.environmentObject(Subscriptions())
|
||||
.environmentObject(SubscriptionsModel())
|
||||
}
|
||||
}
|
||||
|
@@ -12,31 +12,31 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<PlaybackState> private var playbackState
|
||||
|
||||
@ObservedObject private var store = Store<Video>()
|
||||
@StateObject private var store = Store<Video>()
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaybackModel> private var playback
|
||||
|
||||
var resource: Resource {
|
||||
InvidiousAPI.shared.video(video.id)
|
||||
api.video(video.id)
|
||||
}
|
||||
|
||||
var video: Video
|
||||
|
||||
init(_ video: Video) {
|
||||
self.video = video
|
||||
resource.addObserver(store)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
#if os(tvOS)
|
||||
Player(video: video)
|
||||
.environmentObject(playbackState)
|
||||
.environmentObject(playback)
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
@@ -49,8 +49,8 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
|
||||
Player(video: video)
|
||||
.environmentObject(playbackState)
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playbackState.aspectRatio))
|
||||
.environmentObject(playback)
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
|
||||
}
|
||||
.background(.black)
|
||||
|
||||
@@ -73,13 +73,13 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playbackState.aspectRatio))
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: playbackState.aspectRatio)
|
||||
.animation(.linear(duration: 0.2), value: playback.aspectRatio)
|
||||
#endif
|
||||
}
|
||||
|
||||
.onAppear {
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
@@ -109,7 +109,7 @@ struct VideoPlayerView_Previews: PreviewProvider {
|
||||
}
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
VideoPlayerView(Video.fixture)
|
||||
.environmentObject(NavigationState())
|
||||
.environmentObject(NavigationModel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,8 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct PlaylistFormView: View {
|
||||
@Binding var playlist: Playlist!
|
||||
|
||||
@State private var name = ""
|
||||
@State private var visibility = Playlist.Visibility.public
|
||||
|
||||
@@ -10,11 +12,10 @@ struct PlaylistFormView: View {
|
||||
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@Binding var playlist: Playlist!
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@EnvironmentObject<Playlists> private var playlists
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
|
||||
var editing: Bool {
|
||||
playlist != nil
|
||||
@@ -33,6 +34,8 @@ struct PlaylistFormView: View {
|
||||
dismiss()
|
||||
}.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Form {
|
||||
TextField("Name", text: $name, onCommit: validate)
|
||||
.frame(maxWidth: 450)
|
||||
@@ -46,8 +49,7 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
if editing {
|
||||
deletePlaylistButton
|
||||
@@ -59,11 +61,14 @@ struct PlaylistFormView: View {
|
||||
.disabled(!valid)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.onChange(of: name) { _ in validate() }
|
||||
.onAppear(perform: initializeForm)
|
||||
.padding(.horizontal)
|
||||
#if !os(iOS)
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
#endif
|
||||
|
||||
@@ -141,14 +146,14 @@ struct PlaylistFormView: View {
|
||||
playlist = modifiedPlaylist
|
||||
}
|
||||
|
||||
playlists.reload()
|
||||
playlists.load(force: true)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
var resource: Resource {
|
||||
editing ? InvidiousAPI.shared.playlist(playlist.id) : InvidiousAPI.shared.playlists
|
||||
editing ? api.playlist(playlist.id) : api.playlists
|
||||
}
|
||||
|
||||
var visibilityButton: some View {
|
||||
@@ -189,9 +194,9 @@ struct PlaylistFormView: View {
|
||||
}
|
||||
|
||||
func deletePlaylistAndDismiss() {
|
||||
let resource = InvidiousAPI.shared.playlist(playlist.id)
|
||||
resource.request(.delete).onSuccess { _ in
|
||||
api.playlist(playlist.id).request(.delete).onSuccess { _ in
|
||||
playlist = nil
|
||||
playlists.load(force: true)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
@@ -3,9 +3,9 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct PlaylistsView: View {
|
||||
@ObservedObject private var store = Store<[Playlist]>()
|
||||
@StateObject private var store = Store<[Playlist]>()
|
||||
|
||||
@Default(.selectedPlaylistID) private var selectedPlaylistID
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
@State private var showingNewPlaylist = false
|
||||
@State private var createdPlaylist: Playlist?
|
||||
@@ -13,49 +13,32 @@ struct PlaylistsView: View {
|
||||
@State private var showingEditPlaylist = false
|
||||
@State private var editedPlaylist: Playlist?
|
||||
|
||||
var resource: Resource {
|
||||
InvidiousAPI.shared.playlists
|
||||
}
|
||||
@Default(.selectedPlaylistID) private var selectedPlaylistID
|
||||
|
||||
init() {
|
||||
resource.addObserver(store)
|
||||
var resource: Resource {
|
||||
api.playlists
|
||||
}
|
||||
|
||||
var videos: [Video] {
|
||||
currentPlaylist?.videos ?? []
|
||||
}
|
||||
|
||||
var videosViewMaxHeight: Double {
|
||||
#if os(tvOS)
|
||||
videos.isEmpty ? 150 : .infinity
|
||||
#else
|
||||
videos.isEmpty ? 0 : .infinity
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
toolbar
|
||||
.font(.system(size: 28))
|
||||
SignInRequiredView(title: "Playlists") {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
toolbar
|
||||
.font(.system(size: 28))
|
||||
|
||||
#endif
|
||||
if currentPlaylist != nil, videos.isEmpty {
|
||||
hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"")
|
||||
} else if store.collection.isEmpty {
|
||||
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one")
|
||||
} else {
|
||||
VideosView(videos: videos)
|
||||
#endif
|
||||
if currentPlaylist != nil, videos.isEmpty {
|
||||
hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"")
|
||||
} else if store.collection.isEmpty {
|
||||
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one")
|
||||
} else {
|
||||
VideosView(videos: videos)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
toolbar
|
||||
.font(.system(size: 14))
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.overlay(Divider().offset(x: 0, y: -2), alignment: .topTrailing)
|
||||
.transaction { t in t.animation = .none }
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
@@ -85,21 +68,37 @@ struct PlaylistsView: View {
|
||||
#endif
|
||||
newPlaylistButton
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
if store.collection.isEmpty {
|
||||
Text("No Playlists")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Current Playlist")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
selectPlaylistButton
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if currentPlaylist != nil {
|
||||
editPlaylistButton
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
resource.addObserver(store)
|
||||
|
||||
resource.loadIfNeeded()?.onSuccess { _ in
|
||||
selectPlaylist(selectedPlaylistID)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("Playlists")
|
||||
#elseif os(iOS)
|
||||
.navigationBarItems(trailing: newPlaylistButton)
|
||||
#endif
|
||||
}
|
||||
|
||||
var scaledToolbar: some View {
|
||||
toolbar.scaleEffect(0.85)
|
||||
}
|
||||
|
||||
var toolbar: some View {
|
||||
@@ -233,6 +232,6 @@ struct PlaylistsView: View {
|
||||
struct PlaylistsView_Provider: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlaylistsView()
|
||||
.environmentObject(NavigationState())
|
||||
.environmentObject(NavigationModel())
|
||||
}
|
||||
}
|
||||
|
117
Shared/Settings/AccountFormView.swift
Normal file
117
Shared/Settings/AccountFormView.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct AccountFormView: View {
|
||||
let instance: Instance
|
||||
var selectedAccount: Binding<Instance.Account?>?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var sid = ""
|
||||
|
||||
@State private var valid = false
|
||||
@State private var validated = false
|
||||
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack(alignment: .center) {
|
||||
Text("Add Account")
|
||||
.font(.title2.bold())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Form {
|
||||
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
|
||||
.focused($focused)
|
||||
|
||||
TextField("SID", text: $sid, prompt: Text("Invidious SID Cookie"))
|
||||
}
|
||||
.onAppear(perform: initializeForm)
|
||||
.onChange(of: sid) { _ in validate() }
|
||||
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: valid ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundColor(valid ? .green : .red)
|
||||
VStack(alignment: .leading) {
|
||||
Text(valid ? "Account found" : "Invalid account details")
|
||||
}
|
||||
}
|
||||
.opacity(validated ? 1 : 0)
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
.frame(minHeight: 35)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 145)
|
||||
#endif
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
focused = true
|
||||
}
|
||||
|
||||
func validate() {
|
||||
guard !sid.isEmpty else {
|
||||
validator.reset()
|
||||
return
|
||||
}
|
||||
|
||||
validator.validateAccount()
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else {
|
||||
return
|
||||
}
|
||||
|
||||
let account = instances.addAccount(instance: instance, name: name, sid: sid)
|
||||
selectedAccount?.wrappedValue = account
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private var validator: InstanceAccountValidator {
|
||||
InstanceAccountValidator(
|
||||
url: instance.url,
|
||||
account: Instance.Account(url: instance.url, sid: sid),
|
||||
formObjectID: $sid,
|
||||
valid: $valid,
|
||||
validated: $validated
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountFormView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AccountFormView(instance: Instance.fixture)
|
||||
}
|
||||
}
|
36
Shared/Settings/AccountSettingsView.swift
Normal file
36
Shared/Settings/AccountSettingsView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountSettingsView: View {
|
||||
let instance: Instance
|
||||
let account: Instance.Account
|
||||
@Binding var selectedAccount: Instance.Account?
|
||||
|
||||
@State private var presentingRemovalConfirmationDialog = false
|
||||
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(account.description)
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Button("Remove", role: .destructive) {
|
||||
presentingRemovalConfirmationDialog = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure you want to remove \(account.description) account?",
|
||||
isPresented: $presentingRemovalConfirmationDialog
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
instances.removeAccount(instance: instance, account: account)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.foregroundColor(.red)
|
||||
#endif
|
||||
}
|
||||
.opacity(account == selectedAccount ? 1 : 0)
|
||||
}
|
||||
}
|
||||
}
|
45
Shared/Settings/InstanceDetailsSettingsView.swift
Normal file
45
Shared/Settings/InstanceDetailsSettingsView.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceDetailsSettingsView: View {
|
||||
let instanceID: Instance.ID?
|
||||
|
||||
@State private var accountsChanged = false
|
||||
@State private var presentingAccountForm = false
|
||||
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
|
||||
var instance: Instance! {
|
||||
instances.find(instanceID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: Text("Accounts")) {
|
||||
ForEach(instance.accounts, id: \.self) { account in
|
||||
Text(account.description)
|
||||
#if !os(tvOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button("Remove", role: .destructive) {
|
||||
instances.removeAccount(instance: instance, account: account)
|
||||
accountsChanged.toggle()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.redrawOn(change: accountsChanged)
|
||||
|
||||
Button("Add account...") {
|
||||
presentingAccountForm = true
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
|
||||
.navigationTitle(instance.shortDescription)
|
||||
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
|
||||
AccountFormView(instance: instance)
|
||||
}
|
||||
}
|
||||
}
|
126
Shared/Settings/InstanceFormView.swift
Normal file
126
Shared/Settings/InstanceFormView.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceFormView: View {
|
||||
@Binding var savedInstanceID: Instance.ID?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var url = ""
|
||||
|
||||
@State private var valid = false
|
||||
@State private var validated = false
|
||||
@State private var validationError: String?
|
||||
|
||||
@FocusState private var nameFieldFocused: Bool
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@EnvironmentObject<InstancesModel> private var instancesModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
Text("Add Instance")
|
||||
.font(.title2.bold())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Form {
|
||||
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
|
||||
.frame(maxWidth: 450)
|
||||
.focused($nameFieldFocused)
|
||||
|
||||
TextField("URL", text: $url, prompt: Text("https://invidious.home.net"))
|
||||
.frame(maxWidth: 450)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: valid ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundColor(valid ? .green : .red)
|
||||
VStack(alignment: .leading) {
|
||||
Text(valid ? "Connected successfully" : "Connection failed")
|
||||
if !valid {
|
||||
Text(validationError ?? "Unknown Error")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.truncationMode(.tail)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
.opacity(validated ? 1 : 0)
|
||||
Spacer()
|
||||
|
||||
Button("Save", action: submitForm)
|
||||
.disabled(!valid)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.onChange(of: url) { _ in validate() }
|
||||
.onAppear(perform: initializeForm)
|
||||
#if os(iOS)
|
||||
.padding(.vertical)
|
||||
#else
|
||||
.frame(width: 400, height: 150)
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
var validator: InstanceAccountValidator {
|
||||
InstanceAccountValidator(
|
||||
url: url,
|
||||
formObjectID: $url,
|
||||
valid: $valid,
|
||||
validated: $validated,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
|
||||
func validate() {
|
||||
valid = false
|
||||
validated = false
|
||||
validationError = nil
|
||||
|
||||
guard !url.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
validator.validateInstance()
|
||||
}
|
||||
|
||||
func initializeForm() {
|
||||
nameFieldFocused = true
|
||||
}
|
||||
|
||||
func submitForm() {
|
||||
guard valid else {
|
||||
return
|
||||
}
|
||||
|
||||
savedInstanceID = instancesModel.add(name: name, url: url).id
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct InstanceFormView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InstanceFormView(savedInstanceID: .constant(nil))
|
||||
}
|
||||
}
|
168
Shared/Settings/InstancesSettingsView.swift
Normal file
168
Shared/Settings/InstancesSettingsView.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct InstancesSettingsView: View {
|
||||
@Default(.instances) private var instances
|
||||
@EnvironmentObject<InstancesModel> private var instancesModel
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
|
||||
@State private var selectedInstanceID: Instance.ID?
|
||||
@State private var selectedAccount: Instance.Account?
|
||||
|
||||
@State private var presentingAccountForm = false
|
||||
@State private var presentingInstanceForm = false
|
||||
@State private var savedFormInstanceID: Instance.ID?
|
||||
|
||||
@State private var presentingConfirmationDialog = false
|
||||
@State private var presentingInstanceDetails = false
|
||||
|
||||
var selectedInstance: Instance! {
|
||||
instancesModel.find(selectedInstanceID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Section(header: instancesHeader) {
|
||||
ForEach(instances, id: \.self) { instance in
|
||||
Button(action: {
|
||||
self.selectedInstanceID = instance.id
|
||||
self.presentingInstanceDetails = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(instance.description)
|
||||
Spacer()
|
||||
NavigationLink(
|
||||
isActive: .constant(false),
|
||||
destination: { EmptyView() },
|
||||
label: { EmptyView() }
|
||||
)
|
||||
.frame(maxWidth: 100)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button("Remove", role: .destructive) {
|
||||
instancesModel.remove(instance)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button("Add Instance...") {
|
||||
presentingInstanceForm = true
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
#else
|
||||
Section {
|
||||
Text("Instance")
|
||||
|
||||
if !instances.isEmpty {
|
||||
Picker("Instance", selection: $selectedInstanceID) {
|
||||
ForEach(instances, id: \.url) { instance in
|
||||
Text(instance.description).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} else {
|
||||
Text("You have no instances configured")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let instance = selectedInstance {
|
||||
if instance.accounts.isEmpty {
|
||||
Text("You have no accounts for this instance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Accounts")
|
||||
List(selection: $selectedAccount) {
|
||||
ForEach(instance.accounts, id: \.self) { account in
|
||||
AccountSettingsView(instance: instance, account: account,
|
||||
selectedAccount: $selectedAccount)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if selectedInstance != nil {
|
||||
HStack {
|
||||
Button("Add Account...") {
|
||||
selectedAccount = nil
|
||||
presentingAccountForm = true
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Button("Remove Instance", role: .destructive) {
|
||||
presentingConfirmationDialog = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure you want to remove \(selectedInstance!.description) instance?",
|
||||
isPresented: $presentingConfirmationDialog
|
||||
) {
|
||||
Button("Remove Instance", role: .destructive) {
|
||||
instancesModel.remove(selectedInstance!)
|
||||
selectedInstanceID = instances.last?.id
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
.foregroundColor(.red)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Button("Add Instance...") {
|
||||
presentingInstanceForm = true
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
.onAppear {
|
||||
selectedInstanceID = instances.first?.id
|
||||
}
|
||||
.sheet(isPresented: $presentingAccountForm) {
|
||||
AccountFormView(instance: selectedInstance, selectedAccount: $selectedAccount)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $presentingInstanceForm, onDismiss: setSelectedInstanceToFormInstance) {
|
||||
InstanceFormView(savedInstanceID: $savedFormInstanceID)
|
||||
}
|
||||
}
|
||||
|
||||
var instancesHeader: some View {
|
||||
Text("Instances").background(instanceDetailsNavigationLink)
|
||||
}
|
||||
|
||||
var instanceDetailsNavigationLink: some View {
|
||||
NavigationLink(
|
||||
isActive: $presentingInstanceDetails,
|
||||
destination: { InstanceDetailsSettingsView(instanceID: selectedInstanceID) },
|
||||
label: { EmptyView() }
|
||||
)
|
||||
}
|
||||
|
||||
func setSelectedInstanceToFormInstance() {
|
||||
if let id = savedFormInstanceID {
|
||||
selectedInstanceID = id
|
||||
savedFormInstanceID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InstancesSettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InstancesSettingsView()
|
||||
}
|
||||
}
|
25
Shared/Settings/PlaybackSettingsView.swift
Normal file
25
Shared/Settings/PlaybackSettingsView.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackSettingsView: View {
|
||||
@Default(.quality) private var quality
|
||||
|
||||
var body: some View {
|
||||
Section(header: Text("Quality")) {
|
||||
Picker("Quality", selection: $quality) {
|
||||
ForEach(Stream.ResolutionSetting.allCases, id: \.self) { resolution in
|
||||
Text(resolution.description).tag(resolution)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
#if os(iOS)
|
||||
.pickerStyle(.automatic)
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
66
Shared/Settings/SettingsView.swift
Normal file
66
Shared/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
private enum Tabs: Hashable {
|
||||
case playback, instances
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
TabView {
|
||||
Form {
|
||||
InstancesSettingsView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Instances", systemImage: "server.rack")
|
||||
}
|
||||
.tag(Tabs.instances)
|
||||
|
||||
Form {
|
||||
PlaybackSettingsView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Playback", systemImage: "play.rectangle.on.rectangle.fill")
|
||||
}
|
||||
.tag(Tabs.playback)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 400, height: 270)
|
||||
#else
|
||||
NavigationView {
|
||||
List {
|
||||
InstancesSettingsView()
|
||||
PlaybackSettingsView()
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsView()
|
||||
#if os(macOS)
|
||||
.frame(width: 600, height: 300)
|
||||
#endif
|
||||
}
|
||||
}
|
@@ -4,7 +4,7 @@ struct TrendingCountry: View {
|
||||
static let prompt = "Country Name or Code"
|
||||
@Binding var selectedCountry: Country?
|
||||
|
||||
@ObservedObject private var store = Store(Country.allCases)
|
||||
@StateObject private var store = Store(Country.allCases)
|
||||
|
||||
@State private var query: String = ""
|
||||
@State private var selection: Country?
|
||||
|
@@ -2,18 +2,85 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct TrendingView: View {
|
||||
@StateObject private var store = Store<[Video]>()
|
||||
|
||||
@State private var category: TrendingCategory = .default
|
||||
@State private var country: Country! = .pl
|
||||
@State private var selectingCountry = false
|
||||
@State private var presentingCountrySelection = false
|
||||
|
||||
@ObservedObject private var store = Store<[Video]>()
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
var resource: Resource {
|
||||
InvidiousAPI.shared.trending(category: category, country: country)
|
||||
let resource = api.trending(category: category, country: country)
|
||||
resource.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
init() {
|
||||
resource.addObserver(store)
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
#if os(tvOS)
|
||||
toolbar
|
||||
.scaleEffect(0.85)
|
||||
#endif
|
||||
|
||||
if store.collection.isEmpty {
|
||||
Text("Loading")
|
||||
}
|
||||
|
||||
VideosView(videos: store.collection)
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $presentingCountrySelection) {
|
||||
TrendingCountry(selectedCountry: $country)
|
||||
}
|
||||
#else
|
||||
.sheet(isPresented: $presentingCountrySelection) {
|
||||
TrendingCountry(selectedCountry: $country)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("Trending")
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
categoryButton
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Group {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
categoryButton
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Country")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.onChange(of: resource) { resource in
|
||||
resource.load()
|
||||
}
|
||||
.onAppear {
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
var toolbar: some View {
|
||||
@@ -38,67 +105,21 @@ struct TrendingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
#if os(tvOS)
|
||||
toolbar
|
||||
.scaleEffect(0.85)
|
||||
#endif
|
||||
|
||||
VideosView(videos: store.collection)
|
||||
|
||||
#if os(iOS)
|
||||
toolbar
|
||||
.font(.system(size: 14))
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.overlay(Divider().offset(x: 0, y: -2), alignment: .topTrailing)
|
||||
.transaction { t in t.animation = .none }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $selectingCountry, onDismiss: { setCountry(country) }) {
|
||||
TrendingCountry(selectedCountry: $country)
|
||||
}
|
||||
#else
|
||||
.sheet(isPresented: $selectingCountry, onDismiss: { setCountry(country) }) {
|
||||
TrendingCountry(selectedCountry: $country)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("Trending")
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
categoryButton
|
||||
countryButton
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
.onAppear {
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
var categoryButton: some View {
|
||||
#if os(tvOS)
|
||||
Button(category.name) {
|
||||
setCategory(category.next())
|
||||
self.category = category.next()
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Button(category.name) { setCategory(category) }
|
||||
Button(category.name) { self.category = category }
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
Menu(category.name) {
|
||||
ForEach(TrendingCategory.allCases) { category in
|
||||
Button(action: { setCategory(category) }) {
|
||||
Button(action: { self.category = category }) {
|
||||
if category == self.category {
|
||||
Label(category.name, systemImage: "checkmark")
|
||||
} else {
|
||||
@@ -112,30 +133,17 @@ struct TrendingView: View {
|
||||
|
||||
var countryButton: some View {
|
||||
Button(action: {
|
||||
selectingCountry.toggle()
|
||||
presentingCountrySelection.toggle()
|
||||
resource.removeObservers(ownedBy: store)
|
||||
}) {
|
||||
Text("\(country.flag) \(country.id)")
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func setCategory(_ category: TrendingCategory) {
|
||||
resource.removeObservers(ownedBy: store)
|
||||
self.category = category
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
|
||||
fileprivate func setCountry(_ country: Country) {
|
||||
self.country = country
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
struct TrendingView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TrendingView()
|
||||
.environmentObject(NavigationState())
|
||||
.environmentObject(NavigationModel())
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct VideoView: View {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -21,7 +21,7 @@ struct VideoView: View {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
Button(action: { navigationState.playVideo(video) }) {
|
||||
Button(action: { navigation.playVideo(video) }) {
|
||||
content
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ struct VideoView: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
thumbnail
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
videoDetail(video.title, lineLimit: additionalDetailsAvailable ? 2 : 3)
|
||||
#if os(tvOS)
|
||||
.frame(minHeight: additionalDetailsAvailable ? 80 : 120, alignment: .top)
|
||||
@@ -155,7 +155,9 @@ struct VideoView: View {
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 30, alignment: .top)
|
||||
.padding(.bottom, 10)
|
||||
#if os(tvOS)
|
||||
.padding(.bottom, 10)
|
||||
#endif
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading)
|
||||
|
@@ -20,7 +20,7 @@ struct VideosCellsHorizontal: View {
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 40)
|
||||
#else
|
||||
.frame(maxWidth: 300)
|
||||
.frame(width: 300)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ struct VideosCellsHorizontal: View {
|
||||
.padding(.vertical, 20)
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
if let video = videos.first {
|
||||
scrollView.scrollTo(video.id, anchor: .leading)
|
||||
}
|
||||
}
|
||||
.onChange(of: videos) { [videos] newVideos in
|
||||
#if !os(tvOS)
|
||||
guard !videos.isEmpty, let video = newVideos.first else {
|
||||
@@ -45,7 +50,7 @@ struct VideosCellsHorizontal: View {
|
||||
#if os(tvOS)
|
||||
.frame(height: 560)
|
||||
#else
|
||||
.frame(height: 320)
|
||||
.frame(height: 280)
|
||||
#endif
|
||||
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
@@ -55,7 +60,7 @@ struct VideosCellsHorizontal: View {
|
||||
struct VideoCellsHorizontal_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VideosCellsHorizontal(videos: Video.allFixtures)
|
||||
.environmentObject(NavigationState())
|
||||
.environmentObject(Subscriptions())
|
||||
.environmentObject(NavigationModel())
|
||||
.environmentObject(SubscriptionsModel())
|
||||
}
|
||||
}
|
||||
|
@@ -72,6 +72,6 @@ struct VideoCellsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VideosView(videos: Video.allFixtures)
|
||||
.frame(minWidth: 1000)
|
||||
.environmentObject(NavigationState())
|
||||
.environmentObject(NavigationModel())
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct VideosView: View {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
|
||||
#if os(tvOS)
|
||||
@Default(.layout) private var layout
|
||||
|
@@ -4,8 +4,11 @@ import SwiftUI
|
||||
struct ChannelVideosView: View {
|
||||
let channel: Channel
|
||||
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<Subscriptions> private var subscriptions
|
||||
@StateObject private var store = Store<Channel>()
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@@ -14,16 +17,8 @@ struct ChannelVideosView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
@ObservedObject private var store = Store<Channel>()
|
||||
|
||||
@Namespace private var focusNamespace
|
||||
|
||||
init(_ channel: Channel) {
|
||||
self.channel = channel
|
||||
|
||||
resource.addObserver(store)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
@@ -55,12 +50,11 @@ struct ChannelVideosView: View {
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: subscriptionToolbarItemPlacement) {
|
||||
ToolbarItem {
|
||||
HStack {
|
||||
if let channel = store.item, let subscribers = channel.subscriptionsString {
|
||||
Text("**\(subscribers)** subscribers")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("**\(store.item?.subscriptionsString ?? "loading")** subscribers")
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
|
||||
|
||||
subscriptionToggleButton
|
||||
}
|
||||
@@ -77,13 +71,19 @@ struct ChannelVideosView: View {
|
||||
#endif
|
||||
.modifier(UnsubscribeAlertModifier())
|
||||
.onAppear {
|
||||
resource.loadIfNeeded()
|
||||
if store.item.isNil {
|
||||
resource.addObserver(store)
|
||||
resource.load()
|
||||
}
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
}
|
||||
|
||||
var resource: Resource {
|
||||
InvidiousAPI.shared.channel(channel.id)
|
||||
let resource = api.channel(channel.id)
|
||||
resource.addObserver(store)
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -94,7 +94,7 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
return .status
|
||||
return .automatic
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -102,12 +102,12 @@ struct ChannelVideosView: View {
|
||||
Group {
|
||||
if subscriptions.isSubscribing(channel.id) {
|
||||
Button("Unsubscribe") {
|
||||
navigationState.presentUnsubscribeAlert(channel)
|
||||
navigation.presentUnsubscribeAlert(channel)
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptions.subscribe(channel.id) {
|
||||
navigationState.sidebarSectionChanged.toggle()
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,21 +2,22 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct PopularView: View {
|
||||
@ObservedObject private var store = Store<[Video]>()
|
||||
@StateObject private var store = Store<[Video]>()
|
||||
|
||||
var resource = InvidiousAPI.shared.popular
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
init() {
|
||||
resource.addObserver(store)
|
||||
var resource: Resource {
|
||||
api.popular
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VideosView(videos: store.collection)
|
||||
.onAppear {
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("Popular")
|
||||
#endif
|
||||
.onAppear {
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ struct SearchView: View {
|
||||
@Default(.searchDuration) private var searchDuration
|
||||
|
||||
@EnvironmentObject<Recents> private var recents
|
||||
@EnvironmentObject<SearchState> private var state
|
||||
@EnvironmentObject<SearchModel> private var state
|
||||
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@@ -85,7 +85,7 @@ struct SearchView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.opacity(recentsChanged ? 1 : 1)
|
||||
.redrawOn(change: recentsChanged)
|
||||
|
||||
clearAllButton
|
||||
}
|
||||
|
73
Shared/Views/SignInRequiredView.swift
Normal file
73
Shared/Views/SignInRequiredView.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct SignInRequiredView<Content: View>: View {
|
||||
let title: String
|
||||
let content: Content
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@Default(.instances) private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
|
||||
init(title: String, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.title = title
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if api.signedIn {
|
||||
content
|
||||
} else {
|
||||
prompt
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(title)
|
||||
#endif
|
||||
}
|
||||
|
||||
var prompt: some View {
|
||||
VStack(spacing: 30) {
|
||||
Text("Sign In Required")
|
||||
.font(.title2.bold())
|
||||
|
||||
Group {
|
||||
if instances.isEmpty {
|
||||
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")
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.title3)
|
||||
.padding(.vertical)
|
||||
|
||||
if instances.isEmpty {
|
||||
openSettingsButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var openSettingsButton: some View {
|
||||
Button(action: {
|
||||
#if os(macOS)
|
||||
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
||||
#else
|
||||
navigation.presentingSettings = true
|
||||
#endif
|
||||
}) {
|
||||
Text("Open Settings")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
struct SignInRequiredView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SignInRequiredView(title: "Subscriptions") {
|
||||
Text("Only when signed in")
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,30 +1,46 @@
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct SubscriptionsView: View {
|
||||
@ObservedObject private var store = Store<[Video]>()
|
||||
@StateObject private var store = Store<[Video]>()
|
||||
|
||||
var resource = InvidiousAPI.shared.feed
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
init() {
|
||||
resource.addObserver(store)
|
||||
var feed: Resource {
|
||||
api.feed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VideosView(videos: store.collection)
|
||||
.onAppear {
|
||||
if let home = InvidiousAPI.shared.home.loadIfNeeded() {
|
||||
home.onSuccess { _ in
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
} else {
|
||||
resource.loadIfNeeded()
|
||||
SignInRequiredView(title: "Subscriptions") {
|
||||
VideosView(videos: store.collection)
|
||||
.onAppear {
|
||||
loadResources()
|
||||
}
|
||||
.onChange(of: api.account) { _ in
|
||||
loadResources(force: true)
|
||||
}
|
||||
.onChange(of: feed) { _ in
|
||||
loadResources(force: true)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
loadResources(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func loadResources(force: Bool = false) {
|
||||
feed.addObserver(store)
|
||||
|
||||
if let request = force ? api.home.load() : api.home.loadIfNeeded() {
|
||||
request.onSuccess { _ in
|
||||
loadFeed(force: force)
|
||||
}
|
||||
.refreshable {
|
||||
resource.load()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("Subscriptions")
|
||||
#endif
|
||||
} else {
|
||||
loadFeed(force: force)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func loadFeed(force: Bool = false) {
|
||||
_ = force ? feed.load() : feed.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
@@ -2,25 +2,23 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct VideoContextMenuView: View {
|
||||
@EnvironmentObject<NavigationState> private var navigationState
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<Recents> private var recents
|
||||
@EnvironmentObject<Subscriptions> private var subscriptions
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
let video: Video
|
||||
|
||||
@Default(.showingAddToPlaylist) var showingAddToPlaylist
|
||||
@Default(.videoIDToAddToPlaylist) var videoIDToAddToPlaylist
|
||||
|
||||
@State private var subscribed = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
openChannelButton
|
||||
|
||||
subscriptionButton
|
||||
.opacity(subscribed ? 1 : 1)
|
||||
|
||||
if navigationState.tabSelection == .playlists {
|
||||
if navigation.tabSelection == .playlists {
|
||||
removeFromPlaylistButton
|
||||
} else {
|
||||
addToPlaylistButton
|
||||
@@ -32,9 +30,9 @@ struct VideoContextMenuView: View {
|
||||
Button("\(video.author) Channel") {
|
||||
let recent = RecentItem(from: video.channel)
|
||||
recents.open(recent)
|
||||
navigationState.tabSelection = .recentlyOpened(recent.tag)
|
||||
navigationState.isChannelOpen = true
|
||||
navigationState.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
navigation.isChannelOpen = true
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +43,13 @@ struct VideoContextMenuView: View {
|
||||
#if os(tvOS)
|
||||
subscriptions.unsubscribe(video.channel.id)
|
||||
#else
|
||||
navigationState.presentUnsubscribeAlert(video.channel)
|
||||
navigation.presentUnsubscribeAlert(video.channel)
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptions.subscribe(video.channel.id) {
|
||||
navigationState.sidebarSectionChanged.toggle()
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,9 +65,9 @@ struct VideoContextMenuView: View {
|
||||
|
||||
var removeFromPlaylistButton: some View {
|
||||
Button("Remove from playlist", role: .destructive) {
|
||||
let resource = InvidiousAPI.shared.playlistVideo(Defaults[.selectedPlaylistID]!, video.indexID!)
|
||||
let resource = api.playlistVideo(Defaults[.selectedPlaylistID]!, video.indexID!)
|
||||
resource.request(.delete).onSuccess { _ in
|
||||
InvidiousAPI.shared.playlists.load()
|
||||
api.playlists.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,25 +0,0 @@
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct WatchNowPlaylistSection: View {
|
||||
@ObservedObject private var store = Store<Playlist>()
|
||||
|
||||
let id: String
|
||||
|
||||
var resource: Resource {
|
||||
InvidiousAPI.shared.playlist(id)
|
||||
}
|
||||
|
||||
init(id: String) {
|
||||
self.id = id
|
||||
|
||||
resource.addObserver(store)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
WatchNowSectionBody(label: store.item?.title ?? "Loading", videos: store.item?.videos ?? [])
|
||||
.onAppear {
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,23 +1,28 @@
|
||||
import Defaults
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct WatchNowSection: View {
|
||||
@ObservedObject private var store = Store<[Video]>()
|
||||
|
||||
let resource: Resource
|
||||
let label: String
|
||||
|
||||
@StateObject private var store = Store<[Video]>()
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
init(resource: Resource, label: String) {
|
||||
self.resource = resource
|
||||
self.label = label
|
||||
|
||||
self.resource.addObserver(store)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
WatchNowSectionBody(label: label, videos: store.collection)
|
||||
.onAppear {
|
||||
resource.loadIfNeeded()
|
||||
resource.addObserver(store)
|
||||
resource.load()
|
||||
}
|
||||
.onChange(of: api.account) { _ in
|
||||
resource.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,24 +1,28 @@
|
||||
import Defaults
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
struct WatchNowView: View {
|
||||
init() {
|
||||
InvidiousAPI.shared.home.loadIfNeeded()
|
||||
}
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
WatchNowSection(resource: InvidiousAPI.shared.feed, label: "Subscriptions")
|
||||
WatchNowSection(resource: InvidiousAPI.shared.popular, label: "Popular")
|
||||
WatchNowSection(resource: InvidiousAPI.shared.trending(category: .default, country: .pl), label: "Trending")
|
||||
WatchNowSection(resource: InvidiousAPI.shared.trending(category: .movies, country: .pl), label: "Movies")
|
||||
WatchNowSection(resource: InvidiousAPI.shared.trending(category: .music, country: .pl), label: "Music")
|
||||
if api.validInstance {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if api.signedIn {
|
||||
WatchNowSection(resource: api.feed, label: "Subscriptions")
|
||||
}
|
||||
WatchNowSection(resource: api.popular, label: "Popular")
|
||||
WatchNowSection(resource: api.trending(category: .default, country: .pl), label: "Trending")
|
||||
WatchNowSection(resource: api.trending(category: .movies, country: .pl), label: "Movies")
|
||||
WatchNowSection(resource: api.trending(category: .music, country: .pl), label: "Music")
|
||||
|
||||
// TODO: adding sections to view
|
||||
// ===================
|
||||
// WatchNowPlaylistSection(id: "IVPLmRFYLGYZpq61SpujNw3EKbzzGNvoDmH")
|
||||
// WatchNowSection(resource: InvidiousAPI.shared.channelVideos("UCBJycsmduvYEL83R_U4JriQ"), label: "MKBHD")
|
||||
// TODO: adding sections to view
|
||||
// ===================
|
||||
// WatchNowPlaylistSection(id: "IVPLmRFYLGYZpq61SpujNw3EKbzzGNvoDmH")
|
||||
// WatchNowSection(resource: InvidiousAPI.shared.channelVideos("UCBJycsmduvYEL83R_U4JriQ"), label: "MKBHD")
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -36,7 +40,7 @@ struct WatchNowView: View {
|
||||
struct WatchNowView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WatchNowView()
|
||||
.environmentObject(Subscriptions())
|
||||
.environmentObject(NavigationState())
|
||||
.environmentObject(SubscriptionsModel())
|
||||
.environmentObject(NavigationModel())
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user