Compare commits

...

9 Commits
v1.0 ... v1.1

Author SHA1 Message Date
Arkadiusz Fal
e88219ce88 Bump version 2021-11-16 22:12:40 +01:00
Arkadiusz Fal
adf4157ff3 Update README 2021-11-16 21:12:47 +01:00
Arkadiusz Fal
c78dd4a35e Enable text selection for video description 2021-11-15 19:28:21 +01:00
Arkadiusz Fal
8d2694df33 Merge pull request #2 from yattee/feature/piped-accounts
Add support for logging in with Piped accounts, viewing feed and managing subscriptions.
2021-11-15 19:21:01 +01:00
Arkadiusz Fal
0e3effd512 Add support for Piped accounts and subscriptions 2021-11-15 18:58:45 +01:00
Arkadiusz Fal
a70d4f3b38 Fix share URLs 2021-11-13 16:45:47 +01:00
Arkadiusz Fal
6328bfbfab Remove development team 2021-11-12 21:54:55 +01:00
Arkadiusz Fal
184992ea32 Bump packages 2021-11-12 21:54:03 +01:00
Arkadiusz Fal
dd8d6b6c4a Fix removing instance 2021-11-12 21:46:15 +01:00
31 changed files with 346 additions and 157 deletions

View File

@@ -14,4 +14,13 @@ extension Double {
return formatter.string(from: self)
}
func formattedAsRelativeTime() -> String? {
let date = Date(timeIntervalSince1970: self)
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
}

View File

