Compare commits

..

2 Commits

Author SHA1 Message Date
Arkadiusz Fal
37a315e75a iOS 14/macOS Big Sur Support 2021-11-30 18:43:21 +01:00
Arkadiusz Fal
f47d8ed752 Remove alpha channel from iOS icons 2021-11-28 11:21:11 +01:00
810 changed files with 16963 additions and 164582 deletions

View File

@@ -1,15 +0,0 @@
# Yattee UI Test Configuration
# Copy this file to .env and fill in your values
# Invidious credentials for UI tests (required for import_subscriptions tests)
INVIDIOUS_EMAIL=
INVIDIOUS_PASSWORD=
# Piped credentials for UI tests (required for import tests)
PIPED_USERNAME=
PIPED_PASSWORD=
# Optional: Override default test URLs
# YATTEE_SERVER_URL=https://yp.home.arekf.net
# INVIDIOUS_URL=https://invidious.home.arekf.net
# PIPED_URL=https://pipedapi.home.arekf.net

5
.github/FUNDING.yml vendored
View File

@@ -1,5 +0,0 @@
# These are supported funding model platforms
github: yattee
patreon: arekf
custom: https://github.com/yattee/yattee/wiki/Donations

View File

@@ -1,74 +0,0 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: "bug"
body:
- type: markdown
attributes:
value: |
**Before You Submit Your Issue**
- This is not a right place to ask questions. Use [Discussions](https://github.com/yattee/yattee/discussions) or contact directly via [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org)
- type: checkboxes
attributes:
label: Guidelines
description: Please ensure you've completed the following
options:
- label: I have searched the [issue tracker](https://github.com/yattee/yattee/issues) and I haven't found bug report like this
required: true
- type: textarea
attributes:
label: Current Behavior
description: Describe what happened and the steps you have done to encounter the problem
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: Describe what you expected to happen
validations:
required: true
- type: input
attributes:
label: Device & OS Version
description: What device and operating system version are you running? You can find it in device settings
placeholder: e.g. iPhone, iOS 16.1
validations:
required: true
- type: input
attributes:
label: App Build
description: What build of the application are you running? You can find this in the bottom of the Settings page
placeholder: e.g. 112
validations:
required: true
- type: textarea
attributes:
label: App Settings
description: If the issue is related to some non-default configuration, please provide the settings you have changed
validations:
required: false
- type: textarea
attributes:
label: Crash log
description: If you are reporing a crash, attach crash log here
placeholder: |
1. Go to 'Settings'
2. Click on 'Privacy'
3. Scroll down and open 'Analytics & Improvements'
4. Go to 'Analytics Data'
4. See crash log
validations:
required: false
- type: textarea
attributes:
label: Screenshots, Videos and other files
description: Submit screenshots, video of the issue or other files that can help to understand the problem better. If you are reporting issue with playback in MPV, attach [logs from MPV](https://github.com/yattee/yattee/wiki/Getting-MPV-logs)
validations:
required: false

View File

@@ -1 +0,0 @@
blank_issues_enabled: true

View File

@@ -1,39 +0,0 @@
name: Feature request
description: Suggest an new feature or an idea to improve the existing one
labels: "enhancement"
body:
- type: markdown
attributes:
value: |
**Before You Submit Your Issue**
- This is not a right place to ask questions. Use [Discussions](https://github.com/yattee/yattee/discussions) or contact directly via [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org)
- type: checkboxes
attributes:
label: Guidelines
description: Please ensure you've completed the following
options:
- label: I have searched the [issue tracker](https://github.com/yattee/yattee/issues) and I haven't found feature request like this
required: true
- type: dropdown
attributes:
label: Type
description: Please select a type that fits this request. Choose multiple options, if applicable
multiple: true
options:
- New feature
- Improvement to existing feature
- Usability improvement
- Visual improvement
- Other
validations:
required: true
- type: textarea
attributes:
label: Describe the feature
description: Provide a description of the feature that you're requesting to have implemented. You can attach screenshots or mockups to help to understand it better
placeholder: The feature should work like this...
validations:
required: true

View File

@@ -1,59 +0,0 @@
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
on:
workflow_dispatch:
env:
APP_NAME: Yattee
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
TEAM_ID: ${{ secrets.TEAM_ID }}
DEVELOPER_KEY_ID: ${{ secrets.DEVELOPER_KEY_ID }}
DEVELOPER_KEY_ISSUER_ID: ${{ secrets.DEVELOPER_KEY_ISSUER_ID }}
DEVELOPER_KEY_CONTENT: ${{ secrets.DEVELOPER_KEY_CONTENT }}
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
DEVELOPER_APP_IDENTIFIER: ${{ secrets.DEVELOPER_APP_IDENTIFIER }}
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
jobs:
mac_notarized:
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- name: Replace signing certificate to Direct with Developer ID
run: |
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.4'
- name: Clear SPM cache
run: |
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
rm -rf ~/Library/Developer/Xcode/DerivedData
rm -rf .build
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- run: |
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: |
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- uses: actions/upload-artifact@v4
with:
name: mac-notarized-build
path: ${{ env.ZIP_PATH }}
if-no-files-found: error

View File

@@ -1,35 +0,0 @@
name: Bump build number
on:
workflow_dispatch:
env:
APP_NAME: Yattee
jobs:
bump_build:
name: Bump build number
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Configure git
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- uses: maierj/fastlane-action@v3.0.0
with:
lane: bump_build
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GIT_AUTHORIZATION }}
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
base: main
title: Bump build number to ${{ env.BUILD_NUMBER }}

View File

@@ -1,108 +0,0 @@
name: Build and release to TestFlight and GitHub
on:
workflow_dispatch:
env:
APP_NAME: Yattee
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
TEAM_ID: ${{ secrets.TEAM_ID }}
DEVELOPER_KEY_ID: ${{ secrets.DEVELOPER_KEY_ID }}
DEVELOPER_KEY_ISSUER_ID: ${{ secrets.DEVELOPER_KEY_ISSUER_ID }}
DEVELOPER_KEY_CONTENT: ${{ secrets.DEVELOPER_KEY_CONTENT }}
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
DEVELOPER_APP_IDENTIFIER: ${{ secrets.DEVELOPER_APP_IDENTIFIER }}
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
jobs:
testflight:
strategy:
matrix:
# disabled mac beta lane
# lane: ['mac beta', 'ios beta', 'tvos beta']
lane: ['ios beta', 'tvos beta']
name: Releasing ${{ matrix.lane }} version to TestFlight
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- name: Replace signing certificate to AppStore
run: |
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.0.1'
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
with:
lane: ${{ matrix.lane }}
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.lane }} build
path: fastlane/builds/**/*.ipa
if-no-files-found: ignore
mac_notarized:
name: Build and notarize macOS app
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
cache-version: 1
- name: Replace signing certificate to Direct with Developer ID
run: |
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.0.1'
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- run: |
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: |
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- uses: actions/upload-artifact@v4
with:
name: mac notarized build
path: ${{ env.ZIP_PATH }}
if-no-files-found: error
release:
needs: ['testflight', 'mac_notarized']
name: Create GitHub release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
- uses: actions/download-artifact@v4
with:
path: artifacts
- uses: ncipollo/release-action@v1
with:
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip
commit: main
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
prerelease: true
bodyFile: CHANGELOG.md

21
.gitignore vendored
View File

@@ -81,9 +81,6 @@ fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/builds
fastlane/.env
fastlane/*.p8
# Code Injection
#
@@ -94,21 +91,3 @@ iOSInjectionProject/
# SwiftLint Remote Config Cache
.swiftlint/RemoteConfigCache
# User-specific xcconfig files
Xcode-config/DEVELOPMENT_TEAM.xcconfig
# Bundler
.bundle/
Vendor/bundle/
# Code Coverage
coverage/
# UI Test Snapshots (keep baseline/, ignore generated files)
spec/ui_snapshots/current/
spec/ui_snapshots/diff/
# Environment variables (contains secrets)
.env
.env.local

View File

@@ -1,3 +0,0 @@
project: Yattee.xcodeproj
schemes:
- Yattee

View File

@@ -1,120 +0,0 @@
# RuboCop configuration for Yattee UI tests
# Relaxed configuration matching existing code style
plugins:
- rubocop-rspec
AllCops:
TargetRubyVersion: 3.4
NewCops: enable
Include:
- 'spec/**/*.rb'
Exclude:
- 'vendor/**/*'
- 'Gemfile'
# ============================================
# Metrics - Relaxed for UI test complexity
# ============================================
# RSpec blocks are naturally long
Metrics/BlockLength:
Enabled: false
# UI test methods can be longer
Metrics/MethodLength:
Max: 120
# Classes can be larger in test support code
Metrics/ClassLength:
Max: 500
# Allow higher complexity for UI test helpers
Metrics/AbcSize:
Max: 100
Metrics/CyclomaticComplexity:
Max: 40
Metrics/PerceivedComplexity:
Max: 40
# ============================================
# Layout
# ============================================
# Relaxed line length for readability
Layout/LineLength:
Max: 140
# ============================================
# Naming
# ============================================
# Allow methods like find_element, ensure_invidious without ? suffix
Naming/PredicateMethod:
Enabled: false
# Allow set_ prefix for methods like set_status_bar_overrides
Naming/AccessorMethodName:
Enabled: false
# ============================================
# Lint
# ============================================
# Allow duplicate branches in case statements (UI state machines)
Lint/DuplicateBranch:
Enabled: false
# ============================================
# Style
# ============================================
# Not needed for test files
Style/Documentation:
Enabled: false
# Allow multi-line block chains (common in RSpec)
Style/MultilineBlockChain:
Enabled: false
# Allow single-line if statements (don't force modifier style)
Style/IfUnlessModifier:
Enabled: false
# Allow short parameter names in UI test helpers
Naming/MethodParameterName:
Enabled: false
# ============================================
# RSpec - Relaxed for UI testing patterns
# ============================================
# UI tests may need more steps
RSpec/ExampleLength:
Max: 30
# Allow before(:all) for simulator lifecycle management
RSpec/BeforeAfterAll:
Enabled: false
# Allow instance variables shared across examples (@axe, @udid)
RSpec/InstanceVariable:
Enabled: false
# UI tests often batch multiple checks
RSpec/MultipleExpectations:
Enabled: false
# Allow deeper nesting for describe/context blocks
RSpec/NestedGroups:
Max: 5
# Feature/smoke specs use string descriptions, not class names
RSpec/DescribeClass:
Enabled: false
# Allow expect in before hooks for setup verification
RSpec/ExpectInHook:
Enabled: false

View File

@@ -1 +0,0 @@
3.4.8

View File

@@ -1,16 +0,0 @@
# Slather Code Coverage Configuration
# https://github.com/SlatherOrg/slather
proj: Yattee.xcodeproj
scheme: Yattee
build-directory: DerivedData
output-directory: coverage
# Coverage format (options: html, cobertura, llvm-cov, json, sonarqube, gutter-json)
coverage_service: html
# Ignore patterns - adjust as needed
ignore:
- "**/YatteeTests/**"
- "**/Vendor/**"
- "**/*.generated.swift"

1
.swift-version Normal file
View File

@@ -0,0 +1 @@
5

2
.swiftformat Normal file
View File

@@ -0,0 +1,2 @@
--disable trailingCommas
--exclude Tests*

12
.swiftlint.yml Normal file
View File

@@ -0,0 +1,12 @@
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
disabled_rules:
- identifier_name
- opening_brace
- number_separator
- multiline_arguments
excluded:
- Tests Apple TV
- Tests iOS
- Tests macOS

View File

@@ -1,59 +0,0 @@
# Yattee Development Guide for AI Agents
## Deployment Targets
**iOS:** 18.0+ | **macOS:** 15.0+ | **tvOS:** 18.0+
This project targets the latest OS versions only - use newest APIs freely without availability checks.
## Build & Test Commands
**Build:** `xcodebuild -scheme Yattee -configuration Debug`
**Test (all):** `xcodebuild test -scheme Yattee -destination 'platform=macOS'`
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
**Lint:** `periphery scan` (config: `.periphery.yml`)
## Code Style
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)
**UI:** SwiftUI with `@Observable` macro for view models (not `ObservableObject`)
**Concurrency:** Use `actor` for services, `@MainActor` for UI-related code, `async/await` everywhere
**Testing:** Swift Testing framework (`@Test`, `@Suite`, `#expect`) - NOT XCTest
## Imports & Organization
**Import order:** Foundation first, then SwiftUI, then @testable imports
**File headers:** Include `// FileName.swift`, `// Yattee`, and brief comment describing purpose
**MARK comments:** Use `// MARK: - Section Name` to organize code sections
**Sendable:** All models, errors, and actors must conform to `Sendable`
## Types & Naming
**Models:** Immutable structs with `Codable, Hashable, Sendable` conformance
**Services:** Use `actor` for thread-safe services, `final class` for `@Observable` view models
**Enums:** Use associated values for typed errors (see `APIError.swift`)
**Optionals:** Prefer guard-let unwrapping; use `if let` for simple cases
**Naming:** camelCase for variables/functions, PascalCase for types, clear descriptive names
## Error Handling
**Errors:** Define typed enum errors conforming to `Error, LocalizedError, Equatable, Sendable`
**Async throws:** All async network/IO operations should throw typed errors
**Logging:** Use `LoggingService.shared` for all logging (see `HTTPClient.swift` for patterns)
**User feedback:** Provide localized error descriptions via `errorDescription`
## Testing & Debugging
**Add logging/visual clues** (borders, backgrounds) when debugging issues - then ask user for results
**If first fix doesn't work:** Add debug code before second attempt to understand the issue better
## UI Testing (Ruby/RSpec with AXe CLI)
**Run UI tests:** `./bin/ui-test --skip-build --keep-simulator`
**Run single spec:** `SKIP_BUILD=1 KEEP_SIMULATOR=1 bundle exec rspec spec/ui/smoke/search_spec.rb`
**Accessibility labels vs identifiers:** On iOS 26+, `.accessibilityIdentifier()` doesn't work reliably on `Group`, `ScrollView`, and some container views (AXUniqueId comes back empty). Use `.accessibilityLabel()` instead, which maps to `AXLabel` and can be detected via AXe's `text_visible?()` method.
**iOS 26 TabView search:** The search field is integrated into the bottom tab bar with `Tab(role: .search)`. Typing `\n` doesn't submit - use hardware key press via `press_return` (AXe key 40).
**ScrollView children:** Video rows inside `LazyVStack`/`ScrollView` aren't exposed in the accessibility tree. Use coordinate-based tapping instead.

13
Backports/Backport.swift Normal file
View File

