Add Piped support

This commit is contained in:
Arkadiusz Fal
2021-10-17 00:48:58 +02:00
parent a68d89cb6f
commit 62e17d5a18
44 changed files with 919 additions and 327 deletions

View File

@@ -1,7 +1,10 @@
import Defaults
extension Defaults.Keys {
static let instances = Key<[Instance]>("instances", default: [])
static let instances = Key<[Instance]>("instances", default: [
.init(app: .piped, name: "Public", url: "https://pipedapi.kavin.rocks"),
.init(app: .invidious, name: "Private", url: "https://invidious.home.arekf.net")
])
static let accounts = Key<[Instance.Account]>("accounts", default: [])
static let defaultAccountID = Key<String?>("defaultAccountID")

View File

@@ -2,33 +2,27 @@ import Defaults
import SwiftUI
struct AccountsMenuView: View {
@EnvironmentObject<AccountsModel> private var model
@EnvironmentObject<InstancesModel> private var instancesModel
@EnvironmentObject<InvidiousAPI> private var api
@Default(.instances) private var instances
var body: some View {
Menu {
ForEach(instances) { instance in
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
api.setAccount(instance.anonymousAccount)
}
ForEach(instancesModel.accounts(instance.id)) { account in
Button(accountButtonTitle(instance: instance, account: account)) {
api.setAccount(account)
}
ForEach(model.all, id: \.id) { account in
Button(accountButtonTitle(account: account)) {
model.setAccount(account)
}
}
} label: {
Label(api.account?.name ?? "Accounts", systemImage: "person.crop.circle")
Label(model.account?.name ?? "Select Account", 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
func accountButtonTitle(account: Instance.Account) -> String {
instances.count > 1 ? "\(account.description)\(account.instance.description)" : account.description
}
}

View File

@@ -4,8 +4,7 @@ import SwiftUI
#endif
struct AppSidebarNavigation: View {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
#if os(iOS)
@EnvironmentObject<NavigationModel> private var navigation
@State private var didApplyPrimaryViewWorkAround = false
@@ -58,8 +57,8 @@ struct AppSidebarNavigation: View {
.help(
"Switch Instances and Accounts\n" +
"Current Instance: \n" +
"\(api.account?.url ?? "Not Set")\n" +
"Current User: \(api.account?.description ?? "Not set")"
"\(accounts.account?.url ?? "Not Set")\n" +
"Current User: \(accounts.account?.description ?? "Not set")"
)
}
}

View File

@@ -1,8 +1,9 @@
import Defaults
import Siesta
import SwiftUI
struct ContentView: View {
@StateObject private var api = InvidiousAPI()
@StateObject private var accounts = AccountsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var player = PlayerModel()
@@ -29,8 +30,8 @@ struct ContentView: View {
TVNavigationView()
#endif
}
.onAppear(perform: configureAPI)
.environmentObject(api)
.onAppear(perform: configure)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@@ -41,7 +42,7 @@ struct ContentView: View {
#if os(iOS)
.fullScreenCover(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.environmentObject(api)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@@ -51,7 +52,7 @@ struct ContentView: View {
.sheet(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.frame(minWidth: 900, minHeight: 800)
.environmentObject(api)
.environmentObject(accounts)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
@@ -61,31 +62,30 @@ struct ContentView: View {
#if !os(tvOS)
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
.environmentObject(api)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigation.editedPlaylist)
.environmentObject(api)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingSettings) {
SettingsView()
.environmentObject(api)
.environmentObject(instances)
}
#endif
}
func configureAPI() {
if let account = instances.defaultAccount, api.account.isEmpty {
api.setAccount(account)
func configure() {
SiestaLog.Category.enabled = .common
if let account = instances.defaultAccount {
accounts.setAccount(account)
}
player.api = api
playlists.api = api
search.api = api
subscriptions.api = api
player.accounts = accounts
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
}
}

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct Sidebar: View {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
var body: some View {
@@ -12,7 +12,7 @@ struct Sidebar: View {
AppSidebarRecents()
.id("recentlyOpened")
if api.signedIn {
if accounts.signedIn {
AppSidebarSubscriptions()
AppSidebarPlaylists()
}
@@ -31,7 +31,7 @@ struct Sidebar: View {
.accessibility(label: Text("Watch Now"))
}
if api.signedIn {
if accounts.signedIn {
NavigationLink(destination: LazyView(SubscriptionsView()), tag: TabSelection.subscriptions, selection: $navigation.tabSelection) {
Label("Subscriptions", systemImage: "star.circle")
.accessibility(label: Text("Subscriptions"))

View File

@@ -1,3 +1,4 @@
import Defaults
import Foundation
import SwiftUI
@@ -5,47 +6,71 @@ struct PlaybackBar: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<PlayerModel> private var player
var body: some View {
HStack {
closeButton
.frame(width: 80, alignment: .leading)
if player.currentItem != nil {
Text(playbackStatus)
.foregroundColor(.gray)
.font(.caption2)
.frame(minWidth: 130, maxWidth: .infinity)
VStack {
if player.stream != nil {
Text(currentStreamString)
} else {
if player.currentVideo!.live {
Image(systemName: "dot.radiowaves.left.and.right")
} else {
Image(systemName: "bolt.horizontal.fill")
}
Spacer()
HStack(spacing: 4) {
if player.currentVideo!.live {
Image(systemName: "dot.radiowaves.left.and.right")
} else if player.isLoadingAvailableStreams || player.isLoadingStream {
Image(systemName: "bolt.horizontal.fill")
}
streamControl
.disabled(player.isLoadingAvailableStreams)
.frame(alignment: .trailing)
.environment(\.colorScheme, .dark)
.onChange(of: player.streamSelection) { selection in
guard !selection.isNil else {
return
}
player.upgradeToStream(selection!)
}
#if os(macOS)
.frame(maxWidth: 180)
#endif
}
.transaction { t in t.animation = .none }
.foregroundColor(.gray)
.font(.caption2)
.frame(width: 80, alignment: .trailing)
.fixedSize(horizontal: true, vertical: true)
} else {
Spacer()
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.padding(4)
.background(.black)
}
var currentStreamString: String {
"\(player.stream!.resolution.height)p"
private var closeButton: some View {
Button {
dismiss()
} label: {
Label(
"Close",
systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill"
)
.labelStyle(.iconOnly)
}
.accessibilityLabel(Text("Close"))
.buttonStyle(.borderless)
.foregroundColor(.gray)
.keyboardShortcut(.cancelAction)
}
var playbackStatus: String {
private var playbackStatus: String {
if player.live {
return "LIVE"
}
@@ -66,17 +91,61 @@ struct PlaybackBar: View {
return "ends at \(timeFinishAtString)"
}
var closeButton: some View {
Button {
dismiss()
} label: {
Label("Close", systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill")
.labelStyle(.iconOnly)
}
.accessibilityLabel(Text("Close"))
.buttonStyle(.borderless)
.foregroundColor(.gray)
.keyboardShortcut(.cancelAction)
private var streamControl: some View {
#if os(macOS)
Picker("", selection: $player.streamSelection) {
ForEach(instances.all) { instance in
let instanceStreams = availableStreamsForInstance(instance)
if !instanceStreams.values.isEmpty {
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
Section(header: Text(instance.longDescription)) {
ForEach(kinds, id: \.self) { key in
ForEach(instanceStreams[key] ?? []) { stream in
Text(stream.quality).tag(Stream?.some(stream))
}
if kinds.count > 1 {
Divider()
}
}
}
}
}
}
#else
Menu {
ForEach(instances.all) { instance in
let instanceStreams = availableStreamsForInstance(instance)
if !instanceStreams.values.isEmpty {
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
Picker("", selection: $player.streamSelection) {
ForEach(kinds, id: \.self) { key in
ForEach(instanceStreams[key] ?? []) { stream in
Text(stream.description).tag(Stream?.some(stream))
}
if kinds.count > 1 {
Divider()
}
}
}
}
}
} label: {
Text(player.streamSelection?.quality ?? "")
}
#endif
}
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
let streams = player.availableStreams.filter { $0.instance == instance }.sorted(by: streamsSorter)
return Dictionary(grouping: streams, by: \.kind!)
}
private func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
}
}

View File

@@ -2,7 +2,6 @@ import Defaults
import SwiftUI
struct Player: UIViewControllerRepresentable {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlayerModel> private var player
var controller: PlayerViewController?
@@ -18,11 +17,8 @@ struct Player: UIViewControllerRepresentable {
let controller = PlayerViewController()
player.controller = controller
controller.playerModel = player
controller.api = api
controller.resolution = Defaults[.quality]
player.controller = controller
return controller
}

View File

@@ -3,11 +3,9 @@ import Logging
import SwiftUI
final class PlayerViewController: UIViewController {
var api: InvidiousAPI!
var playerLoaded = false
var playerModel: PlayerModel!
var playerViewController = AVPlayerViewController()
var resolution: Stream.ResolutionSetting!
var shouldResume = false
override func viewWillAppear(_ animated: Bool) {
@@ -81,7 +79,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
if shouldResume {
playerModel.player.play()
playerModel.play()
}
dismiss(animated: false)

View File

@@ -4,9 +4,9 @@ import SwiftUI
struct VideoDetailsPaddingModifier: ViewModifier {
static var defaultAdditionalDetailsPadding: Double {
#if os(macOS)
20
30
#else
35
40
#endif
}

View File

@@ -57,10 +57,12 @@ struct VideoPlayerSizeModifier: ViewModifier {
var maxHeight: Double {
#if os(iOS)
verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
let height = verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
#else
geometry.size.height - minimumHeightLeft
let height = geometry.size.height - minimumHeightLeft
#endif
return [height, 0].max()!
}
var edgesIgnoringSafeArea: Edge.Set {

View File

@@ -115,7 +115,7 @@ struct AccountFormView: View {
isValidating = true
validationDebounce.debouncing(1) {
validator.validateAccount()
validator.validateInvidiousAccount()
}
}
@@ -132,6 +132,7 @@ struct AccountFormView: View {
private var validator: AccountValidator {
AccountValidator(
app: .constant(instance.app),
url: instance.url,
account: Instance.Account(instanceID: instance.id, url: instance.url, sid: sid),
id: $sid,

View File

@@ -13,6 +13,18 @@ struct AccountsSettingsView: View {
}
var body: some View {
Group {
if instance.supportsAccounts {
accounts
} else {
Text("Accounts are not supported for the application of this instance")
.foregroundColor(.secondary)
}
}
.navigationTitle(instance.shortDescription)
}
var accounts: some View {
List {
Section(header: Text("Accounts"), footer: sectionFooter) {
ForEach(instances.accounts(instanceID), id: \.self) { account in
@@ -59,7 +71,6 @@ struct AccountsSettingsView: View {
}
}
}
.navigationTitle(instance.shortDescription)
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
AccountFormView(instance: instance)
}

View File

@@ -5,6 +5,7 @@ struct InstanceFormView: View {
@State private var name = ""
@State private var url = ""
@State private var app = Instance.App.invidious
@State private var isValid = false
@State private var isValidated = false
@@ -37,7 +38,7 @@ struct InstanceFormView: View {
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(.thickMaterial)
#else
.frame(width: 400, height: 150)
.frame(width: 400, height: 190)
#endif
}
@@ -73,6 +74,13 @@ struct InstanceFormView: View {
private var formFields: some View {
Group {
Picker("Application", selection: $app) {
ForEach(Instance.App.allCases, id: \.self) { app in
Text(app.rawValue.capitalized).tag(app)
}
}
.pickerStyle(.segmented)
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
.focused($nameFieldFocused)
@@ -104,6 +112,7 @@ struct InstanceFormView: View {
var validator: AccountValidator {
AccountValidator(
app: $app,
url: url,
id: $url,
isValid: $isValid,
@@ -137,7 +146,7 @@ struct InstanceFormView: View {
return
}
savedInstanceID = instancesModel.add(name: name, url: url).id
savedInstanceID = instancesModel.add(app: app, name: name, url: url).id
dismiss()
}

View File

@@ -19,8 +19,10 @@ struct InstancesSettingsView: View {
Group {
Section(header: Text("Instances"), footer: DefaultAccountHint()) {
ForEach(instances) { instance in
NavigationLink(instance.description) {
AccountsSettingsView(instanceID: instance.id)
Group {
NavigationLink(instance.longDescription) {
AccountsSettingsView(instanceID: instance.id)
}
}
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {

View File

@@ -0,0 +1,5 @@
import Siesta
import SwiftyJSON
let SwiftyJSONTransformer =
ResponseContentTransformer(transformErrors: true) { JSON($0.content as AnyObject) }

View File

@@ -11,14 +11,14 @@ struct TrendingView: View {
@State private var presentingCountrySelection = false
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
init(_ videos: [Video] = [Video]()) {
self.videos = videos
}
var resource: Resource {
let resource = api.trending(category: category, country: country)
let resource = accounts.invidious.trending(category: category, country: country)
resource.addObserver(store)
return resource

View File

@@ -6,7 +6,7 @@ struct ChannelVideosView: View {
@StateObject private var store = Store<Channel>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@@ -99,7 +99,7 @@ struct ChannelVideosView: View {
}
var resource: Resource {
let resource = api.channel(channel.id)
let resource = accounts.invidious.channel(channel.id)
resource.addObserver(store)
return resource

View File

@@ -52,6 +52,10 @@ struct PlayerControlsView<Content: View>: View {
.padding(.vertical, 20)
.contentShape(Rectangle())
}
#if !os(tvOS)
.keyboardShortcut("o")
#endif
Group {
if model.isPlaying {
Button(action: {
@@ -65,7 +69,7 @@ struct PlayerControlsView<Content: View>: View {
}) {
Label("Play", systemImage: "play.fill")
}
.disabled(model.player.currentItem == nil)
.disabled(model.player.currentItem.isNil)
}
}
.frame(minWidth: 30)

View File

@@ -4,10 +4,10 @@ import SwiftUI
struct PopularView: View {
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
var resource: Resource {
api.popular
accounts.invidious.popular
}
var body: some View {

View File

@@ -5,12 +5,14 @@ struct SignInRequiredView<Content: View>: View {
let title: String
let content: Content
@EnvironmentObject<InvidiousAPI> private var api
@Default(.instances) private var instances
@EnvironmentObject<AccountsModel> private var accounts
#if !os(macOS)
@EnvironmentObject<NavigationModel> private var navigation
#endif
@Default(.instances) private var instances
init(title: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.content = content()
@@ -18,7 +20,7 @@ struct SignInRequiredView<Content: View>: View {
var body: some View {
Group {
if api.signedIn {
if accounts.signedIn {
content
} else {
prompt

View File

@@ -4,7 +4,11 @@ import SwiftUI
struct SubscriptionsView: View {
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
var api: InvidiousAPI {
accounts.invidious
}
var feed: Resource {
api.feed
@@ -17,10 +21,7 @@ struct SubscriptionsView: View {
.onAppear {
loadResources()
}
.onChange(of: api.account) { _ in
loadResources(force: true)
}
.onChange(of: feed) { _ in
.onChange(of: accounts.account) { _ in
loadResources(force: true)
}
}

View File

@@ -8,7 +8,7 @@ struct WatchNowSection: View {
@StateObject private var store = Store<[Video]>()
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
init(resource: Resource, label: String) {
self.resource = resource
@@ -21,7 +21,7 @@ struct WatchNowSection: View {
resource.addObserver(store)
resource.loadIfNeeded()
}
.onChange(of: api.account) { _ in
.onChange(of: accounts.account) { _ in
resource.load()
}
}

View File

@@ -3,12 +3,16 @@ import Siesta
import SwiftUI
struct WatchNowView: View {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<AccountsModel> private var accounts
var api: InvidiousAPI! {
accounts.invidious
}
var body: some View {
PlayerControlsView {
ScrollView(.vertical, showsIndicators: false) {
if api.validInstance {
if !accounts.account.isNil {
VStack(alignment: .leading, spacing: 0) {
if api.signedIn {
WatchNowSection(resource: api.feed, label: "Subscriptions")