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

View File

@@ -5,7 +5,7 @@ import SwiftUI
final class AccountValidator: Service { final class AccountValidator: Service {
let app: Binding<VideosApp> let app: Binding<VideosApp>
let url: String let url: String
let account: Account? let account: Account!
var formObjectID: Binding<String> var formObjectID: Binding<String>
var isValid: Binding<Bool> var isValid: Binding<Bool>
@@ -46,7 +46,11 @@ final class AccountValidator: Service {
return 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() reset()
feed accountRequest
.load() .onSuccess { response in
.onSuccess { _ in guard self.account!.username == self.formObjectID.wrappedValue else {
guard self.account!.sid == self.formObjectID.wrappedValue else {
return 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 .onFailure { _ in
guard self.account!.sid == self.formObjectID.wrappedValue else { guard self.account!.username == self.formObjectID.wrappedValue else {
return 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() { func reset() {
isValid.wrappedValue = false isValid.wrappedValue = false
isValidated.wrappedValue = false isValidated.wrappedValue = false
@@ -116,8 +136,12 @@ final class AccountValidator: Service {
error?.wrappedValue = nil error?.wrappedValue = nil
} }
var cookieHeader: String { var invidiousCookieHeader: String {
"SID=\(account!.sid)" "SID=\(account.username)"
}
var login: Resource {
resource("/login")
} }
var feed: Resource { var feed: Resource {

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import Siesta
import SwiftyJSON import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI { final class PipedAPI: Service, ObservableObject, VideosAPI {
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
@Published var account: Account! @Published var account: Account!
var anonymousAccount: Account { var anonymousAccount: Account {
@@ -27,10 +29,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
} }
func configure() { func configure() {
invalidateConfiguration()
configure { configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"]) $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 configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
PipedAPI.extractChannel(from: content.json) PipedAPI.extractChannel(from: content.json)
} }
@@ -54,6 +62,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init) 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 { func channel(_ id: String) -> Resource {
@@ -88,15 +128,34 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
resource(baseURL: account.instance.apiURL, path: "streams/\(id)") 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 home: Resource? { nil }
var popular: Resource? { nil } var popular: Resource? { nil }
var playlists: 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 playlist(_: String) -> Resource? { nil }
func playlistVideo(_: String, _: 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 author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
return Video( return Video(
videoID: PipedAPI.extractID(from: content), videoID: PipedAPI.extractID(from: content),
title: details["title"]!.stringValue, title: details["title"]!.stringValue,
author: author, author: author,
length: details["duration"]!.doubleValue, length: details["duration"]!.doubleValue,
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue, published: published,
views: details["views"]!.intValue, views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content), description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author), channel: Channel(id: channelId, name: author),

View File

@@ -20,7 +20,8 @@ protocol VideosAPI {
var popular: Resource? { get } var popular: Resource? { get }
var playlists: 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 playlist(_ id: String) -> Resource?
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
@@ -80,6 +81,6 @@ extension VideosAPI {
urlComponents.queryItems = queryItems urlComponents.queryItems = queryItems
} }
return urlComponents.url! return urlComponents.url
} }
} }

View File

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

View File

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

View File

@@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject {
} }
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) { 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 = {}) { 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 { func isSubscribing(_ channelID: String) -> Bool {
@@ -31,6 +35,9 @@ final class SubscriptionsModel: ObservableObject {
} }
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) { func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard accounts.app.supportsSubscriptions else {
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded() let request = force ? resource?.load() : resource?.loadIfNeeded()
request? request?
@@ -45,8 +52,8 @@ final class SubscriptionsModel: ObservableObject {
} }
} }
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) { private func scheduleLoad(onSuccess: @escaping () -> Void) {
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.load(force: true, onSuccess: onSuccess) 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. 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) ![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
## Features ## Features
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/) * Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
* Multiple instances and accounts, fast switching * 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 * Player queue and history
* Fullscreen playback and Picture in Picture * Fullscreen playback, Picture in Picture and AirPlay support
* Stream quality selection * Stream quality selection
* Favorites: customizable section of channels, playlists, trending, searches and other views * Favorites: customizable section of channels, playlists, trending, searches and other views
* AirPlay support * URL Scheme for integrations
* Safari Extension for macOS and iOS for redirecting to the app
* URL Scheme for easy integrations
### Availability ### Availability
| Feature | Invidious | Piped | | Feature | Invidious | Piped |
| - | - | - | | - | - | - |
| User Accounts | ✅ | 🔴 | | User Accounts | ✅ | |
| Subscriptions | ✅ | 🔴 | | Subscriptions | ✅ | |
| Popular | ✅ | 🔴 | | Popular | ✅ | 🔴 |
| User Playlists | ✅ | 🔴 | | User Playlists | ✅ | 🔴 |
| Trending | ✅ | ✅ | | Trending | ✅ | ✅ |
@@ -34,24 +38,21 @@ Video player with support for [Invidious](https://github.com/iv-org/invidious) a
## Installation ## Installation
### Requirements ### 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? ### How to install?
#### [AltStore](https://altstore.io/) #### [AltStore](https://altstore.io/)
You can sideload IPA files that you can download from Releases page. 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 #### 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. 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 ## 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 ### 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 ```js
{ {
match: [ 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 ## Screenshots
### iOS ### iOS
| Player | Search | Playlists | | Player | Search | Playlists |
@@ -82,7 +91,7 @@ With [Finicky](https://github.com/johnste/finicky) you can configure your system
## Tips ## Tips
### Settings ### 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 ### Navigation
* Use videos context menus to add to queue, open or subscribe channel and add to playlist * 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. * [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+S` - Play Next
* `Command+O` - Toggle Player * `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 ## License and Liability
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license. 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) .opacity(dragging?.id == item.id ? 0.5 : 1)
.onAppear { .onAppear {
resource?.addObserver(store) resource?.addObserver(store)
resource?.loadIfNeeded() resource?.load()
} }
#if !os(tvOS) #if !os(tvOS)
.onDrag { .onDrag {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,8 +39,13 @@ struct SubscriptionsView: View {
fileprivate func loadResources(force: Bool = false) { fileprivate func loadResources(force: Bool = false) {
feed?.addObserver(store) feed?.addObserver(store)
if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() { if accounts.app == .invidious {
request.onSuccess { _ in // 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) loadFeed(force: force)
} }
} else { } else {

View File

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

View File

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

View File

@@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/Alamofire/Alamofire.git", "repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "f96b619bcb2383b43d898402283924b80e2c4bae", "revision": "d120af1e8638c7da36c8481fd61a66c0c08dc4fc",
"version": "5.4.3" "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") Text("If provided, you can copy links from videos, channels and playlist")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer()
} }
if selectedInstance != nil, !selectedInstance.app.supportsAccounts { if selectedInstance != nil, !selectedInstance.app.supportsAccounts {

View File

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