@@ -0,0 +1,13 @@
import SwiftUI
public struct Backport<Content> {
public let content: Content
public init(_ content: Content) {
self.content = content
}
}
extension View {
var backport: Backport<Self> { Backport(self) }
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func badge(_ count: Text) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.badge(count)
} else {
content
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
extension Backport where Content: View {
@ViewBuilder func tint(_ color: Color?) -> some View {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content.tint(color)
} else {
content.foregroundColor(color)
}
}
}

162
CLAUDE.md
View File

@@ -1,162 +0,0 @@
# Yattee Development Notes
## Testing Instances
- **Invidious**: `https://invidious.home.arekf.net/` - Use this instance for testing API calls
- **Yattee Server**: `https://main.s.yattee.stream` - Local self-hosted Yattee server for backend testing
## Related Projects
### Yattee Server
Location: `~/Developer/yattee-server`
A self-hosted API server powered by yt-dlp that provides an Invidious-compatible API for YouTube content. Used as an alternative backend when Invidious/Piped instances are blocked or unavailable.
**Key features:**
- Invidious-compatible API endpoints (`/api/v1/videos`, `/api/v1/channels`, `/api/v1/search`, etc.)
- Uses yt-dlp with deno for YouTube JS challenge solving
- Returns direct YouTube CDN stream URLs
- Optional backing Invidious instance for trending, popular, and search suggestions
**API endpoints:**
- `GET /api/v1/videos/{video_id}` - Video metadata and streams
- `GET /api/v1/channels/{channel_id}` - Channel info
- `GET /api/v1/channels/{channel_id}/videos` - Channel videos
- `GET /api/v1/search?q={query}` - Search
- `GET /api/v1/playlists/{playlist_id}` - Playlist info
**Limitations:**
- No comments support
- Stream URLs expire after a few hours
- Trending/popular/suggestions require backing Invidious instance
- scheme name to build is Yattee. use generic platform build instead of specific sim/device id
## UI Testing with AXe
The project uses a Ruby/RSpec-based UI testing framework with [AXe](https://github.com/cameroncooke/AXe) for simulator automation and visual regression testing.
### Running UI Tests
```bash
# Install dependencies (first time)
bundle install
# Run all UI tests
./bin/ui-test
# Skip build (faster iteration)
./bin/ui-test --skip-build
# Keep simulator running after tests
./bin/ui-test --keep-simulator
# Generate new baseline screenshots
./bin/ui-test --generate-baseline
# Run on a different device
./bin/ui-test --device "iPad Pro 13-inch (M5)"
```
### Creating Tests for New Features
When implementing a new feature, create a UI test to verify it works:
1. **Create a new spec file** in `spec/ui/smoke/`:
```ruby
# spec/ui/smoke/my_feature_spec.rb
require_relative '../spec_helper'
RSpec.describe 'My New Feature', :smoke do
before(:all) do
@udid = UITest::Simulator.boot(UITest::Config.device)
UITest::App.build(device: UITest::Config.device, skip: UITest::Config.skip_build?)
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
sleep UITest::Config.app_launch_wait
@axe = UITest::Axe.new(@udid)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
it 'displays the new feature element' do
# Navigate to the feature if needed
@axe.tap_label('Settings')
sleep 1
# Check for expected elements
expect(@axe).to have_text('My New Feature')
end
it 'matches baseline screenshot', :visual do
screenshot = @axe.screenshot('my-feature-screen')
expect(screenshot).to match_baseline
end
end
```
2. **Available AXe actions:**
```ruby
@axe.tap_label('Button Text') # Tap by accessibility label
@axe.tap_id('accessibilityId') # Tap by accessibility identifier
@axe.tap_coordinates(x: 100, y: 200)
@axe.swipe(start_x: 200, start_y: 400, end_x: 200, end_y: 100)
@axe.gesture('scroll-down') # Presets: scroll-up, scroll-down, scroll-left, scroll-right
@axe.type('search text') # Type text
@axe.home_button # Press home
@axe.screenshot('name') # Take screenshot
```
3. **Available matchers:**
```ruby
expect(@axe).to have_element('AXUniqueId') # Check by accessibility identifier
expect(@axe).to have_text('Visible Text') # Check by accessibility label
expect(screenshot_path).to match_baseline # Visual comparison (2% threshold)
```
4. **Run with baseline generation:**
```bash
./bin/ui-test --generate-baseline --keep-simulator
```
5. **Inspect accessibility tree** to find element identifiers:
```bash
# Boot simulator and launch app first, then:
axe describe-ui --udid <SIMULATOR_UDID>
```
### Directory Structure
```
spec/
├── ui/
│ ├── spec_helper.rb # RSpec configuration
│ ├── support/
│ │ ├── config.rb # Test configuration
│ │ ├── simulator.rb # Simulator management
│ │ ├── app.rb # App build/install/launch
│ │ ├── axe.rb # AXe CLI wrapper
│ │ ├── axe_matchers.rb # Custom RSpec matchers
│ │ └── screenshot_comparison.rb
│ └── smoke/
│ └── app_launch_spec.rb # Example test
└── ui_snapshots/
├── baseline/ # Reference screenshots (by device/iOS version)
│ └── iPhone_17_Pro/
│ └── iOS_26_2/
│ └── app-launch-library.png
├── current/ # Current test run screenshots
├── diff/ # Visual diff images
└── false_positives.yml # Mark expected differences
```
### Tips
- Use `have_text` matcher for most checks - it's more reliable than `have_element` since iOS doesn't always expose accessibility identifiers
- Add `sleep 1` after navigation actions to let UI settle
- Use `--keep-simulator` during development to speed up iteration
- Check `spec/ui_snapshots/diff/` for visual diff images when tests fail
- Add entries to `false_positives.yml` for screenshots with expected dynamic content

View File

@@ -0,0 +1,17 @@
extension Array where Element: Equatable {
func next(after element: Element?) -> Element? {
if element.isNil {
return first
}
let idx = firstIndex(of: element!)
if idx.isNil {
return first
}
let next = index(after: idx!)
return self[next == endIndex ? startIndex : next]
}
}

View File

@@ -0,0 +1,10 @@
import CoreMedia
import Foundation
extension CMTime {
static let defaultTimescale: CMTimeScale = 1_000_000
static func secondsInDefaultTimescale(_ seconds: TimeInterval) -> CMTime {
CMTime(seconds: seconds, preferredTimescale: CMTime.defaultTimescale)
}
}

View File

@@ -0,0 +1,15 @@
extension CaseIterable where Self: Equatable {
func next(nilAtEnd: Bool = false) -> Self! {
let all = Self.allCases
let index = all.firstIndex(of: self)!
let next = all.index(after: index)
if nilAtEnd == true {
if next == all.endIndex {
return nil
}
}
return all[next == all.endIndex ? all.startIndex : next]
}
}

View File

@@ -0,0 +1,17 @@
import SwiftUI
extension Color {
#if os(macOS)
static let background = Color(NSColor.windowBackgroundColor)
static let secondaryBackground = Color(NSColor.underPageBackgroundColor)
static let tertiaryBackground = Color(NSColor.controlBackgroundColor)
#elseif os(iOS)
static let background = Color(UIColor.systemBackground)
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
static let tertiaryBackground = Color(UIColor.tertiarySystemBackground)
#else
static let background = Color.black
static let secondaryBackground = Color.black
static let tertiaryBackground = Color.black
#endif
}

View File

@@ -0,0 +1,26 @@
import Foundation
extension Double {
func formattedAsPlaybackTime() -> String? {
guard !isZero else {
return nil
}
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = self >= (60 * 60) ? [.hour, .minute, .second] : [.minute, .second]
formatter.zeroFormattingBehavior = [.pad]
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

@@ -0,0 +1,27 @@
import Foundation
extension Int {
func formattedAsAbbreviation() -> String {
let num = fabs(Double(self))
guard num >= 1000.0 else {
return String(self)
}
let exp = Int(log10(num) / 3.0)
let units = ["K", "M", "B", "T", "X"]
let unit = units[exp - 1]
let formatter = NumberFormatter()
formatter.positiveSuffix = unit
formatter.negativeSuffix = unit
formatter.allowsFloats = true
formatter.minimumIntegerDigits = 1
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
let roundedNum = round(10 * num / pow(1000.0, Double(exp))) / 10
return formatter.string(from: NSNumber(value: roundedNum))!
}
}

View File

@@ -0,0 +1,8 @@
import AppKit
extension NSTextField {
override open var focusRingType: NSFocusRingType {
get { .none }
set {} // swiftlint:disable:this unused_setter_value
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
extension String {
func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
guard let range = range(of: target) else {
return self
}
return replacingCharacters(in: range, with: replacement)
}
}

View File

@@ -0,0 +1,6 @@
import Siesta
import SwiftyJSON
extension TypedContentAccessors {
var json: JSON { typedContent(ifNone: JSON.null) }
}

View File

@@ -0,0 +1,30 @@
import Foundation
import SwiftUI
extension View {
func borderTop(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
verticalEdgeBorder(.top, height: height, color: color)
}
func borderBottom(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
verticalEdgeBorder(.bottom, height: height, color: color)
}
func borderLeading(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
horizontalEdgeBorder(.leading, width: width, color: color)
}
func borderTrailing(width: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
horizontalEdgeBorder(.trailing, width: width, color: color)
}
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
overlay(Rectangle().frame(width: nil, height: height, alignment: .top)
.foregroundColor(color), alignment: edge)
}
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
overlay(Rectangle().frame(width: width, height: nil, alignment: .leading)
.foregroundColor(color), alignment: edge)
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
extension ChannelPlaylist {
static var fixture: ChannelPlaylist {
ChannelPlaylist(
title: "Playlist with a very long title that will not fit easily in the screen",
thumbnailURL: URL(string: "https://i.ytimg.com/vi/hT_nvWreIhg/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLAAD21_-Bo6Td1z3cV-UFyoi1flEg")!,
channel: Video.fixture.channel,
videos: Video.allFixtures
)
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
extension Instance {
static var fixture: Instance {
Instance(app: .invidious, name: "Home", apiURL: "https://invidious.home.net")
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
extension Playlist {
static var fixture: Playlist {
Playlist(id: UUID().uuidString, title: "Relaxing music", visibility: .public, updated: 1)
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
extension Thumbnail {
static func fixture(videoId: String, quality: Thumbnail.Quality = .maxres) -> Thumbnail {
Thumbnail(url: fixtureUrl(videoId: videoId, quality: quality), quality: quality)
}
static func fixturesForAllQualities(videoId: String) -> [Thumbnail] {
Thumbnail.Quality.allCases.map { fixture(videoId: videoId, quality: $0) }
}
private static var fixturesHost: String {
"https://invidious.home.arekf.net"
}
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
URL(string: "\(fixturesHost)/vi/\(videoId)/\(quality.filename).jpg")!
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
extension Video {
static var fixture: Video {
let id = "D2sxamzaHkM"
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
return Video(
videoID: UUID().uuidString,
title: "Relaxing Piano Music that will make you feel amazingly good",
author: "Fancy Videotuber",
length: 582,
published: "7 years ago",
views: 21534,
description: "Some relaxing live piano music",
genre: "Music",
channel: Channel(
id: "AbCdEFgHI",
name: "The Channel",
thumbnailURL: URL(string: thumbnailURL)!,
subscriptionsCount: 2300,
videos: []
),
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
live: false,
upcoming: false,
publishedAt: Date(),
likes: 37333,
dislikes: 30,
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
)
}
static var fixtureLiveWithoutPublishedOrViews: Video {
var video = fixture
video.title = "\(video.title) \(video.title) \(video.title) \(video.title) \(video.title)"
video.published = "0 seconds ago"
video.views = 0
video.live = true
return video
}
static var fixtureUpcomingWithoutPublishedOrViews: Video {
var video = fixtureLiveWithoutPublishedOrViews
video.live = false
video.upcoming = true
return video
}
static var allFixtures: [Video] {
[fixture, fixtureLiveWithoutPublishedOrViews, fixtureUpcomingWithoutPublishedOrViews]
}
static func fixtures(_ count: Int) -> [Video] {
var result = [Video]()
while result.count < count {
result.append(allFixtures.shuffled().first!)
}
return result
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
import SwiftUI
struct FixtureEnvironmentObjectsModifier: ViewModifier {
func body(content: Content) -> some View {
content
.environmentObject(AccountsModel())
.environmentObject(InstancesModel())
.environmentObject(invidious)
.environmentObject(NavigationModel())
.environmentObject(PipedAPI())
.environmentObject(player)
.environmentObject(PlaylistsModel())
.environmentObject(RecentsModel())
.environmentObject(SearchModel())
.environmentObject(subscriptions)
.environmentObject(ThumbnailsModel())
}
private var invidious: InvidiousAPI {
let api = InvidiousAPI()
api.validInstance = true
api.signedIn = true
return api
}
private var player: PlayerModel {
let player = PlayerModel()
player.currentItem = PlayerQueueItem(Video.fixture)
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
player.history = player.queue
return player
}
private var subscriptions: SubscriptionsModel {
let subscriptions = SubscriptionsModel()
subscriptions.channels = Video.allFixtures.map { $0.channel }
return subscriptions
}
}
extension View {
func injectFixtureEnvironmentObjects() -> some View {
modifier(FixtureEnvironmentObjectsModifier())
}
}

20
Gemfile
View File

@@ -1,20 +0,0 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Fastlane for build automation and distribution
gem 'fastlane', '~> 2.225'
# Load environment variables from .env files
# Note: fastlane requires dotenv < 3.0, so we use 2.x
gem 'dotenv', '~> 2.8'
group :test do
# RSpec for UI testing framework
gem 'rspec', '~> 3.13'
# Retry flaky UI tests automatically
gem 'rspec-retry', '~> 0.6'
# Code linting
gem 'rubocop', '~> 1.69', require: false
gem 'rubocop-rspec', '~> 3.3', require: false
end

View File

@@ -1,284 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
ast (2.4.3)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
diff-lcs (1.6.2)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.0)
jwt (2.10.2)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.18.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
parallel (1.27.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
plist (3.7.2)
prism (1.7.0)
public_suffix (7.0.0)
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.1)
regexp_parser (2.11.3)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-retry (0.6.2)
rspec-core (> 3.3)
rspec-support (3.13.6)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-rspec (3.8.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-24
ruby
DEPENDENCIES
dotenv (~> 2.8)
fastlane (~> 2.225)
rspec (~> 3.13)
rspec-retry (~> 0.6)
rubocop (~> 1.69)
rubocop-rspec (~> 3.3)
BUNDLED WITH
2.6.3

View File

@@ -0,0 +1,57 @@
import Defaults
import Foundation
struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
let id: String
let instanceID: String
var name: String?
let url: String
let username: String
let password: String?
var token: String?
let anonymous: Bool
init(
id: String? = nil,
instanceID: String? = nil,
name: String? = nil,
url: String? = nil,
username: String? = nil,
password: String? = nil,
token: String? = nil,
anonymous: Bool = false
) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.username = username ?? ""
self.token = token
self.password = password ?? ""
}
var instance: Instance! {
Defaults[.instances].first { $0.id == instanceID }
}
var shortUsername: String {
guard username.count > 10 else {
return username
}
let index = username.index(username.startIndex, offsetBy: 11)
return String(username[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? shortUsername : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(username)
}
}

View File

@@ -0,0 +1,158 @@
import Foundation
import Siesta
import SwiftUI
final class AccountValidator: Service {
let app: Binding<VideosApp>
let url: String
let account: Account!
var formObjectID: Binding<String>
var isValid: Binding<Bool>
var isValidated: Binding<Bool>
var isValidating: Binding<Bool>
var error: Binding<String?>?
init(
app: Binding<VideosApp>,
url: String,
account: Account? = nil,
id: Binding<String>,
isValid: Binding<Bool>,
isValidated: Binding<Bool>,
isValidating: Binding<Bool>,
error: Binding<String?>? = nil
) {
self.app = app
self.url = url
self.account = account
formObjectID = id
self.isValid = isValid
self.isValidated = isValidated
self.isValidating = isValidating
self.error = error
super.init(baseURL: url)
configure()
}
func configure() {
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure("/api/v1/auth/feed", requestMethods: [.get]) {
guard self.account != nil else {
return
}
$0.headers["Cookie"] = self.invidiousCookieHeader
}
configure("/login", requestMethods: [.post]) {
$0.headers["Content-Type"] = "application/json"
}
}
func validateInstance() {
reset()
neverGonnaGiveYouUp
.load()
.onSuccess { response in
guard self.url == self.formObjectID.wrappedValue else {
return
}
let json = response.json.dictionaryValue
let author = self.app.wrappedValue == .invidious ? json["author"] : json["uploader"]
if author == "Rick Astley" {
self.isValid.wrappedValue = true
self.error?.wrappedValue = nil
} else {
self.isValid.wrappedValue = false
}
}
.onFailure { error in
guard self.url == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = false
self.error?.wrappedValue = error.userMessage
}
.onCompletion { _ in
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
}
}
func validateAccount() {
reset()
accountRequest
.onSuccess { response in
guard self.account!.username == self.formObjectID.wrappedValue else {
return
}
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!.username == self.formObjectID.wrappedValue else {
return
}
self.isValid.wrappedValue = false
}
.onCompletion { _ in
self.isValidated.wrappedValue = true
self.isValidating.wrappedValue = false
}
}
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
isValidating.wrappedValue = false
error?.wrappedValue = nil
}
var invidiousCookieHeader: String {
"SID=\(account.username)"
}
var login: Resource {
resource("/login")
}
var feed: Resource {
resource("/api/v1/auth/feed")
}
var videoResourceBasePath: String {
app.wrappedValue == .invidious ? "/api/v1/videos" : "/streams"
}
var neverGonnaGiveYouUp: Resource {
resource("\(videoResourceBasePath)/dQw4w9WgXcQ")
}
}

View File

@@ -0,0 +1,39 @@
import Defaults
import Foundation
struct AccountsBridge: Defaults.Bridge {
typealias Value = Account
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id,
"instanceID": value.instanceID,
"name": value.name ?? "",
"apiURL": value.url,
"username": value.username,
"password": value.password ?? ""
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["apiURL"],
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, username: username, password: password)
}
}

View File

@@ -0,0 +1,95 @@
import Combine
import Defaults
import Foundation
final class AccountsModel: ObservableObject {
@Published private(set) var current: Account!
@Published private var invidious = InvidiousAPI()
@Published private var piped = PipedAPI()
private var cancellables = [AnyCancellable]()
var all: [Account] {
Defaults[.accounts]
}
var lastUsed: Account? {
guard let id = Defaults[.lastAccountID] else {
return nil
}
return AccountsModel.find(id)
}
var app: VideosApp {
current?.instance?.app ?? .invidious
}
var api: VideosAPI {
app == .piped ? piped : invidious
}
var isEmpty: Bool {
current.isNil
}
var signedIn: Bool {
!isEmpty && !current.anonymous && api.signedIn
}
init() {
cancellables.append(
invidious.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
)
cancellables.append(
piped.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }
)
}
func setCurrent(_ account: Account! = nil) {
guard account != current else {
return
}
current = account
guard !account.isNil else {
return
}
switch account.instance.app {
case .invidious:
invidious.setAccount(account)
case .piped:
piped.setAccount(account)
}
Defaults[.lastAccountID] = account.anonymous ? nil : account.id
Defaults[.lastInstanceID] = account.instanceID
}
static func find(_ id: Account.ID) -> Account? {
Defaults[.accounts].first { $0.id == id }
}
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
}
static func remove(_ account: Account) {
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
Defaults[.accounts].remove(at: accountIndex)
}
}
}

View File

@@ -0,0 +1,61 @@
import Defaults
import Foundation
struct Instance: Defaults.Serializable, Hashable, Identifiable {
static var bridge = InstancesBridge()
let app: VideosApp
let id: String
let name: String
let apiURL: String
var frontendURL: String?
init(app: VideosApp, id: String? = nil, name: String, apiURL: String, frontendURL: String? = nil) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name
self.apiURL = apiURL
self.frontendURL = frontendURL
}
var anonymous: VideosAPI {
switch app {
case .invidious:
return InvidiousAPI(account: anonymousAccount)
case .piped:
return PipedAPI(account: anonymousAccount)
}
}
var description: String {
"\(app.name) - \(shortDescription)"
}
var longDescription: String {
name.isEmpty ? "\(app.name) - \(apiURL)" : "\(app.name) - \(name) (\(apiURL))"
}
var shortDescription: String {
name.isEmpty ? apiURL : name
}
var anonymousAccount: Account {
Account(instanceID: id, name: "Anonymous", url: apiURL, anonymous: true)
}
var urlComponents: URLComponents {
URLComponents(string: apiURL)!
}
var frontendHost: String? {
guard let url = app == .invidious ? apiURL : frontendURL else {
return nil
}
return URLComponents(string: url)?.host
}
func hash(into hasher: inout Hasher) {
hasher.combine(apiURL)
}
}

View File

@@ -0,0 +1,37 @@
import Defaults
import Foundation
struct InstancesBridge: Defaults.Bridge {
typealias Value = Instance
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"app": value.app.rawValue,
"id": value.id,
"name": value.name,
"apiURL": value.apiURL,
"frontendURL": value.frontendURL ?? ""
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let app = VideosApp(rawValue: object["app"] ?? ""),
let id = object["id"],
let apiURL = object["apiURL"]
else {
return nil
}
let name = object["name"] ?? ""
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
return Instance(app: app, id: id, name: name, apiURL: apiURL, frontendURL: frontendURL)
}
}

View File

@@ -0,0 +1,62 @@
import Defaults
import Foundation
final class InstancesModel: ObservableObject {
static var all: [Instance] {
Defaults[.instances]
}
var lastUsed: Instance? {
guard let id = Defaults[.lastInstanceID] else {
return nil
}
return InstancesModel.find(id)
}
static func find(_ id: Instance.ID?) -> Instance? {
guard id != nil else {
return nil
}
return Defaults[.instances].first { $0.id == id }
}
static func accounts(_ id: Instance.ID?) -> [Account] {
Defaults[.accounts].filter { $0.instanceID == id }
}
static func add(app: VideosApp, name: String, url: String) -> Instance {
let instance = Instance(
app: app, id: UUID().uuidString, name: name, apiURL: standardizedURL(url)
)
Defaults[.instances].append(instance)
return instance
}
static func setFrontendURL(_ instance: Instance, _ url: String) {
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
var instance = Defaults[.instances][index]
instance.frontendURL = standardizedURL(url)
Defaults[.instances][index] = instance
}
}
static func remove(_ instance: Instance) {
let accounts = InstancesModel.accounts(instance.id)
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
Defaults[.instances].remove(at: index)
accounts.forEach { AccountsModel.remove($0) }
}
}
static func standardizedURL(_ url: String) -> String {
if url.last == "/" {
return String(url.dropLast())
} else {
return url
}
}
}

View File

@@ -0,0 +1,407 @@
import AVKit
import Defaults
import Foundation
import Siesta
import SwiftyJSON
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
static let basePath = "/api/v1"
@Published var account: Account!
@Published var validInstance = true
@Published var signedIn = false
init(account: Account? = nil) {
super.init()
guard !account.isNil else {
self.account = .init(name: "Empty")
return
}
setAccount(account!)
}
func setAccount(_ account: Account) {
self.account = account
validInstance = false
signedIn = false
configure()
validate()
}
func validate() {
validateInstance()
validateSID()
}
func validateInstance() {
guard !validInstance else {
return
}
home?
.load()
.onSuccess { _ in
self.validInstance = true
}
.onFailure { _ in
self.validInstance = false
}
}
func validateSID() {
guard !signedIn else {
return
}
feed?
.load()
.onSuccess { _ in
self.signedIn = true
}
.onFailure { _ in
self.signedIn = false
}
}
func configure() {
configure {
if !self.account.username.isEmpty {
$0.headers["Cookie"] = self.cookieHeader
}
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure("**", requestMethods: [.post]) {
$0.pipeline[.parsing].removeTransformers()
}
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
}
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
content.json.arrayValue.map {
let type = $0.dictionaryValue["type"]?.stringValue
if type == "channel" {
return ContentItem(channel: InvidiousAPI.extractChannel(from: $0))
} else if type == "playlist" {
return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0))
}
return ContentItem(video: InvidiousAPI.extractVideo(from: $0))
}
}
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
if let suggestions = content.json.dictionaryValue["suggestions"] {
return suggestions.arrayValue.map(String.init)
}
return []
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.map(Playlist.init)
}
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
Playlist(content.json)
}
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
// hacky, to verify if possible to get it in easier way
Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
}
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(InvidiousAPI.extractVideo)
}
return []
}
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.map(InvidiousAPI.extractChannel)
}
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
InvidiousAPI.extractChannel(from: content.json)
}
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(InvidiousAPI.extractVideo)
}
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
InvidiousAPI.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
InvidiousAPI.extractVideo(from: content.json)
}
}
private func pathPattern(_ path: String) -> String {
"**\(InvidiousAPI.basePath)/\(path)"
}
private func basePathAppending(_ path: String) -> String {
"\(InvidiousAPI.basePath)/\(path)"
}
private var cookieHeader: String {
"SID=\(account.username)"
}
var popular: Resource? {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
}
func trending(country: Country, category: TrendingCategory?) -> Resource {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
.withParam("type", category?.name)
.withParam("region", country.rawValue)
}
var home: Resource? {
resource(baseURL: account.url, path: "/feed/subscriptions")
}
var feed: Resource? {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
}
var subscriptions: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
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 {
resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
}
func channelVideos(_ id: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
}
func video(_ id: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
}
var playlists: Resource? {
if account.isNil || account.anonymous {
return nil
}
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource? {
playlist(id)?.child("videos")
}
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
playlist(playlistID)?.child("videos").child(videoID)
}
func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
}
func search(_ query: SearchQuery) -> Resource {
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
.withParam("type", "all")
if let date = query.date, date != .any {
resource = resource.withParam("date", date.rawValue)
}
if let duration = query.duration, duration != .any {
resource = resource.withParam("duration", duration.rawValue)
}
return resource
}
func searchSuggestions(query: String) -> Resource {
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
.withParam("q", query.lowercased())
}
private func searchQuery(_ query: String) -> String {
var searchQuery = query
let url = URLComponents(string: query)
if url != nil,
url!.host == "youtu.be"
{
searchQuery = url!.path.replacingOccurrences(of: "/", with: "")
}
let queryItem = url?.queryItems?.first { item in item.name == "v" }
if let id = queryItem?.value {
searchQuery = id
}
return searchQuery
}
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
guard let instanceURLComponents = URLComponents(string: instance.apiURL),
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
urlComponents.scheme = instanceURLComponents.scheme
urlComponents.host = instanceURLComponents.host
guard let url = urlComponents.url else {
return nil
}
return AVURLAsset(url: url)
}
static func extractVideo(from json: JSON) -> Video {
let indexID: String?
var id: Video.ID
var publishedAt: Date?
if let publishedInterval = json["published"].double {
publishedAt = Date(timeIntervalSince1970: publishedInterval)
}
let videoID = json["videoId"].stringValue
if let index = json["indexId"].string {
indexID = index
id = videoID + index
} else {
indexID = nil
id = videoID
}
return Video(
id: id,
videoID: videoID,
title: json["title"].stringValue,
author: json["author"].stringValue,
length: json["lengthSeconds"].doubleValue,
published: json["publishedText"].stringValue,
views: json["viewCount"].intValue,
description: json["description"].stringValue,
genre: json["genre"].stringValue,
channel: extractChannel(from: json),
thumbnails: extractThumbnails(from: json),
indexID: indexID,
live: json["liveNow"].boolValue,
upcoming: json["isUpcoming"].boolValue,
publishedAt: publishedAt,
likes: json["likeCount"].int,
dislikes: json["dislikeCount"].int,
keywords: json["keywords"].arrayValue.map { $0.stringValue },
streams: extractStreams(from: json),
related: extractRelated(from: json)
)
}
static func extractChannel(from json: JSON) -> Channel {
let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")"
return Channel(
id: json["authorId"].stringValue,
name: json["author"].stringValue,
thumbnailURL: URL(string: thumbnailURL),
subscriptionsCount: json["subCount"].int,
subscriptionsText: json["subCountText"].string,
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(InvidiousAPI.extractVideo) ?? []
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
let details = json.dictionaryValue
return ChannelPlaylist(
id: details["playlistId"]!.stringValue,
title: details["title"]!.stringValue,
thumbnailURL: details["playlistThumbnail"]?.url,
channel: extractChannel(from: json),
videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? []
)
}
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
details["videoThumbnails"].arrayValue.map { json in
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
}
}
private static func extractStreams(from json: JSON) -> [Stream] {
extractFormatStreams(from: json["formatStreams"].arrayValue) +
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
}
private static func extractFormatStreams(from streams: [JSON]) -> [Stream] {
streams.map {
SingleAssetStream(
avAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .stream,
encoding: $0["encoding"].stringValue
)
}
}
private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
guard audioAssetURL != nil else {
return []
}
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/mp4") && $0["encoding"].stringValue == "h264" }
return videoAssetsURLs.map {
Stream(
audioAsset: AVURLAsset(url: audioAssetURL!["url"].url!),
videoAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .adaptive,
encoding: $0["encoding"].stringValue
)
}
}
private static func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["recommendedVideos"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
}