@@ -8,7 +8,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
let instanceID: String
var name: String?
let url: String
let sid: String
let username: String
let password: String?
var token: String?
let anonymous: Bool
init(
@@ -16,7 +18,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
instanceID: String? = nil,
name: String? = nil,
url: String? = nil,
sid: String? = nil,
username: String? = nil,
password: String? = nil,
token: String? = nil,
anonymous: Bool = false
) {
self.anonymous = anonymous
@@ -25,27 +29,29 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.sid = sid ?? ""
self.username = username ?? ""
self.token = token
self.password = password ?? ""
}
var instance: Instance {
Defaults[.instances].first { $0.id == instanceID }!
var instance: Instance! {
Defaults[.instances].first { $0.id == instanceID }
}
var anonymizedSID: String {
guard sid.count > 3 else {
return ""
var shortUsername: String {
guard username.count > 10 else {
return username
}
let index = sid.index(sid.startIndex, offsetBy: 4)
return String(sid[..<index])
let index = username.index(username.startIndex, offsetBy: 11)
return String(username[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
(name != nil && name!.isEmpty) ? shortUsername : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
hasher.combine(username)
}
}

View File

@@ -5,7 +5,7 @@ import SwiftUI
final class AccountValidator: Service {
let app: Binding<VideosApp>
let url: String
let account: Account?
let account: Account!
var formObjectID: Binding<String>
var isValid: Binding<Bool>
@@ -46,7 +46,11 @@ final class AccountValidator: Service {
return
}
$0.headers["Cookie"] = self.cookieHeader
$0.headers["Cookie"] = self.invidiousCookieHeader
}
configure("/login", requestMethods: [.post]) {
$0.headers["Content-Type"] = "application/json"
}
}
@@ -84,20 +88,27 @@ final class AccountValidator: Service {
}
}
func validateInvidiousAccount() {
func validateAccount() {
reset()
feed
.load()
.onSuccess { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
accountRequest
.onSuccess { response in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = true
switch self.app.wrappedValue {
case .invidious:
self.isValid.wrappedValue = true
case .piped:
let error = response.json.dictionaryValue["error"]?.string
let token = response.json.dictionaryValue["token"]?.string
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
self.error!.wrappedValue = error
}
}
.onFailure { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else {
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
@@ -109,6 +120,15 @@ final class AccountValidator: Service {
}
}
var accountRequest: Request {
switch app.wrappedValue {
case .invidious:
return feed.load()
case .piped:
return login.request(.post, json: ["username": account.username, "password": account.password])
}
}
func reset() {
isValid.wrappedValue = false
isValidated.wrappedValue = false
@@ -116,8 +136,12 @@ final class AccountValidator: Service {
error?.wrappedValue = nil
}
var cookieHeader: String {
"SID=\(account!.sid)"
var invidiousCookieHeader: String {
"SID=\(account.username)"
}
var login: Resource {
resource("/login")
}
var feed: Resource {

View File

@@ -15,7 +15,8 @@ struct AccountsBridge: Defaults.Bridge {
"instanceID": value.instanceID,
"name": value.name ?? "",
"apiURL": value.url,
"sid": value.sid
"username": value.username,
"password": value.password ?? ""
]
}
@@ -25,13 +26,14 @@ struct AccountsBridge: Defaults.Bridge {
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["apiURL"],
let sid = object["sid"]
let username = object["username"]
else {
return nil
}
let name = object["name"] ?? ""
let password = object["password"]
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
}
}

View File

@@ -23,7 +23,7 @@ final class AccountsModel: ObservableObject {
}
var app: VideosApp {
current?.instance.app ?? .invidious
current?.instance?.app ?? .invidious
}
var api: VideosAPI {
@@ -35,7 +35,7 @@ final class AccountsModel: ObservableObject {
}
var signedIn: Bool {
!isEmpty && !current.anonymous
!isEmpty && !current.anonymous && api.signedIn
}
init() {
@@ -74,8 +74,14 @@ final class AccountsModel: ObservableObject {
Defaults[.accounts].first { $0.id == id }
}
static func add(instance: Instance, name: String, sid: String) -> Account {
let account = Account(instanceID: instance.id, name: name, url: instance.apiURL, sid: sid)
static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account {
let account = Account(
instanceID: instance.id,
name: name,
url: instance.apiURL,
username: username,
password: password
)
Defaults[.accounts].append(account)
return account

View File

@@ -70,7 +70,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func configure() {
configure {
if !self.account.sid.isEmpty {
if !self.account.username.isEmpty {
$0.headers["Cookie"] = self.cookieHeader
}
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
@@ -160,7 +160,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
private var cookieHeader: String {
"SID=\(account.sid)"
"SID=\(account.username)"
}
var popular: Resource? {
@@ -185,8 +185,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func channelSubscription(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.post)
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
.child(channelID)
.request(.delete)
.onCompletion { _ in onCompletion() }
}
func channel(_ id: String) -> Resource {
@@ -202,7 +212,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
var playlists: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
if account.isNil || account.anonymous {
return nil
}
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource? {

View File

@@ -4,6 +4,8 @@ import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
@Published var account: Account!
var anonymousAccount: Account {
@@ -27,10 +29,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
func configure() {
invalidateConfiguration()
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
$0.headers["Authorization"] = self.account.token
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
PipedAPI.extractChannel(from: content.json)
}
@@ -54,6 +62,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init)
}
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
}
if account.token.isNil {
updateToken()
}
}
func needsAuthorization(_ url: URL) -> Bool {
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
@discardableResult func updateToken() -> Request {
account.token = nil
return login.request(
.post,
json: ["username": account.username, "password": account.password]
)
.onSuccess { response in
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
self.configure()
}
}
var login: Resource {
resource(baseURL: account.url, path: "login")
}
func channel(_ id: String) -> Resource {
@@ -88,15 +128,34 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
}
var signedIn: Bool { false }
var signedIn: Bool {
!account.anonymous && !(account.token?.isEmpty ?? true)
}
var subscriptions: Resource? {
resource(baseURL: account.instance.apiURL, path: "subscriptions")
}
var feed: Resource? {
resource(baseURL: account.instance.apiURL, path: "feed")
.withParam("authToken", account.token)
}
var subscriptions: Resource? { nil }
var feed: Resource? { nil }
var home: Resource? { nil }
var popular: Resource? { nil }
var playlists: Resource? { nil }
func channelSubscription(_: String) -> Resource? { nil }
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "subscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func playlist(_: String) -> Resource? { nil }
func playlistVideo(_: String, _: String) -> Resource? { nil }
@@ -211,13 +270,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
return Video(
videoID: PipedAPI.extractID(from: content),
title: details["title"]!.stringValue,
author: author,
length: details["duration"]!.doubleValue,
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
published: published,
views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author),

View File

@@ -20,7 +20,8 @@ protocol VideosAPI {
var popular: Resource? { get }
var playlists: Resource? { get }
func channelSubscription(_ id: String) -> Resource?
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
func playlist(_ id: String) -> Resource?
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
@@ -80,6 +81,6 @@ extension VideosAPI {
urlComponents.queryItems = queryItems
}
return urlComponents.url!
return urlComponents.url
}
}

View File

@@ -8,7 +8,11 @@ enum VideosApp: String, CaseIterable {
}
var supportsAccounts: Bool {
self == .invidious
true
}
var accountsUsePassword: Bool {
self == .piped
}
var supportsPopular: Bool {

View File

@@ -28,6 +28,11 @@ final class PlaylistsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard !resource.isNil else {
playlists = []
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded()
guard !request.isNil else {

View File

@@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject {
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .post, onSuccess: onSuccess)
accounts.api.subscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
performRequest(channelID, method: .delete, onSuccess: onSuccess)
accounts.api.unsubscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func isSubscribing(_ channelID: String) -> Bool {
@@ -31,6 +35,9 @@ final class SubscriptionsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard accounts.app.supportsSubscriptions else {
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded()
request?
@@ -45,8 +52,8 @@ final class SubscriptionsModel: ObservableObject {
}
}
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
private func scheduleLoad(onSuccess: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.load(force: true, onSuccess: onSuccess)
}
}

View File

@@ -3,25 +3,29 @@
Video player with support for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS 15, tvOS 15 and macOS Monterey.
[![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)
![GitHub issues](https://img.shields.io/github/issues/yattee/app)
![GitHub pull requests](https://img.shields.io/github/issues-pr/yattee/app)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org)
![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
## Features
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
* Multiple instances and accounts, fast switching
* [SponsorBlock](https://sponsor.ajay.app/) with selection of categories to skip
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
* Player queue and history
* Fullscreen playback and Picture in Picture
* Fullscreen playback, Picture in Picture and AirPlay support
* Stream quality selection
* Favorites: customizable section of channels, playlists, trending, searches and other views
* AirPlay support
* Safari Extension for macOS and iOS for redirecting to the app
* URL Scheme for easy integrations
* URL Scheme for integrations
### Availability
| Feature | Invidious | Piped |
| - | - | - |
| User Accounts | ✅ | 🔴 |
| Subscriptions | ✅ | 🔴 |
| User Accounts | ✅ | |
| Subscriptions | ✅ | |
| Popular | ✅ | 🔴 |
| User Playlists | ✅ | 🔴 |
| Trending | ✅ | ✅ |
@@ -34,24 +38,21 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
## Installation
### Requirements
Application is built using latest APIs, that's why for now **only recent** software versions: iOS/tvOS 15 or macOS Monterey are supported.
Only iOS/tvOS 15 and macOS Monterey are supported.
### How to install?
#### [AltStore](https://altstore.io/)
You can sideload IPA files that you can download from Releases page.
Alternatively, if you have to access to the beta AltStore version (v1.5), you can add the following repository in `Browse > Sources` screen: `https://alt.yattee.stream`
Alternatively, if you have to access to the beta AltStore version (v1.5), you can add the following repository in `Browse > Sources` screen:
`https://alt.yattee.stream`
#### Manual installation
Download sources and compile them on a Mac using Xcode, install to your devices. Please note that if you are not registered in Apple Developer Program then the applications will require reinstalling every 7 days.
## Integrations
### Safari
macOS and iOS apps include Safari extension which will redirect opened YouTube tabs to the app.
### Firefox
You can use [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect) extension to make the videos open in the app. In extension settings put the following URL as Invidious instance: `https://r.yatte.stream`
### macOS
With [Finicky](https://github.com/johnste/finicky) you can configure your systems so the video links across the entire system will get opened in the app. Example configuration:
With [Finicky](https://github.com/johnste/finicky) you can configure your system to open all the video links in the app. Example configuration:
```js
{
match: [
@@ -62,6 +63,14 @@ With [Finicky](https://github.com/johnste/finicky) you can configure your system
}
```
### Experimental: Safari
macOS and iOS apps include Safari extension which will redirect opened YouTube tabs to the app.
### Expermiental: Firefox
You can use [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect) extension to make the videos open in the app. In extension settings put the following URL as Invidious instance:
`https://r.yatte.stream`
## Screenshots
### iOS
| Player | Search | Playlists |
@@ -82,7 +91,7 @@ With [Finicky](https://github.com/johnste/finicky) you can configure your system
## Tips
### Settings
* [tvOS] To open settings press Play/Pause button while hovering over navigation menu or video
* [tvOS] To open settings, press Play/Pause button while hovering over navigation menu or video
### Navigation
* Use videos context menus to add to queue, open or subscribe channel and add to playlist
* [tvOS] Pressing buttons in the app trigger switch to next available option (for example: next account in Settings). If you want to access list of all options, press and hold to open the context menu.
@@ -102,11 +111,6 @@ With [Finicky](https://github.com/johnste/finicky) you can configure your system
* `Command+S` - Play Next
* `Command+O` - Toggle Player
## Contributing
Every contribution to make this tool better is very welcome. Start with [creating issue](https://github.com/yattee/app/issues/new) to have discussion which can be later transformed into a Pull Request.
Review existing Issues and Pull Requests before creating new ones.
## License and Liability
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.

View File

@@ -52,7 +52,7 @@ struct FavoriteItemView: View {
.opacity(dragging?.id == item.id ? 0.5 : 1)
.onAppear {
resource?.addObserver(store)
resource?.loadIfNeeded()
resource?.load()
}
#if !os(tvOS)
.onDrag {

View File

@@ -15,7 +15,7 @@ struct AccountsMenuView: View {
}
}
} label: {
Label(model.current?.name ?? "Select Account", systemImage: "person.crop.circle")
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
.labelStyle(.titleAndIcon)
}
.disabled(instances.isEmpty)

View File

@@ -30,9 +30,6 @@ struct AppSidebarPlaylists: View {
newPlaylistButton
.padding(.top, 8)
}
.onAppear {
playlists.load()
}
}
var newPlaylistButton: some View {

View File

@@ -22,8 +22,5 @@ struct AppSidebarSubscriptions: View {
.id("channel\(channel.id)")
}
}
.onAppear {
subscriptions.load()
}
}
}

View File

@@ -3,6 +3,8 @@ import SwiftUI
struct Sidebar: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<SubscriptionsModel> private var subscriptions
var body: some View {
ScrollViewReader { scrollView in
@@ -13,12 +15,25 @@ struct Sidebar: View {
AppSidebarRecents()
.id("recentlyOpened")
if accounts.signedIn {
AppSidebarSubscriptions()
AppSidebarPlaylists()
if accounts.api.signedIn {
if accounts.app.supportsSubscriptions {
AppSidebarSubscriptions()
}
if accounts.app.supportsUserPlaylists {
AppSidebarPlaylists()
}
}
}
}
.onAppear {
subscriptions.load()
playlists.load()
}
.onChange(of: accounts.signedIn) { _ in
subscriptions.load(force: true)
playlists.load(force: true)
}
.onChange(of: navigation.sidebarSectionChanged) { _ in
scrollScrollViewToItem(scrollView: scrollView, for: navigation.tabSelection)
}

View File

@@ -14,7 +14,7 @@ struct VideoDetails: View {
@State private var confirmationShown = false
@State private var presentingAddToPlaylist = false
@State private var presentingShareSheet = false
@State private var shareURL = ""
@State private var shareURL: URL?
@State private var currentPage = Page.details
@@ -309,7 +309,9 @@ struct VideoDetails: View {
}
#if os(iOS)
.sheet(isPresented: $presentingShareSheet) {
ShareSheet(activityItems: [shareURL])
if let shareURL = shareURL {
ShareSheet(activityItems: [shareURL])
}
}
#endif
}
@@ -337,6 +339,7 @@ struct VideoDetails: View {
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
Text(description)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
.padding(.bottom, 4)

View File

@@ -6,11 +6,13 @@ struct AccountForm: View {
var selectedAccount: Binding<Account?>?
@State private var name = ""
@State private var sid = ""
@State private var username = ""
@State private var password = ""
@State private var isValid = false
@State private var isValidated = false
@State private var isValidating = false
@State private var validationError: String?
@State private var validationDebounce = Debounce()
@FocusState private var focused: Bool
@@ -67,21 +69,42 @@ struct AccountForm: View {
#endif
}
.onAppear(perform: initializeForm)
.onChange(of: sid) { _ in validate() }
.onChange(of: username) { _ in validate() }
.onChange(of: password) { _ in validate() }
}
var formFields: some View {
Group {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
if !instance.app.accountsUsePassword {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
}
TextField("SID", text: $sid, prompt: Text("Invidious SID Cookie"))
TextField("Username", text: $username, prompt: usernamePrompt)
if instance.app.accountsUsePassword {
SecureField("Password", text: $password, prompt: Text("Password"))
}
}
}
var usernamePrompt: Text {
switch instance.app {
case .invidious:
return Text("SID Cookie")
default:
return Text("Username")
}
}
var footer: some View {
HStack {
AccountValidationStatus(isValid: $isValid, isValidated: $isValidated, isValidating: $isValidating, error: .constant(nil))
AccountValidationStatus(
isValid: $isValid,
isValidated: $isValidated,
isValidating: $isValidating,
error: $validationError
)
Spacer()
@@ -106,7 +129,9 @@ struct AccountForm: View {
isValid = false
validationDebounce.invalidate()
guard !sid.isEmpty else {
let passwordIsValid = instance.app.accountsUsePassword ? !password.isEmpty : true
guard !username.isEmpty, passwordIsValid else {
validator.reset()
return
}
@@ -114,7 +139,7 @@ struct AccountForm: View {
isValidating = true
validationDebounce.debouncing(1) {
validator.validateInvidiousAccount()
validator.validateAccount()
}
}
@@ -123,7 +148,7 @@ struct AccountForm: View {
return
}
let account = AccountsModel.add(instance: instance, name: name, sid: sid)
let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
selectedAccount?.wrappedValue = account
dismiss()
@@ -133,11 +158,12 @@ struct AccountForm: View {
AccountValidator(
app: .constant(instance.app),
url: instance.apiURL,
account: Account(instanceID: instance.id, url: instance.apiURL, sid: sid),
id: $sid,
account: Account(instanceID: instance.id, url: instance.apiURL, username: username, password: password),
id: $username,
isValid: $isValid,
isValidated: $isValidated,
isValidating: $isValidating
isValidating: $isValidating,
error: $validationError
)
}
}

View File

@@ -17,8 +17,8 @@ struct AccountValidationStatus: View {
VStack(alignment: .leading) {
Text(isValid ? "Connected successfully" : "Connection failed")
if !isValid && !error.isNil {
Text(error!)
if let error = error, !isValid {
Text(error)
.font(.caption2)
.foregroundColor(.secondary)
.truncationMode(.tail)

View File

@@ -13,11 +13,14 @@ struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
#endif
@EnvironmentObject<AccountsModel> private var accounts
var body: some View {
#if os(macOS)
TabView {
Form {
InstancesSettings()
.environmentObject(accounts)
}
.tabItem {
Label("Instances", systemImage: "server.rack")
@@ -49,7 +52,7 @@ struct SettingsView: View {
.tag(Tabs.services)
}
.padding(20)
.frame(width: 400, height: 310)
.frame(width: 400, height: 380)
#else
NavigationView {
List {
@@ -63,6 +66,7 @@ struct SettingsView: View {
}
#endif
InstancesSettings()
.environmentObject(accounts)
BrowsingSettings()
PlaybackSettings()
ServicesSettings()

View File

@@ -54,7 +54,6 @@ struct VerticalCells: View {
#endif
}
var scrollViewShowsIndicators: Bool {
#if !os(tvOS)
true

View File

@@ -5,6 +5,7 @@ struct ChannelPlaylistView: View {
var playlist: ChannelPlaylist
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@StateObject private var store = Store<ChannelPlaylist>()
@@ -56,8 +57,8 @@ struct ChannelPlaylistView: View {
}
#if os(iOS)
.sheet(isPresented: $presentingShareSheet) {
if let url = accounts.api.shareURL(contentItem) {
ShareSheet(activityItems: [url])
if let shareURL = shareURL {
ShareSheet(activityItems: [shareURL])
}
}
#endif
@@ -70,7 +71,8 @@ struct ChannelPlaylistView: View {
ToolbarItem(placement: .navigation) {
ShareButton(
contentItem: contentItem,
presentingShareSheet: $presentingShareSheet
presentingShareSheet: $presentingShareSheet,
shareURL: $shareURL
)
}

View File

@@ -5,6 +5,7 @@ struct ChannelVideosView: View {
let channel: Channel
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@StateObject private var store = Store<Channel>()
@@ -79,7 +80,8 @@ struct ChannelVideosView: View {
ToolbarItem(placement: .navigation) {
ShareButton(
contentItem: contentItem,
presentingShareSheet: $presentingShareSheet
presentingShareSheet: $presentingShareSheet,
shareURL: $shareURL
)
}
@@ -100,8 +102,8 @@ struct ChannelVideosView: View {
#endif
#if os(iOS)
.sheet(isPresented: $presentingShareSheet) {
if let url = accounts.api.shareURL(contentItem) {
ShareSheet(activityItems: [url])
if let shareURL = shareURL {
ShareSheet(activityItems: [shareURL])
}
}
#endif

View File

@@ -3,7 +3,7 @@ import SwiftUI
struct ShareButton: View {
let contentItem: ContentItem
@Binding var presentingShareSheet: Bool
@Binding var shareURL: String
@Binding var shareURL: URL?
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
@@ -11,11 +11,11 @@ struct ShareButton: View {
init(
contentItem: ContentItem,
presentingShareSheet: Binding<Bool>,
shareURL: Binding<String>? = nil
shareURL: Binding<URL?>? = nil
) {
self.contentItem = contentItem
_presentingShareSheet = presentingShareSheet
_shareURL = shareURL ?? .constant("")
_shareURL = shareURL ?? .constant(nil)
}
var body: some View {
@@ -81,7 +81,7 @@ struct ShareButton: View {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url.absoluteString, forType: .string)
#else
shareURL = url.absoluteString
shareURL = url
presentingShareSheet = true
#endif
}

View File

@@ -39,8 +39,13 @@ struct SubscriptionsView: View {
fileprivate func loadResources(force: Bool = false) {
feed?.addObserver(store)
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() {
request.onSuccess { _ in
if accounts.app == .invidious {
// Invidious for some reason won't refresh feed until homepage is loaded
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() {
request.onSuccess { _ in
loadFeed(force: force)
}
} else {
loadFeed(force: force)
}
} else {

View File

@@ -26,6 +26,7 @@ struct YatteeApp: App {
#if os(macOS)
Settings {
SettingsView()
.environmentObject(AccountsModel())
.environmentObject(InstancesModel())
}
#endif

View File

@@ -2216,8 +2216,8 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -2250,8 +2250,8 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -2282,8 +2282,8 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
@@ -2314,8 +2314,8 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
@@ -2346,7 +2346,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
@@ -2355,7 +2355,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
@@ -2478,8 +2478,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
@@ -2493,7 +2493,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = iphoneos;
@@ -2510,8 +2510,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist;
@@ -2525,7 +2525,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = iphoneos;
@@ -2546,14 +2546,15 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = macOS/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMainStoryboardFile = Main;
LD_RUNPATH_SEARCH_PATHS = (
@@ -2561,7 +2562,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = macosx;
@@ -2580,14 +2581,15 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = macOS/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMainStoryboardFile = Main;
LD_RUNPATH_SEARCH_PATHS = (
@@ -2595,7 +2597,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = Yattee;
SDKROOT = macosx;
@@ -2610,7 +2612,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -2635,7 +2637,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -2662,7 +2664,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -2687,7 +2689,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -2711,9 +2713,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = tvOS/Info.plist;
@@ -2727,7 +2729,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
@@ -2744,9 +2746,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = tvOS/Info.plist;
@@ -2760,7 +2762,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
@@ -2778,7 +2780,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -2803,7 +2805,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -2827,7 +2829,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
@@ -2836,7 +2838,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
@@ -2845,7 +2847,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
@@ -2854,7 +2856,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
DEVELOPMENT_TEAM = "";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;

View File

@@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": {
"branch": null,
"revision": "f96b619bcb2383b43d898402283924b80e2c4bae",
"version": "5.4.3"
"revision": "d120af1e8638c7da36c8481fd61a66c0c08dc4fc",
"version": "5.4.4"
}
},
{

View File

@@ -86,8 +86,6 @@ struct InstancesSettings: View {
Text("If provided, you can copy links from videos, channels and playlist")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
if selectedInstance != nil, !selectedInstance.app.supportsAccounts {

View File

@@ -44,12 +44,7 @@ struct EditFavorites: View {
ForEach(model.addableItems()) { item in
HStack {
HStack {
Text(label(item))
Spacer()
Text("only with Invidious")
.foregroundColor(.secondary)
}
Text(label(item))
Spacer()