Settings for iOS/macOS

This commit is contained in:
Arkadiusz Fal
2021-09-25 10:18:22 +02:00
parent 433725c5e8
commit a7da3b9468
64 changed files with 1998 additions and 665 deletions

View File

@@ -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"
}
],

View File

@@ -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 {

View 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))
}
}

View File

@@ -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)"
}

View 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
}
}

View File

@@ -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"
}
}

View File

@@ -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)

View File

@@ -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") {

View File

@@ -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)"
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View 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)
}
}

View 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)
}
}
}

View 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)
}
}
}

View 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))
}
}

View 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()
}
}

View 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
}
}
}

View 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
}
}

View File

@@ -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?

View File

@@ -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())
}
}

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -72,6 +72,6 @@ struct VideoCellsView_Previews: PreviewProvider {
static var previews: some View {
VideosView(videos: Video.allFixtures)
.frame(minWidth: 1000)
.environmentObject(NavigationState())
.environmentObject(NavigationModel())
}
}

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}

View 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")
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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())
}
}