View File

@@ -0,0 +1,398 @@
import AVFoundation
import Foundation
import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
@Published var account: Account!
var anonymousAccount: Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL)
}
init(account: Account? = nil) {
super.init()
guard account != nil else {
return
}
setAccount(account!)
}
func setAccount(_ account: Account) {
self.account = account
configure()
}
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)
}
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
PipedAPI.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
PipedAPI.extractVideo(from: content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
PipedAPI.extractVideos(from: content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
}
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 {
resource(baseURL: account.url, path: "channel/\(id)")
}
func channelVideos(_ id: String) -> Resource {
channel(id)
}
func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: "playlists/\(id)")
}
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
resource(baseURL: account.instance.apiURL, path: "trending")
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery) -> Resource {
resource(baseURL: account.instance.apiURL, path: "search")
.withParam("q", query.query)
.withParam("filter", "")
}
func searchSuggestions(query: String) -> Resource {
resource(baseURL: account.instance.apiURL, path: "suggestions")
.withParam("query", query.lowercased())
}
func video(_ id: Video.ID) -> Resource {
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
}
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 home: Resource? { nil }
var popular: Resource? { nil }
var playlists: 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 }
func playlistVideos(_: String) -> Resource? { nil }
private func pathPattern(_ path: String) -> String {
"**\(path)"
}
private static func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let url: String! = details["url"]?.string
let contentType: ContentItem.ContentType
if !url.isNil {
if url.contains("/playlist") {
contentType = .playlist
} else if url.contains("/channel") {
contentType = .channel
} else {
contentType = .video
}
} else {
contentType = .video
}
switch contentType {
case .video:
if let video = PipedAPI.extractVideo(from: content) {
return ContentItem(video: video)
}
case .playlist:
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
return ContentItem(playlist: playlist)
}
case .channel:
if let channel = PipedAPI.extractChannel(from: content) {
return ContentItem(channel: channel)
}
}
return nil
}
private static func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
}
private static func extractChannel(from content: JSON) -> Channel? {
let attributes = content.dictionaryValue
guard let id = attributes["id"]?.stringValue ??
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
else {
return nil
}
let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue
var videos = [Video]()
if let relatedStreams = attributes["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
}
return Channel(
id: id,
name: attributes["name"]!.stringValue,
thumbnailURL: attributes["thumbnail"]?.url,
subscriptionsCount: subscriptionsCount,
videos: videos
)
}
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = PipedAPI.extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
id: id,
title: details["name"]!.stringValue,
thumbnailURL: thumbnailURL,
channel: extractChannel(from: json)!,
videos: videos,
videosCount: details["videos"]?.int
)
}
private static func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
let url = details["url"]?.string
if !url.isNil {
guard url!.contains("/watch") else {
return nil
}
}
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
return nil
}
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: published,
views: details["views"]!.intValue,
description: PipedAPI.extractDescription(from: content),
channel: Channel(id: channelId, name: author),
thumbnails: thumbnails,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
related: extractRelated(from: content)
)
}
private static func extractID(from content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
}
private static func extractThumbnailURL(from content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
}
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
let thumbnailURL = extractThumbnailURL(from: content)
guard !thumbnailURL.isNil else {
return nil
}
return URL(string: thumbnailURL!
.absoluteString
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
)!
}
private static func extractDescription(from content: JSON) -> String? {
guard var description = content.dictionaryValue["description"]?.string else {
return nil
}
description = description.replacingOccurrences(
of: "<br/>|<br />|<br>",
with: "\n",
options: .regularExpression,
range: nil
)
description = description.replacingOccurrences(
of: "<[^>]+>",
with: "",
options: .regularExpression,
range: nil
)
return description
}
private static func extractVideos(from content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(from:))
}
private static func extractStreams(from content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
return streams
}
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
if videoOnly {
streams.append(
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
)
} else {
streams.append(
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
)
}
}
return streams
}
private static func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["relatedStreams"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
content
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
.sorted {
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
} ?? []
}
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
content
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
}

View File

@@ -0,0 +1,86 @@
import AVFoundation
import Foundation
import Siesta
protocol VideosAPI {
var account: Account! { get }
var signedIn: Bool { get }
func channel(_ id: String) -> Resource
func channelVideos(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery) -> Resource
func searchSuggestions(query: String) -> Resource
func video(_ id: Video.ID) -> Resource
var subscriptions: Resource? { get }
var feed: Resource? { get }
var home: Resource? { get }
var popular: Resource? { get }
var playlists: Resource? { get }
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?
func playlistVideos(_ id: String) -> Resource?
func channelPlaylist(_ id: String) -> Resource?
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
}
extension VideosAPI {
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }) {
guard (item.video?.streams ?? []).isEmpty else {
completionHandler(item)
return
}
video(item.videoID).load().onSuccess { response in
guard let video: Video = response.typedContent() else {
return
}
var newItem = item
newItem.video = video
completionHandler(newItem)
}
}
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
guard let frontendHost = frontendHost ?? account.instance.frontendHost else {
return nil
}
var urlComponents = account.instance.urlComponents
urlComponents.host = frontendHost
var queryItems = [URLQueryItem]()
switch item.contentType {
case .video:
urlComponents.path = "/watch"
queryItems.append(.init(name: "v", value: item.video.videoID))
case .channel:
urlComponents.path = "/channel/\(item.channel.id)"
case .playlist:
urlComponents.path = "/playlist"
queryItems.append(.init(name: "list", value: item.playlist.id))
}
if !time.isNil, time!.seconds.isFinite {
queryItems.append(.init(name: "t", value: "\(Int(time!.seconds))s"))
}
if !queryItems.isEmpty {
urlComponents.queryItems = queryItems
}
return urlComponents.url
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
enum VideosApp: String, CaseIterable {
case invidious, piped
var name: String {
rawValue.capitalized
}
var supportsAccounts: Bool {
true
}
var accountsUsePassword: Bool {
self == .piped
}
var supportsPopular: Bool {
self == .invidious
}
var supportsSearchFilters: Bool {
self == .invidious
}
var supportsSubscriptions: Bool {
supportsAccounts
}
var supportsTrendingCategories: Bool {
self == .invidious
}
var supportsUserPlaylists: Bool {
self == .invidious
}
var hasFrontendURL: Bool {
self == .piped
}
}

42
Model/Channel.swift Normal file
View File

@@ -0,0 +1,42 @@
import AVFoundation
import Defaults
import Foundation
import SwiftyJSON
struct Channel: Identifiable, Hashable {
var id: String
var name: String
var thumbnailURL: URL?
var videos = [Video]()
private var subscriptionsCount: Int?
private var subscriptionsText: String?
init(
id: String,
name: String,
thumbnailURL: URL? = nil,
subscriptionsCount: Int? = nil,
subscriptionsText: String? = nil,
videos: [Video] = []
) {
self.id = id
self.name = name
self.thumbnailURL = thumbnailURL
self.subscriptionsCount = subscriptionsCount
self.subscriptionsText = subscriptionsText
self.videos = videos
}
var subscriptionsString: String? {
if subscriptionsCount != nil, subscriptionsCount! > 0 {
return subscriptionsCount!.formattedAsAbbreviation()
}
return subscriptionsText
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
struct ChannelPlaylist: Identifiable {
var id: String = UUID().uuidString
var title: String
var thumbnailURL: URL?
var channel: Channel?
var videos = [Video]()
var videosCount: Int?
}

40
Model/ContentItem.swift Normal file
View File

@@ -0,0 +1,40 @@
import Foundation
struct ContentItem: Identifiable {
enum ContentType: String {
case video, playlist, channel
private var sortOrder: Int {
switch self {
case .channel:
return 1
case .playlist:
return 2
default:
return 3
}
}
static func < (lhs: ContentType, rhs: ContentType) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
}
var video: Video!
var playlist: ChannelPlaylist!
var channel: Channel!
var id: String = UUID().uuidString
static func array(of videos: [Video]) -> [ContentItem] {
videos.map { ContentItem(video: $0) }
}
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
lhs.contentType < rhs.contentType
}
var contentType: ContentType {
video.isNil ? (channel.isNil ? .playlist : .channel) : .video
}
}

279
Model/Country.swift Normal file
View File

@@ -0,0 +1,279 @@
// swiftlint:disable switch_case_on_newline
import Defaults
enum Country: String, CaseIterable, Identifiable, Hashable, Defaults.Serializable {
var id: String {
rawValue
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
case dz = "DZ"
case ar = "AR"
case au = "AU"
case at = "AT"
case az = "AZ"
case bh = "BH"
case bd = "BD"
case by = "BY"
case be = "BE"
case bo = "BO"
case ba = "BA"
case br = "BR"
case bg = "BG"
case ca = "CA"
case cl = "CL"
case co = "CO"
case cr = "CR"
case hr = "HR"
case cy = "CY"
case cz = "CZ"
case dk = "DK"
case `do` = "DO"
case ec = "EC"
case eg = "EG"
case sv = "SV"
case ee = "EE"
case fi = "FI"
case fr = "FR"
case ge = "GE"
case de = "DE"
case gh = "GH"
case gr = "GR"
case gt = "GT"
case hn = "HN"
case hk = "HK"
case hu = "HU"
case `is` = "IS"
case `in` = "IN"
case id = "ID"
case iq = "IQ"
case ie = "IE"
case il = "IL"
case it = "IT"
case jm = "JM"
case jp = "JP"
case jo = "JO"
case kz = "KZ"
case ke = "KE"
case kr = "KR"
case kw = "KW"
case lv = "LV"
case lb = "LB"
case ly = "LY"
case li = "LI"
case lt = "LT"
case lu = "LU"
case mk = "MK"
case my = "MY"
case mt = "MT"
case mx = "MX"
case me = "ME"
case ma = "MA"
case np = "NP"
case nl = "NL"
case nz = "NZ"
case ni = "NI"
case ng = "NG"
case no = "NO"
case om = "OM"
case pk = "PK"
case pa = "PA"
case pg = "PG"
case py = "PY"
case pe = "PE"
case ph = "PH"
case pl = "PL"
case pt = "PT"
case pr = "PR"
case qa = "QA"
case ro = "RO"
case ru = "RU"
case sa = "SA"
case sn = "SN"
case rs = "RS"
case sg = "SG"
case sk = "SK"
case si = "SI"
case za = "ZA"
case es = "ES"
case lk = "LK"
case se = "SE"
case ch = "CH"
case tw = "TW"
case tz = "TZ"
case th = "TH"
case tn = "TN"
case tr = "TR"
case ug = "UG"
case ua = "UA"
case ae = "AE"
case gb = "GB"
case us = "US"
case uy = "UY"
case ve = "VE"
case vn = "VN"
case vi = "VI"
case ye = "YE"
case zw = "ZW"
}
extension Country {
var name: String {
switch self {
case .dz: return "Algeria"
case .ar: return "Argentina"
case .au: return "Australia"
case .at: return "Austria"
case .az: return "Azerbaijan"
case .bh: return "Bahrain"
case .bd: return "Bangladesh"
case .by: return "Belarus"
case .be: return "Belgium"
case .bo: return "Bolivia (Plurinational State of)"
case .ba: return "Bosnia and Herzegovina"
case .br: return "Brazil"
case .bg: return "Bulgaria"
case .ca: return "Canada"
case .cl: return "Chile"
case .co: return "Colombia"
case .cr: return "Costa Rica"
case .hr: return "Croatia"
case .cy: return "Cyprus"
case .cz: return "Czechia"
case .dk: return "Denmark"
case .do: return "Dominican Republic"
case .ec: return "Ecuador"
case .eg: return "Egypt"
case .sv: return "El Salvador"
case .ee: return "Estonia"
case .fi: return "Finland"
case .fr: return "France"
case .ge: return "Georgia"
case .de: return "Germany"
case .gh: return "Ghana"
case .gr: return "Greece"
case .gt: return "Guatemala"
case .hn: return "Honduras"
case .hk: return "Hong Kong"
case .hu: return "Hungary"
case .is: return "Iceland"
case .in: return "India"
case .id: return "Indonesia"
case .iq: return "Iraq"
case .ie: return "Ireland"
case .il: return "Israel"
case .it: return "Italy"
case .jm: return "Jamaica"
case .jp: return "Japan"
case .jo: return "Jordan"
case .kz: return "Kazakhstan"
case .ke: return "Kenya"
case .kr: return "Korea (Republic of)"
case .kw: return "Kuwait"
case .lv: return "Latvia"
case .lb: return "Lebanon"
case .ly: return "Libya"
case .li: return "Liechtenstein"
case .lt: return "Lithuania"
case .lu: return "Luxembourg"
case .mk: return "Macedonia (the former Yugoslav Republic of)"
case .my: return "Malaysia"
case .mt: return "Malta"
case .mx: return "Mexico"
case .me: return "Montenegro"
case .ma: return "Morocco"
case .np: return "Nepal"
case .nl: return "Netherlands"
case .nz: return "New Zealand"
case .ni: return "Nicaragua"
case .ng: return "Nigeria"
case .no: return "Norway"
case .om: return "Oman"
case .pk: return "Pakistan"
case .pa: return "Panama"
case .pg: return "Papua New Guinea"
case .py: return "Paraguay"
case .pe: return "Peru"
case .ph: return "Philippines"
case .pl: return "Poland"
case .pt: return "Portugal"
case .pr: return "Puerto Rico"
case .qa: return "Qatar"
case .ro: return "Romania"
case .ru: return "Russian Federation"
case .sa: return "Saudi Arabia"
case .sn: return "Senegal"
case .rs: return "Serbia"
case .sg: return "Singapore"
case .sk: return "Slovakia"
case .si: return "Slovenia"
case .za: return "South Africa"
case .es: return "Spain"
case .lk: return "Sri Lanka"
case .se: return "Sweden"
case .ch: return "Switzerland"
case .tw: return "Taiwan, Province of China[a]"
case .tz: return "Tanzania, United Republic of"
case .th: return "Thailand"
case .tn: return "Tunisia"
case .tr: return "Turkey"
case .ug: return "Uganda"
case .ua: return "Ukraine"
case .ae: return "United Arab Emirates"
case .gb: return "United Kingdom of Great Britain and Northern Ireland"
case .us: return "United States of America"
case .uy: return "Uruguay"
case .ve: return "Venezuela (Bolivarian Republic of)"
case .vn: return "Viet Nam"
case .vi: return "Virgin Islands (U.S.)"
case .ye: return "Yemen"
case .zw: return "Zimbabwe"
}
}
var flag: String {
let unicodeScalars = rawValue
.unicodeScalars
.map { $0.value + 0x1F1E6 - 65 }
.compactMap(UnicodeScalar.init)
var result = ""
result.unicodeScalars.append(contentsOf: unicodeScalars)
return result
}
static func search(_ query: String) -> [Country] {
if let country = searchByCode(query) {
return [country]
}
let countries = filteredCountries { stringFolding($0) == stringFolding(query) }
return countries.isEmpty ? searchByPartialName(query) : countries
}
static func searchByCode(_ code: String) -> Country? {
Country(rawValue: code.uppercased())
}
static func searchByPartialName(_ name: String) -> [Country] {
guard !name.isEmpty else {
return []
}
return filteredCountries { stringFolding($0).contains(stringFolding(name)) }
}
private static func stringFolding(_ string: String) -> String {
string.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
}
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
Country.allCases
.map { $0.name }
.filter(predicate)
.compactMap { string in Country.allCases.first { $0.name == string } }
}
}

53
Model/FavoriteItem.swift Normal file
View File

@@ -0,0 +1,53 @@
import Defaults
import Foundation
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
enum Section: Codable, Equatable, Defaults.Serializable {
case subscriptions
case popular
case trending(String, String?)
case channel(String, String)
case playlist(String)
case channelPlaylist(String, String)
case searchQuery(String, String, String, String)
var label: String {
switch self {
case .subscriptions:
return "Subscriptions"
case .popular:
return "Popular"
case let .trending(country, category):
let trendingCountry = Country(rawValue: country)!
let trendingCategory = category.isNil ? nil : TrendingCategory(rawValue: category!)
return "\(trendingCountry.flag) \(trendingCountry.id) \(trendingCategory?.name ?? "Trending")"
case let .channel(_, name):
return name
case let .channelPlaylist(_, name):
return name
case let .searchQuery(text, date, duration, order):
var label = "Search: \"\(text)\""
if !date.isEmpty, let date = SearchQuery.Date(rawValue: date), date != .any {
label += " from \(date == .today ? date.name : " this \(date.name)")"
}
if !order.isEmpty, let order = SearchQuery.SortOrder(rawValue: order), order != .relevance {
label += " by \(order.name)"
}
if !duration.isEmpty {
label += " (\(duration))"
}
return label
default:
return ""
}
}
}
static func == (lhs: FavoriteItem, rhs: FavoriteItem) -> Bool {
lhs.section == rhs.section
}
var id = UUID().uuidString
var section: Section
}

View File

@@ -0,0 +1,77 @@
import Defaults
import Foundation
struct FavoritesModel {
static let shared = FavoritesModel()
@Default(.favorites) var all
func contains(_ item: FavoriteItem) -> Bool {
all.contains { $0 == item }
}
func toggle(_ item: FavoriteItem) {
contains(item) ? remove(item) : add(item)
}
func add(_ item: FavoriteItem) {
all.append(item)
}
func remove(_ item: FavoriteItem) {
if let index = all.firstIndex(where: { $0 == item }) {
all.remove(at: index)
}
}
func canMoveUp(_ item: FavoriteItem) -> Bool {
if let index = all.firstIndex(where: { $0 == item }) {
return index > all.startIndex
}
return false
}
func canMoveDown(_ item: FavoriteItem) -> Bool {
if let index = all.firstIndex(where: { $0 == item }) {
return index < all.endIndex - 1
}
return false
}
func moveUp(_ item: FavoriteItem) {
guard canMoveUp(item) else {
return
}
if let from = all.firstIndex(where: { $0 == item }) {
all.move(
fromOffsets: IndexSet(integer: from),
toOffset: from - 1
)
}
}
func moveDown(_ item: FavoriteItem) {
guard canMoveDown(item) else {
return
}
if let from = all.firstIndex(where: { $0 == item }) {
all.move(
fromOffsets: IndexSet(integer: from),
toOffset: from + 2
)
}
}
func addableItems() -> [FavoriteItem] {
let allItems = [
FavoriteItem(section: .subscriptions),
FavoriteItem(section: .popular)
]
return allItems.filter { item in !all.contains { $0.section == item.section } }
}
}

18
Model/MenuModel.swift Normal file
View File

@@ -0,0 +1,18 @@
import Combine
import Foundation
final class MenuModel: ObservableObject {
@Published var accounts: AccountsModel? { didSet { registerChildModel(accounts) } }
@Published var navigation: NavigationModel? { didSet { registerChildModel(navigation) } }
@Published var player: PlayerModel? { didSet { registerChildModel(player) } }
private var cancellables = [AnyCancellable]()
func registerChildModel<T: ObservableObject>(_ model: T?) {
guard !model.isNil else {
return
}
cancellables.append(model!.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() })
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
import SwiftUI
final class NavigationModel: ObservableObject {
enum TabSelection: Hashable {
case favorites
case subscriptions
case popular
case trending
case playlists
case channel(String)
case playlist(String)
case recentlyOpened(String)
case nowPlaying
case search
var playlistID: Playlist.ID? {
if case let .playlist(id) = self {
return id
}
return nil
}
}
@Published var tabSelection: TabSelection! = .favorites
@Published var presentingAddToPlaylist = false
@Published var videoToAddToPlaylist: Video!
@Published var presentingPlaylistForm = false
@Published var editedPlaylist: Playlist!
@Published var presentingUnsubscribeAlert = false
@Published var channelToUnsubscribe: Channel!
@Published var presentingChannel = false
@Published var presentingPlaylist = false
@Published var sidebarSectionChanged = false
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false
var tabSelectionBinding: Binding<TabSelection> {
Binding<TabSelection>(
get: {
self.tabSelection ?? .favorites
},
set: { newValue in
self.tabSelection = newValue
}
)
}
func presentAddToPlaylist(_ video: Video) {
videoToAddToPlaylist = video
presentingAddToPlaylist = true
}
func presentEditPlaylistForm(_ playlist: Playlist?) {
editedPlaylist = playlist
presentingPlaylistForm = editedPlaylist != nil
}
func presentNewPlaylistForm() {
editedPlaylist = nil
presentingPlaylistForm = true
}
func presentUnsubscribeAlert(_ channel: Channel?) {
channelToUnsubscribe = channel
presentingUnsubscribeAlert = channelToUnsubscribe != nil
}
}
typealias TabSelection = NavigationModel.TabSelection

View File

@@ -0,0 +1,520 @@
import AVKit
import Defaults
import Foundation
import Logging
import MediaPlayer
#if !os(macOS)
import UIKit
#endif
import Siesta
import SwiftUI
import SwiftyJSON
final class PlayerModel: ObservableObject {
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
let logger = Logger(label: "stream.yattee.app")
private(set) var player = AVPlayer()
private(set) var playerView = Player()
var controller: PlayerViewController? { didSet { playerView.controller = controller } }
#if os(tvOS)
var avPlayerViewController: AVPlayerViewController?
#endif
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } }
@Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
@Published var availableStreams = [Stream]() { didSet { rebuildTVMenu() } }
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } }
@Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } }
@Published var savedTime: CMTime?
@Published var playerNavigationLinkActive = false
@Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime?
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
@Published var restoredSegments = [Segment]()
var accounts: AccountsModel
var composition = AVMutableComposition()
var loadedCompositionAssets = [AVMediaType]()
private var currentArtwork: MPMediaItemArtwork?
private var frequentTimeObserver: Any?
private var infrequentTimeObserver: Any?
private var playerTimeControlStatusObserver: Any?
private var statusObservation: NSKeyValueObservation?
private var timeObserverThrottle = Throttle(interval: 2)
var playingInPictureInPicture = false
@Published var presentingErrorDetails = false
var playerError: Error? { didSet {
#if !os(tvOS)
if !playerError.isNil {
presentingErrorDetails = true
}
#endif
}}
init(accounts: AccountsModel? = nil, instances _: InstancesModel? = nil) {
self.accounts = accounts ?? AccountsModel()
addItemDidPlayToEndTimeObserver()
addFrequentTimeObserver()
addInfrequentTimeObserver()
addPlayerTimeControlStatusObserver()
}
func presentPlayer() {
presentingPlayer = true
}
func togglePlayer() {
presentingPlayer.toggle()
}
var isPlaying: Bool {
player.timeControlStatus == .playing
}
var time: CMTime? {
currentItem?.playbackTime
}
var live: Bool {
currentItem?.video?.live ?? false
}
var playerItemDuration: CMTime? {
player.currentItem?.asset.duration
}
var videoDuration: TimeInterval? {
currentItem?.duration ?? currentVideo?.length
}
func togglePlay() {
isPlaying ? pause() : play()
}
func play() {
guard player.timeControlStatus != .playing else {
return
}
player.play()
}
func pause() {
guard player.timeControlStatus != .paused else {
return
}
player.pause()
}
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
playStream(stream, of: currentVideo!, preservingTime: true)
}
}
func playStream(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false
) {
playerError = nil
resetSegments()
sponsorBlock.loadSegments(videoID: video.videoID, categories: Defaults[.sponsorBlockCategories])
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
insertPlayerItem(stream, for: video, preservingTime: preservingTime)
} else {
logger.info("playing stream with many assets:")
logger.info("composition audio asset: \(stream.audioAsset.url)")
logger.info("composition video asset: \(stream.videoAsset.url)")
loadComposition(stream, of: video, preservingTime: preservingTime)
}
updateCurrentArtwork()
}
private func pauseOnPlayerDismiss() {
if !playingInPictureInPicture, !presentingPlayer {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.pause()
}
}
}
private func insertPlayerItem(
_ stream: Stream,
for video: Video,
preservingTime: Bool = false
) {
let playerItem = playerItem(stream)
guard playerItem != nil else {
return
}
attachMetadata(to: playerItem!, video: video, for: stream)
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
self.stream = stream
self.composition = AVMutableComposition()
}
let startPlaying = {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
if self.isAutoplaying(playerItem!) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.play()
}
}
}
let replaceItemAndSeek = {
self.player.replaceCurrentItem(with: playerItem)
self.seekToSavedTime { finished in
guard finished else {
return
}
self.savedTime = nil
startPlaying()
}
}
if preservingTime {
if savedTime.isNil {
saveTime {
replaceItemAndSeek()
startPlaying()
}
} else {
replaceItemAndSeek()
startPlaying()
}
} else {
player.replaceCurrentItem(with: playerItem)
startPlaying()
}
}
private func loadComposition(
_ stream: Stream,
of video: Video,
preservingTime: Bool = false
) {
loadedCompositionAssets = []
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime)
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime)
}
func loadCompositionAsset(
_ asset: AVURLAsset,
stream: Stream,
type: AVMediaType,
of video: Video,
preservingTime: Bool = false
) {
asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
guard let self = self else {
return
}
self.logger.info("loading \(type.rawValue) track")
let assetTracks = asset.tracks(withMediaType: type)
guard let compositionTrack = self.composition.addMutableTrack(
withMediaType: type,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
return
}
guard let assetTrack = assetTracks.first else {
self.logger.critical("asset \(type.rawValue) track FAILED")
return
}
try! compositionTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
of: assetTrack,
at: .zero
)
self.logger.critical("\(type.rawValue) LOADED")
guard self.streamSelection == stream else {
self.logger.critical("IGNORING LOADED")
return
}
self.loadedCompositionAssets.append(type)
if self.loadedCompositionAssets.count == 2 {
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
}
}
}
private func playerItem(_ stream: Stream) -> AVPlayerItem? {
if let url = stream.singleAssetURL {
return AVPlayerItem(asset: AVURLAsset(url: url))
} else {
return AVPlayerItem(asset: composition)
}
}
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
#if !os(macOS)
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
]
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
let image = UIImage(data: thumbnailData),
let pngData = image.pngData()
{
let artworkItem = makeMetadataItem(.commonIdentifierArtwork, value: pngData)
externalMetadata.append(artworkItem)
}
item.externalMetadata = externalMetadata
#endif
item.preferredForwardBufferDuration = 5
statusObservation?.invalidate()
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
guard let self = self else {
return
}
switch playerItem.status {
case .readyToPlay:
if self.isAutoplaying(playerItem) {
self.play()
}
case .failed:
self.playerError = item.error
default:
return
}
}
}
#if !os(macOS)
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
item.extendedLanguageTag = "und"
return item.copy() as! AVMetadataItem
}
#endif
private func addItemDidPlayToEndTimeObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(itemDidPlayToEndTime),
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: nil
)
}
@objc func itemDidPlayToEndTime() {
currentItem.playbackTime = playerItemDuration
if queue.isEmpty {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(false)
#endif
addCurrentItemToHistory()
resetQueue()
#if os(tvOS)
avPlayerViewController!.dismiss(animated: true) { [weak self] in
self?.controller!.dismiss(animated: true)
}
#endif
presentingPlayer = false
} else {
advanceToNextItem()
}
}
private func saveTime(completionHandler: @escaping () -> Void = {}) {
let currentTime = player.currentTime()
guard currentTime.seconds > 0 else {
return
}
DispatchQueue.main.async { [weak self] in
self?.savedTime = currentTime
completionHandler()
}
}
private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = savedTime else {
return
}
player.seek(
to: time,
toleranceBefore: .secondsInDefaultTimescale(1),
toleranceAfter: .zero,
completionHandler: completionHandler
)
}
private func addFrequentTimeObserver() {
let interval = CMTime.secondsInDefaultTimescale(0.5)
frequentTimeObserver = player.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] _ in
guard let self = self else {
return
}
guard !self.currentItem.isNil else {
return
}
#if !os(tvOS)
self.updateNowPlayingInfo()
#endif
self.handleSegments(at: self.player.currentTime())
}
}
private func addInfrequentTimeObserver() {
let interval = CMTime.secondsInDefaultTimescale(5)
infrequentTimeObserver = player.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] _ in
guard let self = self else {
return
}
guard !self.currentItem.isNil else {
return
}
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
}
}
}
private func addPlayerTimeControlStatusObserver() {
playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { [weak self] player, _ in
guard let self = self,
self.player == player
else {
return
}
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
self.objectWillChange.send()
}
if player.timeControlStatus == .playing, player.rate != self.currentRate {
player.rate = self.currentRate
}
#if os(macOS)
if player.timeControlStatus == .playing {
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
} else {
ScreenSaverManager.shared.enable()
}
#endif
self.timeObserverThrottle.execute {
self.updateCurrentItemIntervals()
}
}
}
private func updateCurrentItemIntervals() {
currentItem?.playbackTime = player.currentTime()
currentItem?.videoDuration = player.currentItem?.asset.duration.seconds
}
fileprivate func updateNowPlayingInfo() {
let duration: Int? = currentItem.video.live ? nil : Int(currentItem.videoDuration ?? 0)
let nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
MPMediaItemPropertyArtwork: currentArtwork as AnyObject,
MPMediaItemPropertyPlaybackDuration: duration as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func updateCurrentArtwork() {
guard let thumbnailData = try? Data(contentsOf: currentItem.video.thumbnailURL(quality: .medium)!) else {
return
}
#if os(macOS)
let image = NSImage(data: thumbnailData)
#else
let image = UIImage(data: thumbnailData)
#endif
if image.isNil {
return
}
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
}
func rateLabel(_ rate: Float) -> String {
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
return "\(formatter.string(from: NSNumber(value: rate))!)×"
}
}

View File

@@ -0,0 +1,238 @@
import AVFoundation
import Defaults
import Foundation
import Siesta
extension PlayerModel {
var currentVideo: Video? {
currentItem?.video
}
func playAll(_ videos: [Video]) {
let first = videos.first
videos.forEach { video in
enqueueVideo(video) { _, item in
if item.video == first {
self.advanceToItem(item)
}
}
}
}
func playNext(_ video: Video) {
enqueueVideo(video, prepending: true) { _, item in
if self.currentItem.isNil {
self.advanceToItem(item)
}
}
}
func playNow(_ video: Video, at time: TimeInterval? = nil) {
addCurrentItemToHistory()
enqueueVideo(video, prepending: true) { _, item in
self.advanceToItem(item, at: time)
}
}
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
currentItem = item
if !time.isNil {
currentItem.playbackTime = .secondsInDefaultTimescale(time!)
} else if currentItem.playbackTime.isNil {
currentItem.playbackTime = .zero
}
if video != nil {
currentItem.video = video!
}
savedTime = currentItem.playbackTime
loadAvailableStreams(currentVideo!) { streams in
guard let stream = self.preferredStream(streams) else {
return
}
self.streamSelection = stream
self.playStream(
stream,
of: self.currentVideo!,
preservingTime: !self.currentItem.playbackTime.isNil
)
}
}
private func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality]
var streams = streams
if let id = Defaults[.playerInstanceID] {
streams = streams.filter { $0.instance.id == id }
}
switch quality {
case .best:
return streams.first { $0.kind == .hls } ?? streams.first
default:
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
return sorted.first(where: { $0.resolution.height <= quality.value.height })
}
}
func advanceToNextItem() {
addCurrentItemToHistory()
if let nextItem = queue.first {
advanceToItem(nextItem)
}
}
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
addCurrentItemToHistory()
remove(newItem)
accounts.api.loadDetails(newItem) { newItem in
self.playItem(newItem, video: newItem.video, at: time)
}
}
@discardableResult func remove(_ item: PlayerQueueItem) -> PlayerQueueItem? {
if let index = queue.firstIndex(where: { $0.videoID == item.videoID }) {
return queue.remove(at: index)
}
return nil
}
func resetQueue() {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
self.currentItem = nil
self.stream = nil
self.removeQueueItems()
}
player.replaceCurrentItem(with: nil)
}
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item && presentingPlayer
}
@discardableResult func enqueueVideo(
_ video: Video,
play: Bool = false,
atTime: CMTime? = nil,
prepending: Bool = false,
videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in }
) -> PlayerQueueItem? {
let item = PlayerQueueItem(video, playbackTime: atTime)
queue.insert(item, at: prepending ? 0 : queue.endIndex)
accounts.api.loadDetails(item) { newItem in
videoDetailsLoadHandler(newItem.video, newItem)
if play {
self.playItem(newItem, video: video)
}
}
return item
}
func addCurrentItemToHistory() {
if let item = currentItem, Defaults[.saveHistory] {
addItemToHistory(item)
}
}
func addItemToHistory(_ item: PlayerQueueItem) {
if let index = history.firstIndex(where: { $0.video?.videoID == item.video?.videoID }) {
history.remove(at: index)
}
history.insert(currentItem, at: 0)
}
func playHistory(_ item: PlayerQueueItem) {
var time = item.playbackTime
if item.shouldRestartPlaying {
time = .zero
}
let newItem = enqueueVideo(item.video, atTime: time, prepending: true)
advanceToItem(newItem!)
if let historyItemIndex = history.firstIndex(of: item) {
history.remove(at: historyItemIndex)
}
}
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
if let index = history.firstIndex(where: { $0 == item }) {
return history.remove(at: index)
}
return nil
}
func removeQueueItems() {
queue.removeAll()
}
func removeHistoryItems() {
history.removeAll()
}
func loadHistoryDetails() {
guard !accounts.current.isNil else {
return
}
queue = Defaults[.queue]
queue.forEach { item in
accounts.api.loadDetails(item) { newItem in
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
self.queue[index] = newItem
}
}
}
var savedHistory = Defaults[.history]
if let lastPlayed = Defaults[.lastPlayed] {
if let index = savedHistory.firstIndex(where: { $0.videoID == lastPlayed.videoID }) {
var updatedLastPlayed = savedHistory[index]
updatedLastPlayed.playbackTime = lastPlayed.playbackTime
updatedLastPlayed.videoDuration = lastPlayed.videoDuration
savedHistory.remove(at: index)
savedHistory.insert(updatedLastPlayed, at: 0)
} else {
savedHistory.insert(lastPlayed, at: 0)
}
Defaults[.lastPlayed] = nil
}
history = savedHistory
history.forEach { item in
accounts.api.loadDetails(item) { newItem in
if let index = self.history.firstIndex(where: { $0.id == item.id }) {
self.history[index] = newItem
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
import AVFoundation
import Defaults
import Foundation
struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
static let bridge = PlayerQueueItemBridge()
var id = UUID()
var video: Video!
var videoID: Video.ID
var playbackTime: CMTime?
var videoDuration: TimeInterval?
init(_ video: Video? = nil, videoID: Video.ID? = nil, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) {
self.video = video
self.videoID = videoID ?? video!.videoID
self.playbackTime = playbackTime
self.videoDuration = videoDuration
}
var duration: TimeInterval {
videoDuration ?? video.length
}
var shouldRestartPlaying: Bool {
guard let seconds = playbackTime?.seconds else {
return false
}
return duration - seconds <= 10
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@@ -0,0 +1,65 @@
import CoreMedia
import Defaults
import Foundation
struct PlayerQueueItemBridge: Defaults.Bridge {
typealias Value = PlayerQueueItem
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
var playbackTime = ""
if let time = value.playbackTime {
if time.seconds.isFinite {
playbackTime = String(time.seconds)
}
}
var videoDuration = ""
if let duration = value.videoDuration {
if duration.isFinite {
videoDuration = String(duration)
}
}
return [
"videoID": value.videoID,
"playbackTime": playbackTime,
"videoDuration": videoDuration
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let videoID = object["videoID"]
else {
return nil
}
var playbackTime: CMTime?
var videoDuration: TimeInterval?
if let time = object["playbackTime"],
!time.isEmpty,
let seconds = TimeInterval(time)
{
playbackTime = .secondsInDefaultTimescale(seconds)
}
if let duration = object["videoDuration"],
!duration.isEmpty
{
videoDuration = TimeInterval(duration)
}
return PlayerQueueItem(
videoID: videoID,
playbackTime: playbackTime,
videoDuration: videoDuration
)
}
}

View File

@@ -0,0 +1,79 @@
import CoreMedia
import Defaults
import Foundation
extension PlayerModel {
func handleSegments(at time: CMTime) {
if let segment = lastSkipped {
if time > .secondsInDefaultTimescale(segment.end + 10) {
resetLastSegment()
}
}
guard let firstSegment = sponsorBlock.segments.first(where: { $0.timeInSegment(time) }) else {
return
}
// find last segment in case they are 2 sec or less after each other
// to avoid multiple skips in a row
var nextSegments = [firstSegment]
while let segment = sponsorBlock.segments.first(where: {
$0.timeInSegment(.secondsInDefaultTimescale(nextSegments.last!.end + 2))
}) {
nextSegments.append(segment)
}
if let segmentToSkip = nextSegments.last(where: { $0.endTime <= playerItemDuration ?? .zero }),
self.shouldSkip(segmentToSkip, at: time)
{
skip(segmentToSkip, at: time)
}
}
private func skip(_ segment: Segment, at time: CMTime) {
guard segment.endTime.seconds <= playerItemDuration?.seconds ?? .infinity else {
logger.error(
"segment end time is: \(segment.end) when player item duration is: \(playerItemDuration?.seconds ?? .infinity)"
)
return
}
player.seek(to: segment.endTime)
lastSkipped = segment
segmentRestorationTime = time
logger.info("SponsorBlock skipping to: \(segment.end)")
}
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
guard isPlaying,
!restoredSegments.contains(segment),
Defaults[.sponsorBlockCategories].contains(segment.category)
else {
return false
}
return time.seconds - segment.start < 2 && segment.end - time.seconds > 2
}
func restoreLastSkippedSegment() {
guard let segment = lastSkipped,
let time = segmentRestorationTime
else {
return
}
restoredSegments.append(segment)
player.seek(to: time)
resetLastSegment()
}
private func resetLastSegment() {
lastSkipped = nil
segmentRestorationTime = nil
}
func resetSegments() {
resetLastSegment()
restoredSegments = []
}
}

View File

@@ -0,0 +1,87 @@
import Foundation
import Siesta
import SwiftUI
extension PlayerModel {
var isLoadingAvailableStreams: Bool {
streamSelection.isNil || availableStreams.isEmpty
}
var isLoadingStream: Bool {
!stream.isNil && stream != streamSelection
}
var availableStreamsSorted: [Stream] {
availableStreams.sorted(by: streamsSorter)
}
func loadAvailableStreams(
_ video: Video,
completionHandler: @escaping ([Stream]) -> Void = { _ in }
) {
availableStreams = []
var instancesWithLoadedStreams = [Instance]()
InstancesModel.all.forEach { instance in
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
}
}
private func fetchStreams(
_ resource: Resource,
instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
resource
.load()
.onSuccess { response in
if let video: Video = response.typedContent() {
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
}
}
.onCompletion(onCompletion)
}
private func completeIfAllInstancesLoaded(
instance: Instance,
streams: [Stream],
instancesWithLoadedStreams: inout [Instance],
completionHandler: @escaping ([Stream]) -> Void
) {
instancesWithLoadedStreams.append(instance)
rebuildTVMenu()
if InstancesModel.all.count == instancesWithLoadedStreams.count {
completionHandler(streams.sorted { $0.kind < $1.kind })
}
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in
stream.instance = instance
if instance.app == .invidious {
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.audioAsset)
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: stream.videoAsset)
}
return stream
}
}
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
if lhs.resolution.isNil || rhs.resolution.isNil {
return lhs.kind < rhs.kind
}
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
#if !os(macOS)
import UIKit
#endif
extension PlayerModel {
#if os(tvOS)
var streamsMenu: UIMenu {
UIMenu(
title: "Streams",
image: UIImage(systemName: "antenna.radiowaves.left.and.right"),
children: streamsMenuActions
)
}
var streamsMenuActions: [UIAction] {
guard !availableStreams.isEmpty else {
return [ // swiftlint:disable:this implicit_return
UIAction(title: "Empty", attributes: .disabled) { _ in }
]
}
return availableStreamsSorted.map { stream in
let state = stream == streamSelection ? UIAction.State.on : .off
return UIAction(title: stream.description, state: state) { _ in
self.streamSelection = stream
self.upgradeToStream(stream)
}
}
}
var restoreLastSkippedSegmentAction: UIAction? {
guard let segment = lastSkipped else {
return nil // swiftlint:disable:this implicit_return
}
return UIAction(
title: "Restore \(segment.title())",
image: UIImage(systemName: "arrow.uturn.left.circle")
) { _ in
self.restoreLastSkippedSegment()
}
}
private var rateMenu: UIMenu {
UIMenu(title: "Playback rate", image: UIImage(systemName: rateMenuSystemImage), children: rateMenuActions)
}
private var rateMenuSystemImage: String {
[0.0, 1.0].contains(currentRate) ? "speedometer" : (currentRate < 1.0 ? "tortoise.fill" : "hare.fill")
}
private var rateMenuActions: [UIAction] {
PlayerModel.availableRates.map { rate in
let image = currentRate == Float(rate) ? UIImage(systemName: "checkmark") : nil
return UIAction(title: rateLabel(rate), image: image) { _ in
DispatchQueue.main.async {
self.currentRate = rate
}
}
}
}
#endif
func rebuildTVMenu() {
#if os(tvOS)
avPlayerViewController?.transportBarCustomMenuItems = [
restoreLastSkippedSegmentAction,
rateMenu,
streamsMenu
].compactMap { $0 }
#endif
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
import IOKit.pwr_mgt
struct ScreenSaverManager {
static var shared = ScreenSaverManager()
var noSleepAssertion: IOPMAssertionID = 0
var noSleepReturn: IOReturn?
var enabled: Bool {
noSleepReturn == nil
}
@discardableResult mutating func disable(reason: String = "Unknown reason") -> Bool {
guard enabled else {
return false
}
noSleepReturn = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason as CFString,
&noSleepAssertion)
return noSleepReturn == kIOReturnSuccess
}
@discardableResult mutating func enable() -> Bool {
if noSleepReturn != nil {
_ = IOPMAssertionRelease(noSleepAssertion) == kIOReturnSuccess
noSleepReturn = nil
return true
}
return false
}
}

47
Model/Playlist.swift Normal file
View File

@@ -0,0 +1,47 @@
import Foundation
import SwiftyJSON
struct Playlist: Identifiable, Equatable, Hashable {
enum Visibility: String, CaseIterable, Identifiable {
case `public`, unlisted, `private`
var id: String {
rawValue
}
var name: String {
rawValue.capitalized
}
}
let id: String
var title: String
var visibility: Visibility
var updated: TimeInterval
var videos = [Video]()
init(id: String, title: String, visibility: Visibility, updated: TimeInterval) {
self.id = id
self.title = title
self.visibility = visibility
self.updated = updated
}
init(_ json: JSON) {
id = json["playlistId"].stringValue
title = json["title"].stringValue
visibility = json["isListed"].boolValue ? .public : .private
updated = json["updated"].doubleValue
videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) }
}
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
lhs.id == rhs.id && lhs.updated == rhs.updated
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import Siesta
import SwiftUI
final class PlaylistsModel: ObservableObject {
@Published var playlists = [Playlist]()
var accounts = AccountsModel()
init(_ playlists: [Playlist] = [Playlist]()) {
self.playlists = playlists
}
var all: [Playlist] {
playlists.sorted { $0.title.lowercased() < $1.title.lowercased() }
}
func find(id: Playlist.ID?) -> Playlist? {
if id.isNil {
return nil
}
return playlists.first { $0.id == id! }
}
var isEmpty: Bool {
playlists.isEmpty
}
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 {
onSuccess()
return
}
request?
.onSuccess { resource in
if let playlists: [Playlist] = resource.typedContent() {
self.playlists = playlists
onSuccess()
}
}
.onFailure { _ in
self.playlists = []
}
}
func addVideo(playlistID: Playlist.ID, videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
let resource = accounts.api.playlistVideos(playlistID)
let body = ["videoId": videoID]
resource?.request(.post, json: body).onSuccess { _ in
self.load(force: true)
onSuccess()
}
}
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
resource?.request(.delete).onSuccess { _ in
self.load(force: true)
onSuccess()
}
}
private var resource: Resource? {
accounts.api.playlists
}
}

View File

@@ -0,0 +1,22 @@
import Alamofire
import Foundation
import SwiftyJSON
final class PlaylistsProvider: DataProvider {
@Published var playlists = [Playlist]()
let profile = Profile()
func load(successHandler: @escaping ([Playlist]) -> Void = { _ in }) {
let headers = HTTPHeaders([HTTPHeader(name: "Cookie", value: "SID=\(profile.sid)")])
DataProvider.request("auth/playlists", headers: headers).responseJSON { response in
switch response.result {
case let .success(value):
self.playlists = JSON(value).arrayValue.map { Playlist($0) }
successHandler(self.playlists)
case let .failure(error):
print(error)
}
}
}
}

148
Model/RecentsModel.swift Normal file
View File

@@ -0,0 +1,148 @@
import Defaults
import Foundation
final class RecentsModel: ObservableObject {
@Default(.recentlyOpened) var items
func clear() {
items = []
}
func clearQueries() {
items.removeAll { $0.type == .query }
}
func add(_ item: RecentItem) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
items.remove(at: index)
}
items.append(item)
}
func close(_ item: RecentItem) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
items.remove(at: index)
}
}
func addQuery(_ query: String) {
if !query.isEmpty {
add(.init(from: query))
}
}
var presentedChannel: Channel? {
if let recent = items.last(where: { $0.type == .channel }) {
return recent.channel
}
return nil
}
var presentedPlaylist: ChannelPlaylist? {
if let recent = items.last(where: { $0.type == .playlist }) {
return recent.playlist
}
return nil
}
}
struct RecentItem: Defaults.Serializable, Identifiable {
static var bridge = RecentItemBridge()
enum ItemType: String {
case channel, playlist, query
}
var type: ItemType
var id: String
var title: String
var tag: String {
"recent\(type.rawValue.capitalized)\(id)"
}
var query: SearchQuery? {
guard type == .query else {
return nil
}
return SearchQuery(query: title)
}
var channel: Channel? {
guard type == .channel else {
return nil
}
return Channel(id: id, name: title)
}
var playlist: ChannelPlaylist? {
guard type == .playlist else {
return nil
}
return ChannelPlaylist(id: id, title: title)
}
init(type: ItemType, identifier: String, title: String) {
self.type = type
id = identifier
self.title = title
}
init(from channel: Channel) {
type = .channel
id = channel.id
title = channel.name
}
init(from query: String) {
type = .query
id = query
title = query
}
init(from playlist: ChannelPlaylist) {
type = .playlist
id = playlist.id
title = playlist.title
}
}
struct RecentItemBridge: Defaults.Bridge {
typealias Value = RecentItem
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"type": value.type.rawValue,
"identifier": value.id,
"title": value.title
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let type = object["type"],
let identifier = object["identifier"],
let title = object["title"]
else {
return nil
}
return RecentItem(
type: .init(rawValue: type)!,
identifier: identifier,
title: title
)
}
}

View File

@@ -0,0 +1,108 @@
import Defaults
import Siesta
import SwiftUI
final class SearchModel: ObservableObject {
@Published var store = Store<[ContentItem]>()
var accounts = AccountsModel()
@Published var query = SearchQuery()
@Published var queryText = ""
@Published var querySuggestions = Store<[String]>()
@Published var fieldIsFocused = false
private var previousResource: Resource?
private var resource: Resource!
var isLoading: Bool {
resource?.isLoading ?? false
}
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
return
}
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
}
}
func resetQuery(_ query: SearchQuery = SearchQuery()) {
self.query = query
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
return
}
store.replace([])
previousResource?.removeObservers(ownedBy: store)
previousResource = newResource
resource = newResource
resource.addObserver(store)
if !query.isEmpty {
loadResourceIfNeededAndReplaceStore()
}
}
func loadResourceIfNeededAndReplaceStore() {
let currentResource = resource!
if let request = resource.loadIfNeeded() {
request.onSuccess { response in
if let results: [ContentItem] = response.typedContent() {
self.replace(results, for: currentResource)
}
}
} else {
replace(store.collection, for: currentResource)
}
}
func replace(_ videos: [ContentItem], for resource: Resource) {
if self.resource == resource {
store = Store<[ContentItem]>(videos)
}
}
private var suggestionsDebounceTimer: Timer?
func loadSuggestions(_ query: String) {
guard !query.isEmpty else {
return
}
suggestionsDebounceTimer?.invalidate()
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
let resource = self.accounts.api.searchSuggestions(query: query)
resource.addObserver(self.querySuggestions)
resource.loadIfNeeded()
if let request = resource.loadIfNeeded() {
request.onSuccess { response in
if let suggestions: [String] = response.typedContent() {
self.querySuggestions = Store<[String]>(suggestions)
}
}
} else {
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
}
}
}
}

View File

@@ -0,0 +1,77 @@
import Defaults
import Foundation
final class SearchQuery: ObservableObject {
enum Date: String, CaseIterable, Identifiable {
case any, hour, today, week, month, year
var id: SearchQuery.Date.RawValue {
rawValue
}
var name: String {
rawValue.capitalized
}
}
enum Duration: String, CaseIterable, Identifiable {
case any, short, long
var id: SearchQuery.Duration.RawValue {
rawValue
}
var name: String {
rawValue.capitalized
}
}
enum SortOrder: String, CaseIterable, Identifiable {
case relevance, rating, uploadDate, viewCount
var id: SearchQuery.SortOrder.RawValue {
rawValue
}
var name: String {
switch self {
case .uploadDate:
return "Date"
case .viewCount:
return "Views"
default:
return rawValue.capitalized
}
}
var parameter: String {
switch self {
case .uploadDate:
return "upload_date"
case .viewCount:
return "view_count"
default:
return rawValue
}
}
}
@Published var query: String
@Published var sortBy: SearchQuery.SortOrder = .relevance
@Published var date: SearchQuery.Date? = .month
@Published var duration: SearchQuery.Duration?
@Published var page = 1
init(query: String = "", page: Int = 1, sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
self.query = query
self.page = page
self.sortBy = sortBy
self.date = date
self.duration = duration
}
var isEmpty: Bool {
query.isEmpty
}
}

46
Model/Segment.swift Normal file
View File

@@ -0,0 +1,46 @@
import CoreMedia
import Foundation
import SwiftyJSON
// swiftlint:disable:next final_class
class Segment: ObservableObject, Hashable {
let category: String
let segment: [Double]
let uuid: String
let videoDuration: Int
var start: Double {
segment.first!
}
var end: Double {
segment.last!
}
var endTime: CMTime {
CMTime(seconds: end, preferredTimescale: 1000)
}
init(category: String, segment: [Double], uuid: String, videoDuration: Int) {
self.category = category
self.segment = segment
self.uuid = uuid
self.videoDuration = videoDuration
}
func timeInSegment(_ time: CMTime) -> Bool {
(start ... end).contains(time.seconds)
}
func hash(into hasher: inout Hasher) {
hasher.combine(uuid)
}
static func == (lhs: Segment, rhs: Segment) -> Bool {
lhs.uuid == rhs.uuid
}
func title() -> String {
category
}
}

View File

@@ -0,0 +1,12 @@
import AVFoundation
import Foundation
final class SingleAssetStream: Stream {
var avAsset: AVURLAsset
init(avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "") {
self.avAsset = avAsset
super.init(audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding)
}
}

View File

@@ -0,0 +1,73 @@
import Alamofire
import Defaults
import Foundation
import Logging
import SwiftyJSON
final class SponsorBlockAPI: ObservableObject {
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
let logger = Logger(label: "net.yattee.app.sb")
@Published var videoID: String?
@Published var segments = [Segment]()
static func categoryDescription(_ name: String) -> String? {
guard SponsorBlockAPI.categories.contains(name) else {
return nil
}
switch name {
case "selfpromo":
return "Self-promotion"
case "music_offtopic":
return "Offtopic in Music Videos"
default:
return name.capitalized
}
}
func loadSegments(videoID: String, categories: Set<String>) {
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
return
}
self.videoID = videoID
requestSegments(categories: categories)
}
private func requestSegments(categories: Set<String>) {
guard let url = skipSegmentsURL, !categories.isEmpty else {
return
}
AF.request(url, parameters: parameters(categories: categories)).responseJSON { response in
switch response.result {
case let .success(value):
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
self.segments.forEach {
self.logger.info("\($0.start) -> \($0.end)")
}
case let .failure(error):
self.segments = []
self.logger.error("failed to load SponsorBlock segments: \(error.localizedDescription)")
}
}
}
private var skipSegmentsURL: String? {
let url = Defaults[.sponsorBlockInstance]
return url.isEmpty ? nil : "\(url)/api/skipSegments"
}
private func parameters(categories: Set<String>) -> [String: String] {
[
"videoID": videoID!,
"categories": JSON(Array(categories)).rawString(String.Encoding.utf8)!
]
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
import SwiftyJSON
final class SponsorBlockSegment: Segment {
init(_ json: JSON) {
super.init(
category: json["category"].string!,
segment: json["segment"].array!.map { $0.double! },
uuid: json["UUID"].string!,
videoDuration: json["videoDuration"].int!
)
}
override func title() -> String {
switch category {
case "selfpromo":
return "self-promotion"
case "music_offtopic":
return "offtopic"
default:
return category
}
}
}

25
Model/Store.swift Normal file
View File

@@ -0,0 +1,25 @@
import Foundation
import Siesta
final class Store<Data>: ResourceObserver, ObservableObject {
@Published private var all: Data?
var collection: Data { all ?? ([] as! Data) }
var item: Data? { all }
init(_ data: Data? = nil) {
if data != nil {
replace(data!)
}
}
func resourceChanged(_ resource: Resource, event _: ResourceEvent) {
if let items: Data = resource.typedContent() {
replace(items)
}
}
func replace(_ items: Data) {
all = items
}
}

125
Model/Stream.swift Normal file
View File

@@ -0,0 +1,125 @@
import AVFoundation
import Defaults
import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable, Identifiable {
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
case hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
var name: String {
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
}
var height: Int {
if self == .unknown {
return -1
}
let resolutionPart = rawValue.components(separatedBy: "p").first!
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
}
var refreshRate: Int {
if self == .unknown {
return -1
}
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
}
static func from(resolution: String) -> Resolution {
allCases.first { "\($0)".contains(resolution) } ?? .unknown
}
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
lhs.height < rhs.height
}
}
enum Kind: String, Comparable {
case stream, adaptive, hls
private var sortOrder: Int {
switch self {
case .hls:
return 0
case .stream:
return 1
case .adaptive:
return 2
}
}
static func < (lhs: Kind, rhs: Kind) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
}
let id = UUID()
var instance: Instance!
var audioAsset: AVURLAsset!
var videoAsset: AVURLAsset!
var hlsURL: URL!
var resolution: Resolution!
var kind: Kind!
var encoding: String!
init(
instance: Instance? = nil,
audioAsset: AVURLAsset? = nil,
videoAsset: AVURLAsset? = nil,
hlsURL: URL? = nil,
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil
) {
self.instance = instance
self.audioAsset = audioAsset
self.videoAsset = videoAsset
self.hlsURL = hlsURL
self.resolution = resolution
self.kind = kind
self.encoding = encoding
}
var quality: String {
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
}
var description: String {
"\(quality) - \(instance?.description ?? "")"
}
var assets: [AVURLAsset] {
[audioAsset, videoAsset]
}
var videoAssetContainsAudio: Bool {
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
}
var singleAssetURL: URL? {
if kind == .hls {
return hlsURL
} else if videoAssetContainsAudio {
return videoAsset.url
}
return nil
}
static func == (lhs: Stream, rhs: Stream) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(videoAsset?.url)
hasher.combine(audioAsset?.url)
hasher.combine(hlsURL)
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
import Siesta
import SwiftUI
final class SubscriptionsModel: ObservableObject {
@Published var channels = [Channel]()
var accounts: AccountsModel
var resource: Resource? {
accounts.api.subscriptions
}
init(accounts: AccountsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
}
var all: [Channel] {
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
accounts.api.subscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
accounts.api.unsubscribe(channelID) {
self.scheduleLoad(onSuccess: onSuccess)
}
}
func isSubscribing(_ channelID: String) -> Bool {
channels.contains { $0.id == channelID }
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
guard accounts.app.supportsSubscriptions else {
return
}
let request = force ? resource?.load() : resource?.loadIfNeeded()
request?
.onSuccess { resource in
if let channels: [Channel] = resource.typedContent() {
self.channels = channels
onSuccess()
}
}
.onFailure { _ in
self.channels = []
}
}
private func scheduleLoad(onSuccess: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.load(force: true, onSuccess: onSuccess)
}
}
}

39
Model/Thumbnail.swift Normal file
View File

@@ -0,0 +1,39 @@
import Foundation
import SwiftyJSON
struct Thumbnail {
enum Quality: String, CaseIterable {
case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end
var filename: String {
switch self {
case .maxres:
return "maxres"
case .maxresdefault:
return "maxresdefault"
case .sddefault:
return "sddefault"
case .high:
return "hqdefault"
case .medium:
return "mqdefault"
case .default:
return "default"
case .start:
return "1"
case .middle:
return "2"
case .end:
return "3"
}
}
}
var url: URL
var quality: Quality
init(url: URL, quality: Quality) {
self.url = url
self.quality = quality
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
final class ThumbnailsModel: ObservableObject {
@Published var unloadable = Set<URL>()
func insertUnloadable(_ url: URL) {
unloadable.insert(url)
}
func isUnloadable(_ url: URL!) -> Bool {
guard !url.isNil else {
return true
}
return unloadable.contains(url)
}
func best(_ video: Video) -> URL? {
let qualities = [Thumbnail.Quality.maxresdefault, .medium, .default]
for quality in qualities {
let url = video.thumbnailURL(quality: quality)
if !isUnloadable(url) {
return url
}
}
return nil
}
}

View File

@@ -0,0 +1,21 @@
import Defaults
enum TrendingCategory: String, CaseIterable, Identifiable, Defaults.Serializable {
case `default`, music, gaming, movies
var id: RawValue {
rawValue
}
var title: RawValue {
rawValue.capitalized
}
var name: String {
id == "default" ? "Trending" : title
}
var controlLabel: String {
id == "default" ? "All" : title
}
}

114
Model/Video.swift Normal file
View File

@@ -0,0 +1,114 @@
import Alamofire
import AVKit
import Foundation
import SwiftyJSON
struct Video: Identifiable, Equatable, Hashable {
let id: String
let videoID: String
var title: String
var thumbnails: [Thumbnail]
var author: String
var length: TimeInterval
var published: String
var views: Int
var description: String?
var genre: String?
// index used when in the Playlist
let indexID: String?
var live: Bool
var upcoming: Bool
var streams = [Stream]()
var publishedAt: Date?
var likes: Int?
var dislikes: Int?
var keywords = [String]()
var channel: Channel
var related = [Video]()
init(
id: String? = nil,
videoID: String,
title: String,
author: String,
length: TimeInterval,
published: String,
views: Int,
description: String? = nil,
genre: String? = nil,
channel: Channel,
thumbnails: [Thumbnail] = [],
indexID: String? = nil,
live: Bool = false,
upcoming: Bool = false,
publishedAt: Date? = nil,
likes: Int? = nil,
dislikes: Int? = nil,
keywords: [String] = [],
streams: [Stream] = [],
related: [Video] = []
) {
self.id = id ?? UUID().uuidString
self.videoID = videoID
self.title = title
self.author = author
self.length = length
self.published = published
self.views = views
self.description = description
self.genre = genre
self.channel = channel
self.thumbnails = thumbnails
self.indexID = indexID
self.live = live
self.upcoming = upcoming
self.publishedAt = publishedAt
self.likes = likes
self.dislikes = dislikes
self.keywords = keywords
self.streams = streams
self.related = related
}
var publishedDate: String? {
(published.isEmpty || published == "0 seconds ago") ? nil : published
}
var viewsCount: String? {
views != 0 ? views.formattedAsAbbreviation() : nil
}
var likesCount: String? {
guard likes != -1 else {
return nil
}
return likes?.formattedAsAbbreviation()
}
var dislikesCount: String? {
guard dislikes != -1 else {
return nil
}
return dislikes?.formattedAsAbbreviation()
}
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {
thumbnails.first { $0.quality == quality }?.url
}
static func == (lhs: Video, rhs: Video) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

13
Open in Yattee/Info.plist Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.Safari.web-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,31 @@
{
"manifest_version": 2,
"default_locale": "en",
"name": "Open in Yattee",
"description": "Open YouTube videos in Yattee app",
"version": "1.0",
"icons": {
"48": "images/icon-48.png",
"96": "images/icon-96.png",
"128": "images/icon-128.png",
"256": "images/icon-256.png",
"512": "images/icon-512.png",
"16": "images/toolbar-icon-16.png",
"19": "images/toolbar-icon-19.png",
"32": "images/toolbar-icon-32.png",
"38": "images/toolbar-icon-38.png"
},
"content_scripts": [{
"js": [ "content.js" ],
"matches": [
"*://*.youtube-nocookie.com/*",
"*://*.youtube.com/*",
"*://*.youtu.be/*"
]
}],
"permissions": [ ]
}

View File

@@ -0,0 +1,15 @@
import os.log
import SafariServices
final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
func beginRequest(with context: NSExtensionContext) {
let item = context.inputItems[0] as! NSExtensionItem
let message = item.userInfo?[SFExtensionMessageKey]
os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg)
let response = NSExtensionItem()
response.userInfo = [SFExtensionMessageKey: ["Response to": message]]
context.completeRequest(returningItems: [response], completionHandler: nil)
}
}

26
Open in Yattee/content.js Normal file
View File

@@ -0,0 +1,26 @@
if (document.readyState !== 'complete') {
window.addEventListener('load', redirectAndReplaceContentWithLink);
} else {
redirectAndReplaceContentWithLink();
}
function yatteeUrl() {
return window.location.href.replace(/^https?:\/\//, 'yattee://');
}
function yatteeLink() {
return '<a href="'+ yatteeUrl() +'" onclick=\'window.location.href="'+ yatteeUrl() +'"\'>Open in Yattee</a>';
}
function redirect() {
window.location.href = yatteeUrl()
}
function replaceContentWithLink() {
document.querySelector('body').innerHTML = '<h1>' + yatteeLink() + '</h1>';
}
function redirectAndReplaceContentWithLink(){
redirect()
replaceContentWithLink()
}

187
README.md
View File

@@ -1,100 +1,143 @@
<div align="center">
<!-- TODO: new logo asset -->
<img src="https://r.yattee.stream/icons/yattee-150.png" width="150" height="150" alt="Yattee logo">
<h1>Yattee</h1>
<p>Privacy-focused video player for iOS, macOS, and tvOS</p>
![Yattee Banner](https://r.yattee.stream/icons/yattee-banner.png)
Video player for [Invidious](https://github.com/iv-org/invidious) and [Piped](https://github.com/TeamPiped/Piped) instances built for iOS, tvOS and macOS.
[![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/yattee)](https://github.com/yattee/yattee/issues)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/yattee/yattee)](https://github.com/yattee/yattee/pulls)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#Yattee:matrix.org)
[![Matrix](https://img.shields.io/matrix/yattee:matrix.org)](https://matrix.to/#/#yattee:matrix.org)
[![Discord](https://invidget.switchblade.xyz/pSnNKhZHEG)](https://yattee.stream/discord)
<!-- TODO: new screenshot assets -->
![Screenshot](https://r.yattee.stream/screenshots/all-platforms.png)
</div>
## Features
* Native user interface built with [SwiftUI](https://developer.apple.com/xcode/swiftui/)
* Multiple instances and accounts, fast switching
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
* Player queue and history
* Fullscreen playback, Picture in Picture and AirPlay support
* Stream quality selection
* Favorites: customizable section of channels, playlists, trending, searches and other views
* `yattee://` URL Scheme for integrations
**Playback**
- 4K video with custom MPV-based player
- Picture in Picture, background audio, fullscreen
- Playback queue, history, resume from last position
- Chapter navigation, playback speed, subtitles and captions
- Gesture controls (seek, volume, brightness)
### Availability
| Feature | Invidious | Piped |
| - | - | - |
| User Accounts | ✅ | ✅ |
| Subscriptions | ✅ | ✅ |
| Popular | ✅ | 🔴 |
| User Playlists | ✅ | 🔴 |
| Trending | ✅ | ✅ |
| Channels | ✅ | ✅ |
| Channel Playlists | ✅ | ✅ |
| Search | ✅ | ✅ |
| Search Suggestions | ✅ | ✅ |
| Search Filters | ✅ | 🔴 |
| Subtitles | 🔴 | ✅ |
**Content Sources**
- YouTube via Invidious, Piped, or self-hosted Yattee Server
- PeerTube instances (federated video)
- Local files, SMB network shares, WebDAV servers
## Installation
### Requirements
System requirements:
* iOS 14 (or newer)
* tvOS 15 (or newer)
* macOS Big Sur (or newer)
**Integrations**
- [SponsorBlock](https://sponsor.ajay.app/) (configurable skip categories)
- [DeArrow](https://dearrow.ajay.app/) (crowdsourced titles and thumbnails)
- [Return YouTube Dislike](https://returnyoutubedislike.com/)
### How to install?
#### [AltStore](https://altstore.io/) (free)
You can sideload IPA files downloaded from the [Releases](https://github.com/yattee/yattee/releases) page to your iOS or tvOS device - check [AltStore FAQ](https://altstore.io/faq/) for more information.
**Privacy**
- No tracking, no ads, no account required
- All traffic goes through your chosen instances
If you have to access to the beta AltStore version (v1.5, for Patreons only), you can add the following repository in `Browse > Sources` screen:
**Library**
- Subscriptions with per-channel notifications
- Bookmarks, playlists, watch history
- Search across all configured sources
- Import/export subscriptions (JSON, CSV, OPML)
`https://alt.yattee.stream`
**Downloads & Sync**
- Offline video and audio downloads
- iCloud sync for bookmarks, subscriptions, and history across devices
- Handoff continuity between iPhone, iPad, Mac, and Apple TV
#### Signing IPA files online (paid)
[UDID Registrations](https://www.udidregistrations.com/) provides services to sign IPA files for your devices. Refer to: ***Break free from the App Store*** section of the website for more information.
**Platforms**
- iOS 18+ / macOS 15+ / tvOS 18+
- Native SwiftUI on every platform
- Customizable home layout, accent colors, and player controls
#### 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 you will need to reinstall every 7 days.
## Yattee Server
## Integrations
### macOS
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: [
finicky.matchDomains(/(.*\.)?youtube.com/),
finicky.matchDomains(/(.*\.)?youtu.be/)
],
browser: "/Applications/Yattee.app"
}
```
A self-hosted backend powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp) that gives Yattee superpowers.
## Screenshots
### iOS
| Player | Search | Playlists |
| - | - | - |
| [![Yattee Player iOS](https://r.yattee.stream/screenshots/iOS/player-thumb.png)](https://r.yattee.stream/screenshots/iOS/player.png) | [![Yattee Search iOS](https://r.yattee.stream/screenshots/iOS/search-suggestions-thumb.png)](https://r.yattee.stream/screenshots/iOS/search-suggestions.png) | [![Yattee Subscriptions iOS](https://r.yattee.stream/screenshots/iOS/playlists-thumb.png)](https://r.yattee.stream/screenshots/iOS/playlists.png) |
### iPadOS
| Settings | Player | Subscriptions |
| - | - | - |
| [![Yattee Player iPadOS](https://r.yattee.stream/screenshots/iPadOS/settings-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/settings.png) | [![Yattee Player iPadOS](https://r.yattee.stream/screenshots/iPadOS/player-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/player.png) | [![Yattee Subscriptions iPad S](https://r.yattee.stream/screenshots/iPadOS/subscriptions-thumb.png)](https://r.yattee.stream/screenshots/iPadOS/subscriptions.png) |
### tvOS
| Player | Popular | Search | Now Playing | Settings |
| - | - | - | - | - |
| [![Yattee Player tvOS](https://r.yattee.stream/screenshots/tvOS/player-thumb.png)](https://r.yattee.stream/screenshots/tvOS/player.png) | [![Yattee Popular tvOS](https://r.yattee.stream/screenshots/tvOS/popular-thumb.png)](https://r.yattee.stream/screenshots/tvOS/popular.png) | [![Yattee Search tvOS](https://r.yattee.stream/screenshots/tvOS/search-thumb.png)](https://r.yattee.stream/screenshots/tvOS/search.png) | [![Yattee Now Playing tvOS](https://r.yattee.stream/screenshots/tvOS/now-playing-thumb.png)](https://r.yattee.stream/screenshots/tvOS/now-playing.png) | [![Yattee Settings tvOS](https://r.yattee.stream/screenshots/tvOS/settings-thumb.png)](https://r.yattee.stream/screenshots/tvOS/settings.png) |
### macOS
| Player | Channel | Search | Settings |
| - | - | - | - |
| [![Yattee Player macOS](https://r.yattee.stream/screenshots/macOS/player-thumb.png)](https://r.yattee.stream/screenshots/macOS/player.png) | [![Yattee Channel macOS](https://r.yattee.stream/screenshots/macOS/channel-thumb.png)](https://r.yattee.stream/screenshots/macOS/channel.png) | [![Yattee Search macOS](https://r.yattee.stream/screenshots/macOS/search-thumb.png)](https://r.yattee.stream/screenshots/macOS/search.png) | [![Yattee Settings macOS](https://r.yattee.stream/screenshots/macOS/settings-thumb.png)](https://r.yattee.stream/screenshots/macOS/settings.png) |
- **Direct stream URLs** — gets fresh YouTube CDN URLs, bypassing Invidious/Piped blocks and rate limits
- **Play from 1000+ sites** — Vimeo, TikTok, Twitch, Dailymotion, Twitter/X, and anything else yt-dlp supports
- **Invidious-compatible API** — drop-in replacement, works alongside existing Invidious/Piped instances
- **Self-hosted & private** — run on your own hardware, no data leaves your network
- **Fast parallel streaming** — yt-dlp parallel downloading streams video while it downloads
- **Admin panel** — web UI for settings, credentials, and monitoring
- **Docker ready** — single container deployment
## Tips
### Settings
* [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.
* [iOS] Swipe the player/title bar: up to open fullscreen details view, bottom to close fullscreen details or hide player
### Favorites
* Add more sections using ❤️ button in views channels, playlists, searches, subscriptions and popular
* [iOS/macOS] Reorganize with dragging and dropping
* [iOS/macOS] Remove section with right click/press and hold on section name
* [tvOS] Reorganize and remove from `Settings > Edit Favorites...`
### Keyboard shortcuts
* `Command+1` - Favorites
* `Command+2` - Subscriptions
* `Command+3` - Popular
* `Command+4` - Trending
* `Command+F` - Search
* `Command+P` - Play/Pause
* `Command+S` - Play Next
* `Command+O` - Toggle Player
Check out the [yattee-server](https://github.com/yattee/yattee-server) repository to get started.
## Documentation
## Donations
- [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
- [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
- [Features](https://github.com/yattee/yattee/wiki/Features)
- [FAQ](https://github.com/yattee/yattee/wiki/FAQ)
- [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
- [Tips](https://github.com/yattee/yattee/wiki/Tips)
- [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
- [Donations](https://github.com/yattee/yattee/wiki/Donations)
You can support development of this app with
[Patreon](https://www.patreon.com/arekf) or cryptocurrencies:
**Monero (XMR)**
```
48zfKjLmnXs21PinU2ucMiUPwhiKt5d7WJKiy3ACVS28BKqSn52c1TX8L337oESHJ5TZCyGkozjfWZG11h6C46mN9n4NPrD
```
**Bitcoin (BTC)**
```
bc1qe24zz5a5hm0trc7glwckz93py274eycxzju3mv
```
**Ethereum (ETH)**
```
0xa2f81A58Ec5E550132F03615c8d91954A4E37423
```
Donations will be used to cover development program access and domain renewal costs.
## Contributing
If you're interestred in contributing, you can browse the [issues](https://github.com/yattee/yattee/issues) list or create a new one to discuss your feature idea. Every contribution is very welcome.
Browse the [issues](https://github.com/yattee/yattee/issues) list or open a new one to discuss your idea. Every contribution is welcome.
## License and Liability
Join [Discord](https://yattee.stream/discord) or the [Matrix channel](https://matrix.to/#/#yattee:matrix.org) if you need advice or want to discuss the project.
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
## Translations
Contributors take no responsibility for the use of the tool (Point 16. of the license). We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of the tool, such as downloading materials without proper consent.
Help make Yattee accessible to everyone by contributing translations.
<a href="https://hosted.weblate.org/engage/yattee/">
<img src="https://hosted.weblate.org/widgets/yattee/-/localizable-strings/multi-auto.svg" alt="Translation status" />
</a>
Localization hosting provided by [Weblate](https://weblate.org/en/).
## License
Yattee is shared under the [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
This tool is an open source software built for learning and research purposes.

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.361",
"green" : "0.200",
"red" : "0.129"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.263",
"green" : "0.290",
"red" : "0.859"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Some files were not shown because too many files have changed in this diff Show More