mirror of
https://github.com/yattee/yattee.git
synced 2026-08-08 16:21:28 +00:00
Compare commits
35 Commits
2.0.0-beta
...
v1.4-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a283ff1d | ||
|
|
13688adb6e | ||
|
|
f5b6c97003 | ||
|
|
915cae2f5a | ||
|
|
586fb2cee1 | ||
|
|
da0b25b14d | ||
|
|
d31aeea52a | ||
|
|
812e2aef87 | ||
|
|
382cb30f2a | ||
|
|
91bc909b37 | ||
|
|
725dadf7c5 | ||
|
|
4d72844975 | ||
|
|
dc18e189aa | ||
|
|
02349723a1 | ||
|
|
4094abb8ba | ||
|
|
0b213e54ad | ||
|
|
622dd5ba8a | ||
|
|
a77d72e2b6 | ||
|
|
755497eda5 | ||
|
|
48487f9707 | ||
|
|
5ef47fc2a9 | ||
|
|
a26d02271d | ||
|
|
10e5975b37 | ||
|
|
4178275e66 | ||
|
|
207ba49977 | ||
|
|
4193972669 | ||
|
|
2f395f19ad | ||
|
|
fc3ab0d8ae | ||
|
|
931107b8d5 | ||
|
|
a2d84a905c | ||
|
|
3327ee3fbd | ||
|
|
4227bb304e | ||
|
|
8d3e5b2a2d | ||
|
|
ac1218a612 | ||
|
|
7a6820dde2 |
15
.env.example
15
.env.example
@@ -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
5
.github/FUNDING.yml
vendored
@@ -1,5 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: yattee
|
||||
patreon: arekf
|
||||
custom: https://github.com/yattee/yattee/wiki/Donations
|
||||
74
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
74
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -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
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -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
|
||||
356
.github/workflows/release.yml
vendored
356
.github/workflows/release.yml
vendored
@@ -1,356 +0,0 @@
|
||||
name: Build and release to TestFlight and GitHub
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_ios:
|
||||
description: 'Build iOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
build_tvos:
|
||||
description: 'Build tvOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
build_mac_beta:
|
||||
description: 'Build macOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
build_mac_notarized:
|
||||
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
|
||||
type: boolean
|
||||
default: true
|
||||
release_channel:
|
||||
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
|
||||
type: choice
|
||||
options:
|
||||
- beta
|
||||
- stable
|
||||
default: beta
|
||||
create_release:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
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:
|
||||
determine_build_number:
|
||||
name: Determine build number
|
||||
runs-on: macos-26
|
||||
outputs:
|
||||
build_number: ${{ steps.calc.outputs.build_number }}
|
||||
version_number: ${{ steps.version.outputs.version_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: latest_build_number
|
||||
- name: Calculate next build number
|
||||
id: calc
|
||||
run: |
|
||||
LATEST=$(cat latest_build_number.txt)
|
||||
NEXT=$((LATEST + 1))
|
||||
echo "build_number=$NEXT" >> $GITHUB_OUTPUT
|
||||
- name: Read version number
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -m 1 MARKETING_VERSION Yattee.xcodeproj/project.pbxproj | cut -d' ' -f3 | sed 's/;//g')
|
||||
echo "version_number=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
ios_beta:
|
||||
if: ${{ inputs.build_ios }}
|
||||
needs: [determine_build_number]
|
||||
name: Release iOS to TestFlight
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ios beta
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-beta-build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
if-no-files-found: ignore
|
||||
|
||||
tvos_beta:
|
||||
if: ${{ inputs.build_tvos }}
|
||||
needs: [determine_build_number]
|
||||
name: Release tvOS to TestFlight
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: tvos beta
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tvos-beta-build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
if-no-files-found: ignore
|
||||
|
||||
mac_beta:
|
||||
if: ${{ inputs.build_mac_beta }}
|
||||
needs: [determine_build_number]
|
||||
name: Release macOS to TestFlight
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac beta
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-beta-build
|
||||
path: fastlane/builds/**/*.pkg
|
||||
if-no-files-found: ignore
|
||||
|
||||
mac_notarized:
|
||||
if: ${{ inputs.build_mac_notarized }}
|
||||
needs: [determine_build_number]
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-26
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
|
||||
- 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
|
||||
- name: Resolve artifact paths
|
||||
run: |
|
||||
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
|
||||
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $GITHUB_ENV
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: |
|
||||
${{ env.ZIP_PATH }}
|
||||
${{ env.DMG_PATH }}
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
if: ${{ inputs.create_release && !cancelled() && !failure() }}
|
||||
needs: [determine_build_number, ios_beta, tvos_beta, mac_beta, mac_notarized]
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Commit build number
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
sed -i 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
|
||||
git add Yattee.xcodeproj/project.pbxproj
|
||||
git diff --cached --quiet && echo "Build number already up to date" || {
|
||||
git commit -m "Bump build number to ${{ env.BUILD_NUMBER }}"
|
||||
git push origin ${{ github.ref_name }}
|
||||
}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- name: Compute release tag
|
||||
id: compute_tag
|
||||
run: |
|
||||
if [ "$RELEASE_CHANNEL" = "beta" ]; then
|
||||
echo "tag=${VERSION_NUMBER}-beta.${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${VERSION_NUMBER}-${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: ncipollo/release-action@v1
|
||||
with:
|
||||
# No .pkg here on purpose: the mac TestFlight pkg is App Store-signed
|
||||
# (no Sparkle, not notarized) and would not run if installed directly.
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.dmg
|
||||
commit: ${{ github.ref_name }}
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
|
||||
bodyFile: CHANGELOG.md
|
||||
|
||||
publish_appcast:
|
||||
if: ${{ inputs.build_mac_notarized && inputs.create_release && !cancelled() && !failure() }}
|
||||
needs: [determine_build_number, mac_notarized, release]
|
||||
name: Publish Sparkle appcast
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
RELEASE_CHANNEL: ${{ inputs.release_channel }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.tag }}
|
||||
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Guard — secret configured
|
||||
run: |
|
||||
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
|
||||
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Download notarized mac artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: mac-artifacts
|
||||
- name: Locate sign_update binary
|
||||
id: find_sign_update
|
||||
run: |
|
||||
# Sparkle's `sign_update` ships as a package artifact. We need SPM to
|
||||
# resolve the Sparkle package so the binary is present on disk.
|
||||
xcodebuild -resolvePackageDependencies -project Yattee.xcodeproj -scheme Yattee >/dev/null
|
||||
SIGN=$(find "$HOME/Library/Developer/Xcode/DerivedData" -name sign_update -type f 2>/dev/null | head -1)
|
||||
if [ -z "$SIGN" ]; then
|
||||
SIGN=$(find ~ -name sign_update -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$SIGN" ]; then
|
||||
echo "::error::Could not locate sign_update binary"
|
||||
exit 1
|
||||
fi
|
||||
echo "sign_update=$SIGN" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout gh-pages (create if missing)
|
||||
run: |
|
||||
# Fetch into a local branch: actions/checkout configures a narrow
|
||||
# refspec, so a plain `git fetch origin gh-pages` never creates
|
||||
# origin/gh-pages, and a detached worktree has no local branch for
|
||||
# `git push origin gh-pages` to resolve.
|
||||
if git fetch origin +refs/heads/gh-pages:refs/heads/gh-pages; then
|
||||
git worktree add gh-pages gh-pages
|
||||
else
|
||||
# First run — create orphan gh-pages with only appcast scaffolding.
|
||||
git worktree add --detach gh-pages HEAD
|
||||
cd gh-pages
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf . >/dev/null 2>&1 || true
|
||||
cp ../scripts/sparkle/appcast_template.xml appcast.xml
|
||||
cd ..
|
||||
fi
|
||||
- name: Write private key to a temp file
|
||||
id: ed_key
|
||||
run: |
|
||||
KEY_FILE=$(mktemp)
|
||||
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
|
||||
echo "path=$KEY_FILE" >> "$GITHUB_OUTPUT"
|
||||
- name: Sign update and update appcast.xml
|
||||
run: |
|
||||
ZIP=$(find mac-artifacts -name '*.zip' | head -1)
|
||||
if [ -z "$ZIP" ]; then
|
||||
echo "::error::No .zip found in mac-artifacts"
|
||||
exit 1
|
||||
fi
|
||||
./scripts/sparkle/update_appcast.rb \
|
||||
--zip "$ZIP" \
|
||||
--version "$VERSION_NUMBER" \
|
||||
--build "$BUILD_NUMBER" \
|
||||
--channel "$RELEASE_CHANNEL" \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--sign-update-bin "${{ steps.find_sign_update.outputs.sign_update }}" \
|
||||
--ed-key-file "${{ steps.ed_key.outputs.path }}" \
|
||||
--appcast gh-pages/appcast.xml \
|
||||
--repo "$REPO"
|
||||
- name: Scrub private key
|
||||
if: always()
|
||||
run: rm -f "${{ steps.ed_key.outputs.path }}"
|
||||
- name: Commit & push appcast.xml
|
||||
run: |
|
||||
cd gh-pages
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add appcast.xml
|
||||
if git diff --cached --quiet; then
|
||||
echo "No appcast changes to publish"
|
||||
else
|
||||
git commit -m "Publish Sparkle appcast: ${VERSION_NUMBER} (${BUILD_NUMBER}) [${RELEASE_CHANNEL}]"
|
||||
git push origin gh-pages
|
||||
fi
|
||||
|
||||
update_altstore:
|
||||
# Only when an iOS IPA was actually built and released — a mac/tvOS-only
|
||||
# release has no IPA and would corrupt the AltStore source.
|
||||
if: ${{ inputs.build_ios && success() }}
|
||||
needs: [release]
|
||||
uses: ./.github/workflows/update-altstore.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
tag: ${{ needs.release.outputs.tag }}
|
||||
78
.github/workflows/update-altstore.yml
vendored
78
.github/workflows/update-altstore.yml
vendored
@@ -1,78 +0,0 @@
|
||||
name: Update AltStore source
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish (defaults to the latest release)'
|
||||
type: string
|
||||
required: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
type: string
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
update_altstore:
|
||||
name: Update AltStore source
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
# Default GITHUB_TOKEN cannot bypass the "changes through PR only"
|
||||
# branch ruleset; REPO_TOKEN can (same as the release workflow).
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Get version info from release
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=$(gh release view --json tagName --jq '.tagName')
|
||||
fi
|
||||
# Tags are <version>-<build> (stable) or <version>-beta.<build> (beta),
|
||||
# e.g. 2.0.0-263 or 2.0.0-beta.263.
|
||||
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||
echo "VERSION_NUMBER=${TAG%%-*}" >> $GITHUB_ENV
|
||||
echo "BUILD_NUMBER=${TAG##*[-.]}" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Get IPA size from release
|
||||
run: |
|
||||
IPA_NAME="Yattee-${{ env.VERSION_NUMBER }}-iOS.ipa"
|
||||
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq ".assets[] | select(.name == \"$IPA_NAME\") | .size")
|
||||
if [ -z "$SIZE" ]; then
|
||||
echo "::error::Release ${{ env.TAG }} has no asset named $IPA_NAME — refusing to publish a broken AltStore entry"
|
||||
exit 1
|
||||
fi
|
||||
echo "IPA_NAME=${IPA_NAME}" >> $GITHUB_ENV
|
||||
echo "IPA_SIZE=${SIZE}" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Update altstore-source.json
|
||||
run: |
|
||||
DATE=$(date -u +"%Y-%m-%dT%H:%M:%S+00:00")
|
||||
jq --arg version "${{ env.VERSION_NUMBER }}" \
|
||||
--arg build "${{ env.BUILD_NUMBER }}" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/${{ env.IPA_NAME }}" \
|
||||
--argjson size "${{ env.IPA_SIZE }}" \
|
||||
'.apps[0].versions = [{
|
||||
version: $version,
|
||||
buildVersion: $build,
|
||||
date: $date,
|
||||
localizedDescription: "",
|
||||
downloadURL: $url,
|
||||
size: $size,
|
||||
minOSVersion: "18.0"
|
||||
}] + [.apps[0].versions[] | select(.version != $version or .buildVersion != $build)]' \
|
||||
altstore-source.json > altstore-source.tmp && mv altstore-source.tmp altstore-source.json
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add altstore-source.json
|
||||
git diff --cached --quiet && echo "No changes to commit" && exit 0
|
||||
git commit -m "Update AltStore source for ${{ env.VERSION_NUMBER }} (${{ env.BUILD_NUMBER }})"
|
||||
git push
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -81,9 +81,6 @@ fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/builds
|
||||
fastlane/.env
|
||||
fastlane/*.p8
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
@@ -95,20 +92,5 @@ 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
|
||||
# disable simulator libraries - to be removed when replaced with framework for mpv
|
||||
Vendor/mpv/iOS/lib_sim
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
project: Yattee.xcodeproj
|
||||
schemes:
|
||||
- Yattee
|
||||
120
.rubocop.yml
120
.rubocop.yml
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
3.4.8
|
||||
16
.slather.yml
16
.slather.yml
@@ -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
1
.swift-version
Normal file
@@ -0,0 +1 @@
|
||||
5
|
||||
2
.swiftformat
Normal file
2
.swiftformat
Normal file
@@ -0,0 +1,2 @@
|
||||
--disable trailingCommas
|
||||
--exclude Tests*
|
||||
13
.swiftlint.yml
Normal file
13
.swiftlint.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
|
||||
|
||||
disabled_rules:
|
||||
- identifier_name
|
||||
- opening_brace
|
||||
- number_separator
|
||||
- multiline_arguments
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
71
AGENTS.md
71
AGENTS.md
@@ -1,71 +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`)
|
||||
|
||||
## Build Configurations
|
||||
|
||||
Three configurations exist, mapped to distribution channels:
|
||||
|
||||
| Configuration | Sparkle (`#if SPARKLE`) | Used for |
|
||||
|---|---|---|
|
||||
| `Debug` | off | local development, tests |
|
||||
| `Release` | off | App Store / TestFlight (`fastlane mac beta`) — must stay Sparkle-free, App Review rejects auto-update frameworks |
|
||||
| `Release-DeveloperID` | **on** | Developer ID notarized build (`fastlane mac build_and_notarize`), distributed via GitHub Releases + Homebrew cask, receives Sparkle updates |
|
||||
|
||||
All Sparkle-dependent code must be wrapped in `#if SPARKLE ... #endif` so the `Release` variant links zero Sparkle symbols. When adding new Sparkle features, test both configs build clean on macOS.
|
||||
|
||||
## 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
13
Backports/Backport.swift
Normal 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) }
|
||||
}
|
||||
16
Backports/Badge+Backport.swift
Normal file
16
Backports/Badge+Backport.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
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 {
|
||||
HStack {
|
||||
content
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Backports/Tint+Backport.swift
Normal file
11
Backports/Tint+Backport.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
Backports/VisualEffectBlur-iOS.swift
Normal file
97
Backports/VisualEffectBlur-iOS.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
public struct VisualEffectBlur<Content: View>: View {
|
||||
/// Defaults to .systemMaterial
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
|
||||
/// Defaults to nil
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
|
||||
var content: Content
|
||||
|
||||
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.blurStyle = blurStyle
|
||||
self.vibrancyStyle = vibrancyStyle
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: ZStack { content })
|
||||
.accessibility(hidden: Content.self == EmptyView.self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable<Content: View>: UIViewRepresentable {
|
||||
var blurStyle: UIBlurEffect.Style
|
||||
var vibrancyStyle: UIVibrancyEffectStyle?
|
||||
var content: Content
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
context.coordinator.blurView
|
||||
}
|
||||
|
||||
func updateUIView(_: UIVisualEffectView, context: Context) {
|
||||
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
extension VisualEffectBlur.Representable {
|
||||
class Coordinator {
|
||||
let blurView = UIVisualEffectView()
|
||||
let vibrancyView = UIVisualEffectView()
|
||||
let hostingController: UIHostingController<Content>
|
||||
|
||||
init(content: Content) {
|
||||
hostingController = UIHostingController(rootView: content)
|
||||
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
hostingController.view.backgroundColor = nil
|
||||
blurView.contentView.addSubview(vibrancyView)
|
||||
|
||||
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
vibrancyView.contentView.addSubview(hostingController.view)
|
||||
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
|
||||
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
|
||||
hostingController.rootView = content
|
||||
|
||||
let blurEffect = UIBlurEffect(style: blurStyle)
|
||||
blurView.effect = blurEffect
|
||||
|
||||
if let vibrancyStyle = vibrancyStyle {
|
||||
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
|
||||
} else {
|
||||
vibrancyView.effect = nil
|
||||
}
|
||||
|
||||
hostingController.view.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VisualEffectBlur where Content == EmptyView {
|
||||
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
|
||||
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
83
Backports/VisualEffectBlur-macOS.swift
Normal file
83
Backports/VisualEffectBlur-macOS.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright © 2020 Apple Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
public struct VisualEffectBlur: View {
|
||||
private var material: NSVisualEffectView.Material
|
||||
private var blendingMode: NSVisualEffectView.BlendingMode
|
||||
private var state: NSVisualEffectView.State
|
||||
|
||||
public init(
|
||||
material: NSVisualEffectView.Material = .headerView,
|
||||
blendingMode: NSVisualEffectView.BlendingMode = .withinWindow,
|
||||
state: NSVisualEffectView.State = .followsWindowActiveState
|
||||
) {
|
||||
self.material = material
|
||||
self.blendingMode = blendingMode
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Representable(
|
||||
material: material,
|
||||
blendingMode: blendingMode,
|
||||
state: state
|
||||
).accessibility(hidden: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Representable
|
||||
|
||||
extension VisualEffectBlur {
|
||||
struct Representable: NSViewRepresentable {
|
||||
var material: NSVisualEffectView.Material
|
||||
var blendingMode: NSVisualEffectView.BlendingMode
|
||||
var state: NSVisualEffectView.State
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
context.coordinator.visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_: NSVisualEffectView, context: Context) {
|
||||
context.coordinator.update(material: material)
|
||||
context.coordinator.update(blendingMode: blendingMode)
|
||||
context.coordinator.update(state: state)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
|
||||
init() {
|
||||
visualEffectView.blendingMode = .withinWindow
|
||||
}
|
||||
|
||||
func update(material: NSVisualEffectView.Material) {
|
||||
visualEffectView.material = material
|
||||
}
|
||||
|
||||
func update(blendingMode: NSVisualEffectView.BlendingMode) {
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
|
||||
func update(state: NSVisualEffectView.State) {
|
||||
visualEffectView.state = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,13 +0,0 @@
|
||||
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix macOS legacy import sheet missing grouped form style
|
||||
* Fix tvOS legacy import rows acting as a single Remove button
|
||||
* Fix periodic tvOS playback stutter from render stalls on mpvQueue
|
||||
|
||||
### Other
|
||||
|
||||
* Make Enable Logging the master switch over all verbose logging
|
||||
17
Extensions/Array+Next.swift
Normal file
17
Extensions/Array+Next.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
10
Extensions/CMTime+DefaultTimescale.swift
Normal file
10
Extensions/CMTime+DefaultTimescale.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
15
Extensions/CaseIterable+Next.swift
Normal file
15
Extensions/CaseIterable+Next.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
15
Extensions/Color+Background.swift
Normal file
15
Extensions/Color+Background.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
#if os(macOS)
|
||||
static let background = Color(NSColor.windowBackgroundColor)
|
||||
static let secondaryBackground = Color(NSColor.controlBackgroundColor)
|
||||
#elseif os(iOS)
|
||||
static let background = Color(UIColor.systemBackground)
|
||||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||||
#else
|
||||
static func background(scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? .black : .init(white: 0.8)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
7
Extensions/Comparable+Clamped.swift
Normal file
7
Extensions/Comparable+Clamped.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
||||
26
Extensions/Double+Format.swift
Normal file
26
Extensions/Double+Format.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime() -> String? {
|
||||
guard !isZero, isFinite 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())
|
||||
}
|
||||
}
|
||||
27
Extensions/Int+Format.swift
Normal file
27
Extensions/Int+Format.swift
Normal 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))!
|
||||
}
|
||||
}
|
||||
8
Extensions/NSTextField+FocusRingType.swift
Normal file
8
Extensions/NSTextField+FocusRingType.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import AppKit
|
||||
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {}
|
||||
}
|
||||
}
|
||||
10
Extensions/String+Format.swift
Normal file
10
Extensions/String+Format.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
6
Extensions/TypedContentAccessors.swift
Normal file
6
Extensions/TypedContentAccessors.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
extension TypedContentAccessors {
|
||||
var json: JSON { typedContent(ifNone: JSON.null) }
|
||||
}
|
||||
30
Extensions/View+Borders.swift
Normal file
30
Extensions/View+Borders.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
12
Fixtures/ChannelPlaylist+Fixtures.swift
Normal file
12
Fixtures/ChannelPlaylist+Fixtures.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
18
Fixtures/Comment+Fixtures.swift
Normal file
18
Fixtures/Comment+Fixtures.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
extension Comment {
|
||||
static var fixture: Comment {
|
||||
Comment(
|
||||
id: UUID().uuidString,
|
||||
author: "The Author",
|
||||
authorAvatarURL: "https://pipedproxy-ams-2.kavin.rocks/Si7ZhtmpX84wj6MoJYLs8kwALw2Hm53wzbrPamoU-z3qvCKs2X3zPNYKMSJEvPDLUHzbvTfLcg=s176-c-k-c0x00ffffff-no-rw?host=yt3.ggpht.com",
|
||||
time: "2 months ago",
|
||||
pinned: true,
|
||||
hearted: true,
|
||||
likeCount: 30032,
|
||||
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
|
||||
repliesPage: "some url",
|
||||
channel: .init(id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
7
Fixtures/Instance+Fixtures.swift
Normal file
7
Fixtures/Instance+Fixtures.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Instance {
|
||||
static var fixture: Instance {
|
||||
Instance(app: .invidious, name: "Home", apiURL: "https://invidious.home.net")
|
||||
}
|
||||
}
|
||||
7
Fixtures/Playlist+Fixtures.swift
Normal file
7
Fixtures/Playlist+Fixtures.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension Playlist {
|
||||
static var fixture: Playlist {
|
||||
Playlist(id: UUID().uuidString, title: "Relaxing music", visibility: .public, updated: 1)
|
||||
}
|
||||
}
|
||||
19
Fixtures/Thumbnail+Fixtures.swift
Normal file
19
Fixtures/Thumbnail+Fixtures.swift
Normal 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.snopyta.org"
|
||||
}
|
||||
|
||||
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
|
||||
URL(string: "\(fixturesHost)/vi/\(videoId)/\(quality.filename).jpg")!
|
||||
}
|
||||
}
|
||||
69
Fixtures/Video+Fixtures.swift
Normal file
69
Fixtures/Video+Fixtures.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID {
|
||||
"FIXTURE"
|
||||
}
|
||||
|
||||
static var fixture: Video {
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
|
||||
return Video(
|
||||
videoID: fixtureID,
|
||||
title: "Relaxing Piano Music to feel 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: [],
|
||||
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
|
||||
}
|
||||
}
|
||||
52
Fixtures/View+Fixtures.swift
Normal file
52
Fixtures/View+Fixtures.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environmentObject(AccountsModel())
|
||||
.environmentObject(CommentsModel())
|
||||
.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) }
|
||||
|
||||
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
20
Gemfile
@@ -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
|
||||
288
Gemfile.lock
288
Gemfile.lock
@@ -1,288 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.9)
|
||||
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.1231.0)
|
||||
aws-sdk-core (3.244.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.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.217.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.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)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.0)
|
||||
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.5)
|
||||
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.2.0)
|
||||
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.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.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.1.1)
|
||||
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)
|
||||
ostruct (>= 0.1.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.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.98.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
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.19.3)
|
||||
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.19.1)
|
||||
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)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
plist (3.7.2)
|
||||
prism (1.9.0)
|
||||
public_suffix (7.0.5)
|
||||
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.4.1)
|
||||
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.8)
|
||||
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.7)
|
||||
rubocop (1.86.0)
|
||||
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.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-rspec (3.9.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
|
||||
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
|
||||
0
ISSUE_TEMPLATE.md
Normal file
0
ISSUE_TEMPLATE.md
Normal file
57
Model/Accounts/Account.swift
Normal file
57
Model/Accounts/Account.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
158
Model/Accounts/AccountValidator.swift
Normal file
158
Model/Accounts/AccountValidator.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
39
Model/Accounts/AccountsBridge.swift
Normal file
39
Model/Accounts/AccountsBridge.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
99
Model/Accounts/AccountsModel.swift
Normal file
99
Model/Accounts/AccountsModel.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
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 any: Account? {
|
||||
lastUsed ?? all.randomElement()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Model/Accounts/Instance.swift
Normal file
61
Model/Accounts/Instance.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
37
Model/Accounts/InstancesBridge.swift
Normal file
37
Model/Accounts/InstancesBridge.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
70
Model/Accounts/InstancesModel.swift
Normal file
70
Model/Accounts/InstancesModel.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class InstancesModel: ObservableObject {
|
||||
static var all: [Instance] {
|
||||
Defaults[.instances]
|
||||
}
|
||||
|
||||
static var forPlayer: Instance? {
|
||||
guard let id = Defaults[.playerInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return InstancesModel.find(id)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
439
Model/Applications/InvidiousAPI.swift
Normal file
439
Model/Applications/InvidiousAPI.swift
Normal file
@@ -0,0 +1,439 @@
|
||||
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
|
||||
signedIn = false
|
||||
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
|
||||
if !account.anonymous {
|
||||
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(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
|
||||
let type = json.dictionaryValue["type"]?.stringValue
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
} else if type == "playlist" {
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
} else if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
}
|
||||
|
||||
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(self.extractPlaylist)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||
self.extractPlaylist(from: 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(self.extractVideo)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map(self.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
self.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, page: String?) -> 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)
|
||||
}
|
||||
|
||||
if let page = page {
|
||||
resource = resource.withParam("page", page)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_: Video.ID, page _: String?) -> Resource? { nil }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannel(from json: JSON) -> Channel {
|
||||
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
|
||||
|
||||
// append https protocol to unproxied thumbnail URL if it's missing
|
||||
if thumbnailURL.count > 2,
|
||||
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
|
||||
{
|
||||
thumbnailURL = "https:\(thumbnailURL)"
|
||||
}
|
||||
|
||||
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(extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
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(extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.map { json in
|
||||
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractStreams(from json: JSON) -> [Stream] {
|
||||
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
||||
}
|
||||
|
||||
private 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 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/") }
|
||||
|
||||
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,
|
||||
videoFormat: $0["type"].stringValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
.init(
|
||||
id: content["playlistId"].stringValue,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
460
Model/Applications/PipedAPI.swift
Normal file
460
Model/Applications/PipedAPI.swift
Normal file
@@ -0,0 +1,460 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
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
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
||||
self.extractVideos(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
|
||||
return SearchPage(
|
||||
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
|
||||
nextPage: nextPage,
|
||||
last: nextPage == "null"
|
||||
)
|
||||
}
|
||||
|
||||
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 { self.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map { self.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
|
||||
let nextPage = details["nextpage"]?.stringValue
|
||||
let disabled = details["disabled"]?.boolValue ?? false
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuthorization(_ url: URL) -> Bool {
|
||||
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
guard !account.anonymous else {
|
||||
return
|
||||
}
|
||||
|
||||
account.token = nil
|
||||
|
||||
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, page: String?) -> Resource {
|
||||
let path = page.isNil ? "search" : "nextpage/search"
|
||||
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: path)
|
||||
.withParam("q", query.query)
|
||||
.withParam("filter", "all")
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let account = account else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !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 }
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
|
||||
if page.isNil {
|
||||
return resource
|
||||
}
|
||||
|
||||
return resource.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
private 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 = extractVideo(from: content) {
|
||||
return ContentItem(video: video)
|
||||
}
|
||||
|
||||
case .playlist:
|
||||
if let playlist = extractChannelPlaylist(from: content) {
|
||||
return ContentItem(playlist: playlist)
|
||||
}
|
||||
|
||||
case .channel:
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractContentItems(from content: JSON) -> [ContentItem] {
|
||||
content.arrayValue.compactMap { extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
private 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 = extractVideos(from: relatedStreams)
|
||||
}
|
||||
|
||||
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
|
||||
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
|
||||
|
||||
return Channel(
|
||||
id: id,
|
||||
name: name,
|
||||
thumbnailURL: thumbnailURL,
|
||||
subscriptionsCount: subscriptionsCount,
|
||||
videos: videos
|
||||
)
|
||||
}
|
||||
|
||||
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 = extractVideos(from: relatedStreams)
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id,
|
||||
title: details["name"]!.stringValue,
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
videosCount: details["videos"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
private 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 = buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
if published.isNil {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
|
||||
}
|
||||
|
||||
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
|
||||
|
||||
return Video(
|
||||
videoID: extractID(from: content),
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
related: extractRelated(from: content)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractID(from content: JSON) -> Video.ID {
|
||||
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
||||
}
|
||||
|
||||
private func extractThumbnailURL(from content: JSON) -> URL? {
|
||||
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
||||
}
|
||||
|
||||
private 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 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 func extractVideos(from content: JSON) -> [Video] {
|
||||
content.arrayValue.compactMap(extractVideo(from:))
|
||||
}
|
||||
|
||||
private func extractStreams(from content: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
let audioStreams = content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
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)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.stringValue
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive, videoFormat: videoFormat)
|
||||
)
|
||||
} else {
|
||||
streams.append(
|
||||
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["relatedStreams"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
return Comment(
|
||||
id: details["commentId"]?.stringValue ?? UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.stringValue ?? "",
|
||||
time: details["commentedTime"]?.stringValue ?? "",
|
||||
pinned: details["pinned"]?.boolValue ?? false,
|
||||
hearted: details["hearted"]?.boolValue ?? false,
|
||||
likeCount: details["likeCount"]?.intValue ?? 0,
|
||||
text: details["commentText"]?.stringValue ?? "",
|
||||
repliesPage: details["repliesPage"]?.stringValue,
|
||||
channel: Channel(id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
}
|
||||
91
Model/Applications/VideosAPI.swift
Normal file
91
Model/Applications/VideosAPI.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
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, page: String?) -> 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?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
|
||||
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,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
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))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
49
Model/Applications/VideosApp.swift
Normal file
49
Model/Applications/VideosApp.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
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
|
||||
}
|
||||
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
}
|
||||
46
Model/Channel.swift
Normal file
46
Model/Channel.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
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 detailsLoaded: Bool {
|
||||
!subscriptionsString.isNil
|
||||
}
|
||||
|
||||
var subscriptionsString: String? {
|
||||
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
||||
return subscriptionsCount!.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
return subscriptionsText
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
10
Model/ChannelPlaylist.swift
Normal file
10
Model/ChannelPlaylist.swift
Normal 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?
|
||||
}
|
||||
16
Model/Comment.swift
Normal file
16
Model/Comment.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
struct Comment: Identifiable, Equatable {
|
||||
let id: String
|
||||
let author: String
|
||||
let authorAvatarURL: String
|
||||
let time: String
|
||||
let pinned: Bool
|
||||
let hearted: Bool
|
||||
var likeCount: Int
|
||||
let text: String
|
||||
let repliesPage: String?
|
||||
let channel: Channel
|
||||
|
||||
var hasReplies: Bool {
|
||||
!(repliesPage?.isEmpty ?? true)
|
||||
}
|
||||
}
|
||||
119
Model/CommentsModel.swift
Normal file
119
Model/CommentsModel.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class CommentsModel: ObservableObject {
|
||||
@Published var all = [Comment]()
|
||||
|
||||
@Published var nextPage: String?
|
||||
@Published var firstPage = true
|
||||
|
||||
@Published var loaded = false
|
||||
@Published var disabled = false
|
||||
|
||||
@Published var replies = [Comment]()
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var player: PlayerModel!
|
||||
|
||||
static var instance: Instance? {
|
||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||
}
|
||||
|
||||
var api: VideosAPI? {
|
||||
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
|
||||
}
|
||||
|
||||
static var enabled: Bool {
|
||||
!instance.isNil
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
static var placement: CommentsPlacement {
|
||||
Defaults[.commentsPlacement]
|
||||
}
|
||||
#endif
|
||||
|
||||
var nextPageAvailable: Bool {
|
||||
!(nextPage?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard Self.enabled else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !Self.instance.isNil,
|
||||
!(player?.currentVideo.isNil ?? true)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPageIfNeeded(current comment: Comment) {
|
||||
let thresholdIndex = all.index(all.endIndex, offsetBy: -5)
|
||||
if all.firstIndex(where: { $0 == comment }) == thresholdIndex {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
load(page: nextPage)
|
||||
}
|
||||
|
||||
func loadReplies(page: String) {
|
||||
guard !player.currentVideo.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
if page == repliesPageID {
|
||||
return
|
||||
}
|
||||
|
||||
replies = []
|
||||
repliesPageID = page
|
||||
repliesLoaded = false
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.replies = page.comments
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.repliesLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
all = []
|
||||
disabled = false
|
||||
firstPage = true
|
||||
nextPage = nil
|
||||
loaded = false
|
||||
replies = []
|
||||
repliesLoaded = false
|
||||
}
|
||||
}
|
||||
7
Model/CommentsPage.swift
Normal file
7
Model/CommentsPage.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct CommentsPage {
|
||||
var comments = [Comment]()
|
||||
var nextPage: String?
|
||||
var disabled = false
|
||||
}
|
||||
40
Model/ContentItem.swift
Normal file
40
Model/ContentItem.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct ContentItem: Identifiable {
|
||||
enum ContentType: String {
|
||||
case video, playlist, channel, placeholder
|
||||
|
||||
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.isNil ? .placeholder : .playlist) : .channel) : .video
|
||||
}
|
||||
}
|
||||
279
Model/Country.swift
Normal file
279
Model/Country.swift
Normal 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
53
Model/FavoriteItem.swift
Normal 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
|
||||
}
|
||||
82
Model/FavoritesModel.swift
Normal file
82
Model/FavoritesModel.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
|
||||
@Default(.favorites) var all
|
||||
@Default(.visibleSections) var visibleSections
|
||||
|
||||
var isEnabled: Bool {
|
||||
visibleSections.contains(.favorites)
|
||||
}
|
||||
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
81
Model/HistoryModel.swift
Normal file
81
Model/HistoryModel.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
extension PlayerModel {
|
||||
func historyVideo(_ id: String) -> Video? {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ id: Video.ID) {
|
||||
guard historyVideo(id).isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.video(id).load().onSuccess { [weak self] response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
self?.historyVideos.append(video)
|
||||
}
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false) {
|
||||
guard let id = currentVideo?.videoID,
|
||||
Defaults[.saveHistory]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
|
||||
let watch: Watch!
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
|
||||
|
||||
let results = try? context.fetch(watchFetchRequest)
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
if seconds < 1 {
|
||||
return
|
||||
}
|
||||
watch = Watch(context: context)
|
||||
watch.videoID = id
|
||||
} else {
|
||||
watch = results?.first
|
||||
|
||||
if !Defaults[.resetWatchedStatusOnPlaying], watch.finished {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let seconds = playerItemDuration?.seconds {
|
||||
watch.videoDuration = seconds
|
||||
}
|
||||
|
||||
if finished {
|
||||
watch.stoppedAt = watch.videoDuration
|
||||
} else if seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
|
||||
watch.watchedAt = Date()
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeWatch(_ watch: Watch) {
|
||||
context.delete(watch)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
_ = try? context.execute(deleteRequest)
|
||||
_ = try? context.save()
|
||||
}
|
||||
}
|
||||
18
Model/MenuModel.swift
Normal file
18
Model/MenuModel.swift
Normal 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() })
|
||||
}
|
||||
}
|
||||
177
Model/NavigationModel.swift
Normal file
177
Model/NavigationModel.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
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 stringValue: String {
|
||||
switch self {
|
||||
case .favorites:
|
||||
return "favorites"
|
||||
case .subscriptions:
|
||||
return "subscriptions"
|
||||
case .popular:
|
||||
return "popular"
|
||||
case .trending:
|
||||
return "trending"
|
||||
case .playlists:
|
||||
return "playlists"
|
||||
case let .channel(string):
|
||||
return "channel\(string)"
|
||||
case let .playlist(string):
|
||||
return "playlist\(string)"
|
||||
case .recentlyOpened:
|
||||
return "recentlyOpened"
|
||||
case .search:
|
||||
return "search"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var playlistID: Playlist.ID? {
|
||||
if case let .playlist(id) = self {
|
||||
return id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection!
|
||||
|
||||
@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
|
||||
|
||||
static func openChannel(
|
||||
_ channel: Channel,
|
||||
player: PlayerModel,
|
||||
recents: RecentsModel,
|
||||
navigation: NavigationModel,
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = true
|
||||
) {
|
||||
let recent = RecentItem(from: channel)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
#else
|
||||
player.hide()
|
||||
#endif
|
||||
|
||||
let openRecent = {
|
||||
recents.add(recent)
|
||||
navigation.presentingChannel = true
|
||||
}
|
||||
|
||||
if navigationStyle == .tab {
|
||||
if delay {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
openRecent()
|
||||
}
|
||||
} else {
|
||||
openRecent()
|
||||
}
|
||||
} else if navigationStyle == .sidebar {
|
||||
openRecent()
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
|
||||
static func openChannelPlaylist(
|
||||
_ playlist: ChannelPlaylist,
|
||||
player: PlayerModel,
|
||||
recents: RecentsModel,
|
||||
navigation: NavigationModel,
|
||||
navigationStyle: NavigationStyle,
|
||||
delay: Bool = false
|
||||
) {
|
||||
let recent = RecentItem(from: playlist)
|
||||
#if os(macOS)
|
||||
Windows.main.open()
|
||||
#else
|
||||
player.hide()
|
||||
#endif
|
||||
|
||||
let openRecent = {
|
||||
recents.add(recent)
|
||||
navigation.presentingPlaylist = true
|
||||
}
|
||||
|
||||
if navigationStyle == .tab {
|
||||
if delay {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
openRecent()
|
||||
}
|
||||
} else {
|
||||
openRecent()
|
||||
}
|
||||
} else if navigationStyle == .sidebar {
|
||||
openRecent()
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
navigation.tabSelection = .recentlyOpened(recent.tag)
|
||||
}
|
||||
}
|
||||
|
||||
var tabSelectionBinding: Binding<TabSelection> {
|
||||
Binding<TabSelection>(
|
||||
get: {
|
||||
self.tabSelection ?? .search
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
47
Model/PersistenceController.swift
Normal file
47
Model/PersistenceController.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import CoreData
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "Yattee")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
566
Model/Player/Backends/AVPlayerBackend.swift
Normal file
566
Model/Player/Backends/AVPlayerBackend.swift
Normal file
@@ -0,0 +1,566 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class AVPlayerBackend: PlayerBackend {
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
|
||||
var model: PlayerModel!
|
||||
var controls: PlayerControlsModel!
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
|
||||
var currentTime: CMTime? {
|
||||
avPlayer.currentTime()
|
||||
}
|
||||
|
||||
var loadedVideo: Bool {
|
||||
!avPlayer.currentItem.isNil
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
model.currentItem == nil || model.time == nil || !model.time!.isValid
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
avPlayer.currentItem?.asset.duration
|
||||
}
|
||||
|
||||
private(set) var avPlayer = AVPlayer()
|
||||
|
||||
var controller: AppleAVPlayerViewController?
|
||||
|
||||
private var asset: AVURLAsset?
|
||||
private var composition = AVMutableComposition()
|
||||
private var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
init(model: PlayerModel, controls: PlayerControlsModel?) {
|
||||
self.model = model
|
||||
self.controls = controls
|
||||
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution _: ResolutionSetting) -> Stream? {
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.max { $0.resolution < $1.resolution }
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || (stream.kind == .stream && stream.resolution.height <= 720)
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading _: Bool
|
||||
) {
|
||||
if let url = stream.singleAssetURL {
|
||||
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
model.logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard avPlayer.timeControlStatus != .playing else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard avPlayer.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
|
||||
avPlayer.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler ?? { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
if let currentTime = currentTime {
|
||||
seek(to: currentTime + time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
avPlayer.rate = rate
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
let item = avPlayer.currentItem
|
||||
let time = avPlayer.currentTime()
|
||||
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
|
||||
guard !item.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.seek(to: time)
|
||||
avPlayer.replaceCurrentItem(with: item)
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#else
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
controller?.playerView.player = nil
|
||||
controller?.playerView.player = avPlayer
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func loadSingleAsset(
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.playerError = error
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadComposition(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
loadedCompositionAssets = []
|
||||
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime, model: model)
|
||||
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime, model: model)
|
||||
}
|
||||
|
||||
private func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
stream: Stream,
|
||||
type: AVMediaType,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false,
|
||||
model: PlayerModel
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
model.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
model.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
guard let assetTrack = assetTracks.first else {
|
||||
model.logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
model.logger.critical("\(type.rawValue) LOADED")
|
||||
|
||||
guard model.streamSelection == stream else {
|
||||
model.logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
self.loadedCompositionAssets.append(type)
|
||||
|
||||
if self.loadedCompositionAssets.count == 2 {
|
||||
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
attachMetadata(to: model.playerItem!, video: video, for: stream)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.stream = stream
|
||||
self.video = video
|
||||
self.model.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
self.asset = nil
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
if self.isAutoplaying(self.model.playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start < 3,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.avPlayer.seek(
|
||||
to: segment.endTime,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero
|
||||
) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.model.play()
|
||||
}
|
||||
} else {
|
||||
self.model.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
guard video == self.model.currentVideo else {
|
||||
return
|
||||
}
|
||||
self.avPlayer.replaceCurrentItem(with: self.model.playerItem)
|
||||
self.seekToPreservedTime { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
self.model.preservedTime = nil
|
||||
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
avPlayer.replaceCurrentItem(with: model.playerItem)
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = model.preservedTime else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||
if let asset = asset {
|
||||
return AVPlayerItem(asset: asset)
|
||||
} 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
|
||||
|
||||
observePlayerItemStatus(item)
|
||||
}
|
||||
|
||||
#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
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
avPlayer.currentItem == item
|
||||
}
|
||||
|
||||
private func observePlayerItemStatus(_ item: AVPlayerItem) {
|
||||
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.model.play()
|
||||
}
|
||||
case .failed:
|
||||
self.model.playerError = item.error
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
private func removeItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
model.resetQueue()
|
||||
#if os(tvOS)
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#else
|
||||
model.hide()
|
||||
#endif
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func addFrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(0.5)
|
||||
|
||||
frequentTimeObserver = avPlayer.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.model.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.controls.duration = self.playerItemDuration ?? .zero
|
||||
self.controls.currentTime = self.currentTime ?? .zero
|
||||
|
||||
#if !os(tvOS)
|
||||
self.model.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
if let currentTime = self.currentTime {
|
||||
self.model.handleSegments(at: currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addInfrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(5)
|
||||
|
||||
infrequentTimeObserver = avPlayer.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.model.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = avPlayer.observe(\.timeControlStatus) { [weak self] player, _ in
|
||||
guard let self = self,
|
||||
self.avPlayer == player
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.controls.isPlaying = player.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing, player.rate != self.model.currentRate {
|
||||
player.rate = self.model.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.model.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateControls() {}
|
||||
func startControlsUpdates() {}
|
||||
func stopControlsUpdates() {}
|
||||
func setNeedsDrawing(_: Bool) {}
|
||||
func setSize(_: Double, _: Double) {}
|
||||
}
|
||||
349
Model/Player/Backends/MPVBackend.swift
Normal file
349
Model/Player/Backends/MPVBackend.swift
Normal file
@@ -0,0 +1,349 @@
|
||||
import AVFAudio
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftUI
|
||||
|
||||
final class MPVBackend: PlayerBackend {
|
||||
private var logger = Logger(label: "mpv-backend")
|
||||
|
||||
var model: PlayerModel!
|
||||
var controls: PlayerControlsModel!
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
var currentTime: CMTime?
|
||||
|
||||
var loadedVideo = false
|
||||
var isLoadingVideo = true { didSet {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.controls.isLoadingVideo = self.isLoadingVideo
|
||||
|
||||
if !self.isLoadingVideo {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.handleEOF = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
var isPlaying = true { didSet {
|
||||
if isPlaying {
|
||||
startClientUpdates()
|
||||
} else {
|
||||
stopControlsUpdates()
|
||||
}
|
||||
|
||||
updateControlsIsPlaying()
|
||||
|
||||
#if !os(macOS)
|
||||
UIApplication.shared.isIdleTimerDisabled = model.presentingPlayer && isPlaying
|
||||
#endif
|
||||
}}
|
||||
var playerItemDuration: CMTime?
|
||||
|
||||
#if !os(macOS)
|
||||
var controller: MPVViewController!
|
||||
#endif
|
||||
var client: MPVClient! { didSet { client.backend = self } }
|
||||
|
||||
private var clientTimer: RepeatingTimer!
|
||||
|
||||
private var handleEOF = false
|
||||
private var onFileLoaded: (() -> Void)?
|
||||
|
||||
private var controlsUpdates = false
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
init(model: PlayerModel, controls: PlayerControlsModel? = nil) {
|
||||
self.model = model
|
||||
self.controls = controls
|
||||
|
||||
clientTimer = .init(timeInterval: 1)
|
||||
clientTimer.eventHandler = getClientUpdates
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind == .adaptive && $0.resolution <= maxResolution.value }
|
||||
.max { $0.resolution < $1.resolution } ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != "AV1"
|
||||
}
|
||||
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading _: Bool) {
|
||||
handleEOF = false
|
||||
#if !os(macOS)
|
||||
if model.presentingPlayer {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
#endif
|
||||
|
||||
let updateCurrentStream = {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.stream = stream
|
||||
self?.video = video
|
||||
self?.model.stream = stream
|
||||
}
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start < 3,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.seek(to: segment.endTime) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.play()
|
||||
}
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItem: (CMTime?) -> Void = { [weak self] time in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.stop()
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
self.onFileLoaded = {
|
||||
updateCurrentStream()
|
||||
startPlaying()
|
||||
}
|
||||
|
||||
self.client.loadFile(url, time: time) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
self.onFileLoaded = { [weak self] in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
self?.client.addAudio(stream.audioAsset.url) { _ in
|
||||
updateCurrentStream()
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.client.loadFile(stream.videoAsset.url, time: time) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(nil)
|
||||
}
|
||||
|
||||
startClientUpdates()
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
startClientUpdates()
|
||||
|
||||
if controls.presentingControls {
|
||||
startControlsUpdates()
|
||||
}
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
stopClientUpdates()
|
||||
|
||||
client?.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
client?.stop()
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
|
||||
client.seek(to: time) { [weak self] _ in
|
||||
self?.getClientUpdates()
|
||||
self?.updateControls()
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
client.seek(relative: time) { [weak self] _ in
|
||||
self?.getClientUpdates()
|
||||
self?.updateControls()
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_: Float) {
|
||||
// TODO: Implement rate change
|
||||
}
|
||||
|
||||
func closeItem() {}
|
||||
|
||||
func enterFullScreen() {}
|
||||
|
||||
func exitFullScreen() {}
|
||||
|
||||
func closePiP(wasPlaying _: Bool) {}
|
||||
|
||||
func updateControls() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.logger.info("updating controls")
|
||||
self?.controls.currentTime = self?.currentTime ?? .zero
|
||||
self?.controls.duration = self?.playerItemDuration ?? .zero
|
||||
}
|
||||
}
|
||||
|
||||
func startControlsUpdates() {
|
||||
self.logger.info("starting controls updates")
|
||||
controlsUpdates = true
|
||||
}
|
||||
|
||||
func stopControlsUpdates() {
|
||||
self.logger.info("stopping controls updates")
|
||||
controlsUpdates = false
|
||||
}
|
||||
|
||||
func startClientUpdates() {
|
||||
clientTimer.resume()
|
||||
}
|
||||
|
||||
private func getClientUpdates() {
|
||||
self.logger.info("getting client updates")
|
||||
|
||||
currentTime = client?.currentTime
|
||||
playerItemDuration = client?.duration
|
||||
|
||||
if controlsUpdates {
|
||||
updateControls()
|
||||
}
|
||||
|
||||
model.updateNowPlayingInfo()
|
||||
|
||||
if let currentTime = currentTime {
|
||||
model.handleSegments(at: currentTime)
|
||||
}
|
||||
|
||||
timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopClientUpdates() {
|
||||
clientTimer.suspend()
|
||||
}
|
||||
|
||||
private func updateControlsIsPlaying() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.controls?.isPlaying = self?.isPlaying ?? false
|
||||
}
|
||||
}
|
||||
|
||||
func handle(_ event: UnsafePointer<mpv_event>!) {
|
||||
logger.info("\(String(cString: mpv_event_name(event.pointee.event_id)))")
|
||||
|
||||
switch event.pointee.event_id {
|
||||
case MPV_EVENT_SHUTDOWN:
|
||||
mpv_destroy(client.mpv)
|
||||
client.mpv = nil
|
||||
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
let logmsg = UnsafeMutablePointer<mpv_event_log_message>(OpaquePointer(event.pointee.data))
|
||||
logger.info(.init(stringLiteral: "log: \(String(cString: (logmsg!.pointee.prefix)!)), "
|
||||
+ "\(String(cString: (logmsg!.pointee.level)!)), "
|
||||
+ "\(String(cString: (logmsg!.pointee.text)!))"))
|
||||
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
onFileLoaded?()
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
isLoadingVideo = false
|
||||
|
||||
onFileLoaded?()
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_UNPAUSE:
|
||||
isLoadingVideo = false
|
||||
|
||||
case MPV_EVENT_END_FILE:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleEndOfFile(event)
|
||||
}
|
||||
|
||||
default:
|
||||
logger.info(.init(stringLiteral: "event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||
}
|
||||
}
|
||||
|
||||
func handleEndOfFile(_: UnsafePointer<mpv_event>!) {
|
||||
guard handleEOF, !isLoadingVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
model.resetQueue()
|
||||
|
||||
model.hide()
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
client?.setNeedsDrawing(needsDrawing)
|
||||
}
|
||||
|
||||
func setSize(_ width: Double, _ height: Double) {
|
||||
self.client?.setSize(width, height)
|
||||
}
|
||||
}
|
||||
294
Model/Player/Backends/MPVClient.swift
Normal file
294
Model/Player/Backends/MPVClient.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import Siesta
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class MPVClient: ObservableObject {
|
||||
private var logger = Logger(label: "mpv-client")
|
||||
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
var queue: DispatchQueue!
|
||||
#if os(macOS)
|
||||
var layer: VideoLayer!
|
||||
var link: CVDisplayLink!
|
||||
#else
|
||||
var glView: MPVOGLView!
|
||||
#endif
|
||||
var backend: MPVBackend!
|
||||
|
||||
var seeking = false
|
||||
|
||||
func create(frame: CGRect? = nil) {
|
||||
#if !os(macOS)
|
||||
if let frame = frame {
|
||||
glView = MPVOGLView(frame: frame)
|
||||
}
|
||||
#endif
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
print("failed creating context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
checkError(mpv_request_log_messages(mpv, "warn"))
|
||||
|
||||
#if os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#else
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "override-display-fps", "\(UIScreen.main.maximumFramesPerSecond)"))
|
||||
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
||||
#endif
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: getProcAddress,
|
||||
get_proc_address_ctx: nil,
|
||||
extra_exts: nil
|
||||
)
|
||||
|
||||
queue = DispatchQueue(label: "mpv", qos: .background)
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
|
||||
mpv_render_param()
|
||||
]
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
puts("failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate,
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(layer).toOpaque())
|
||||
)
|
||||
#else
|
||||
glView.mpvGL = UnsafeMutableRawPointer(mpvGL)
|
||||
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate(_:),
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(glView).toOpaque())
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
queue!.async {
|
||||
mpv_set_wakeup_callback(self.mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
}
|
||||
}
|
||||
|
||||
func readEvents() {
|
||||
queue?.async { [self] in
|
||||
while self.mpv != nil {
|
||||
let event = mpv_wait_event(self.mpv, 0)
|
||||
if event!.pointee.event_id == MPV_EVENT_NONE {
|
||||
break
|
||||
}
|
||||
backend.handle(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(_ url: URL, time: CMTime? = nil, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
var args = [url.absoluteString]
|
||||
if let time = time {
|
||||
args.append("replace")
|
||||
args.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func addAudio(_ url: URL, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
command("audio-add", args: [url.absoluteString], returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func play() {
|
||||
setFlagAsync("pause", false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
setFlagAsync("pause", true)
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
command("cycle", args: ["pause"])
|
||||
}
|
||||
|
||||
func stop() {
|
||||
command("stop")
|
||||
}
|
||||
|
||||
var currentTime: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(getDouble("time-pos"))
|
||||
}
|
||||
|
||||
var duration: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(getDouble("duration"))
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
guard !seeking else {
|
||||
logger.warning("ignoring seek, another in progress")
|
||||
return
|
||||
}
|
||||
|
||||
seeking = true
|
||||
command("seek", args: [String(time.seconds)]) { [weak self] _ in
|
||||
self?.seeking = false
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
guard !seeking else {
|
||||
logger.warning("ignoring seek, another in progress")
|
||||
return
|
||||
}
|
||||
|
||||
seeking = true
|
||||
command("seek", args: [String(time.seconds), "absolute"]) { [weak self] _ in
|
||||
self?.seeking = false
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func setSize(_ width: Double, _ height: Double) {
|
||||
let roundedWidth = width.rounded()
|
||||
let roundedHeight = height.rounded()
|
||||
|
||||
guard width > 0, height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("setting player size to \(roundedWidth),\(roundedHeight)")
|
||||
#if !os(macOS)
|
||||
guard roundedWidth <= UIScreen.main.bounds.width, roundedHeight <= UIScreen.main.bounds.height else {
|
||||
logger.info("requested size is greater than screen size, ignoring")
|
||||
logger.info("width: \(roundedWidth) <= \(UIScreen.main.bounds.width)")
|
||||
logger.info("height: \(roundedHeight) <= \(UIScreen.main.bounds.height)")
|
||||
return
|
||||
}
|
||||
|
||||
glView?.frame = CGRect(x: 0, y: 0, width: roundedWidth, height: roundedHeight)
|
||||
#endif
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
logger.info("needs drawing: \(needsDrawing)")
|
||||
#if !os(macOS)
|
||||
glView.needsDrawing = needsDrawing
|
||||
#endif
|
||||
}
|
||||
|
||||
func command(
|
||||
_ command: String,
|
||||
args: [String?] = [],
|
||||
checkForErrors: Bool = true,
|
||||
returnValueCallback: ((Int32) -> Void)? = nil
|
||||
) {
|
||||
guard mpv != nil else {
|
||||
return
|
||||
}
|
||||
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
|
||||
defer {
|
||||
for ptr in cargs where ptr != nil {
|
||||
free(UnsafeMutablePointer(mutating: ptr!))
|
||||
}
|
||||
}
|
||||
logger.info("\(command) -- \(args)")
|
||||
let returnValue = mpv_command(mpv, &cargs)
|
||||
if checkForErrors {
|
||||
checkError(returnValue)
|
||||
}
|
||||
if let cb = returnValueCallback {
|
||||
cb(returnValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func setFlagAsync(_ name: String, _ flag: Bool) {
|
||||
var data: Int = flag ? 1 : 0
|
||||
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)
|
||||
}
|
||||
|
||||
private func getDouble(_ name: String) -> Double {
|
||||
var data = Double()
|
||||
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
|
||||
return data
|
||||
}
|
||||
|
||||
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
|
||||
if !args.isEmpty, args.last == nil {
|
||||
fatalError("Command do not need a nil suffix")
|
||||
}
|
||||
|
||||
var strArgs = args
|
||||
strArgs.insert(command, at: 0)
|
||||
strArgs.append(nil)
|
||||
|
||||
return strArgs
|
||||
}
|
||||
|
||||
func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
logger.error(.init(stringLiteral: "MPV API error: \(String(cString: mpv_error_string(status)))\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
|
||||
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
|
||||
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengl" as CFString)
|
||||
|
||||
return CFBundleGetFunctionPointerForName(identifier, symbolName)
|
||||
}
|
||||
|
||||
func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let videoLayer = unsafeBitCast(ctx, to: VideoLayer.self)
|
||||
|
||||
videoLayer.client?.queue?.async {
|
||||
if !videoLayer.isAsynchronous {
|
||||
videoLayer.display()
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
|
||||
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
|
||||
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString)
|
||||
|
||||
return CFBundleGetFunctionPointerForName(identifier, symbolName)
|
||||
}
|
||||
|
||||
private func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let glView = unsafeBitCast(ctx, to: MPVOGLView.self)
|
||||
|
||||
guard glView.needsDrawing else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
glView.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
private func wakeUp(_ context: UnsafeMutableRawPointer?) {
|
||||
let client = unsafeBitCast(context, to: MPVClient.self)
|
||||
client.readEvents()
|
||||
}
|
||||
68
Model/Player/Backends/PlayerBackend.swift
Normal file
68
Model/Player/Backends/PlayerBackend.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
protocol PlayerBackend {
|
||||
var model: PlayerModel! { get set }
|
||||
var controls: PlayerControlsModel! { get set }
|
||||
|
||||
var stream: Stream? { get set }
|
||||
var video: Video? { get set }
|
||||
var currentTime: CMTime? { get }
|
||||
|
||||
var loadedVideo: Bool { get }
|
||||
var isLoadingVideo: Bool { get }
|
||||
|
||||
var isPlaying: Bool { get }
|
||||
var playerItemDuration: CMTime? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading: Bool
|
||||
)
|
||||
|
||||
func play()
|
||||
func pause()
|
||||
func togglePlay()
|
||||
|
||||
func stop()
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?)
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)?)
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)?)
|
||||
|
||||
func setRate(_ rate: Float)
|
||||
|
||||
func closeItem()
|
||||
|
||||
func enterFullScreen()
|
||||
func exitFullScreen()
|
||||
|
||||
func closePiP(wasPlaying: Bool)
|
||||
|
||||
func updateControls()
|
||||
func startControlsUpdates()
|
||||
func stopControlsUpdates()
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool)
|
||||
func setSize(_ width: Double, _ height: Double)
|
||||
}
|
||||
|
||||
extension PlayerBackend {
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: time, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: .secondsInDefaultTimescale(seconds), completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(relative: time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
|
||||
case mpv
|
||||
case appleAVPlayer
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .mpv:
|
||||
return "MPV"
|
||||
case .appleAVPlayer:
|
||||
return "AVPlayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
120
Model/Player/PlayerControlsModel.swift
Normal file
120
Model/Player/PlayerControlsModel.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerControlsModel: ObservableObject {
|
||||
@Published var isLoadingVideo = true
|
||||
@Published var isPlaying = true
|
||||
@Published var currentTime = CMTime.zero
|
||||
@Published var duration = CMTime.zero
|
||||
@Published var presentingControls = false { didSet { handlePresentationChange() } }
|
||||
@Published var timer: Timer?
|
||||
@Published var playingFullscreen = false
|
||||
|
||||
private var throttle = Throttle(interval: 1)
|
||||
|
||||
var player: PlayerModel!
|
||||
|
||||
var playbackTime: String {
|
||||
guard let current = currentTime.seconds.formattedAsPlaybackTime(),
|
||||
let duration = duration.seconds.formattedAsPlaybackTime()
|
||||
else {
|
||||
return "--:-- / --:--"
|
||||
}
|
||||
|
||||
var withoutSegments = ""
|
||||
if let withoutSegmentsDuration = playerItemDurationWithoutSponsorSegments,
|
||||
self.duration.seconds != withoutSegmentsDuration
|
||||
{
|
||||
withoutSegments = " (\(withoutSegmentsDuration.formattedAsPlaybackTime() ?? "--:--"))"
|
||||
}
|
||||
|
||||
return "\(current) / \(duration)\(withoutSegments)"
|
||||
}
|
||||
|
||||
var playerItemDurationWithoutSponsorSegments: Double? {
|
||||
guard let duration = player.playerItemDurationWithoutSponsorSegments else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return duration.seconds
|
||||
}
|
||||
|
||||
func handlePresentationChange() {
|
||||
if presentingControls {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.player.backend.startControlsUpdates()
|
||||
self?.resetTimer()
|
||||
}
|
||||
} else {
|
||||
player.backend.stopControlsUpdates()
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard !(player?.currentItem.isNil ?? true) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !presentingControls else {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls = true
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
player?.backend.stopControlsUpdates()
|
||||
|
||||
guard !(player?.currentItem.isNil ?? true) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard presentingControls else {
|
||||
return
|
||||
}
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls = false
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
withAnimation(PlayerControls.animation) {
|
||||
presentingControls.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentTime = .zero
|
||||
duration = .zero
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
if !presentingControls {
|
||||
show()
|
||||
}
|
||||
|
||||
removeTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||||
withAnimation(PlayerControls.animation) { [weak self] in
|
||||
self?.presentingControls = false
|
||||
self?.player.backend.stopControlsUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func update() {
|
||||
throttle.execute { [weak self] in
|
||||
self?.player?.backend.updateControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
558
Model/Player/PlayerModel.swift
Normal file
558
Model/Player/PlayerModel.swift
Normal file
@@ -0,0 +1,558 @@
|
||||
import AVKit
|
||||
import CoreData
|
||||
#if os(iOS)
|
||||
import CoreMotion
|
||||
#endif
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import MediaPlayer
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
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")
|
||||
|
||||
var avPlayerView = AppleAVPlayerView()
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
var mpvPlayerView = MPVPlayerView()
|
||||
|
||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||
@Published var activeBackend = PlayerBackendType.mpv
|
||||
|
||||
var avPlayerBackend: AVPlayerBackend!
|
||||
var mpvBackend: MPVBackend!
|
||||
|
||||
var backends: [PlayerBackend] {
|
||||
[avPlayerBackend, mpvBackend]
|
||||
}
|
||||
|
||||
var backend: PlayerBackend! {
|
||||
switch activeBackend {
|
||||
case .mpv:
|
||||
return mpvBackend
|
||||
case .appleAVPlayer:
|
||||
return avPlayerBackend
|
||||
}
|
||||
}
|
||||
|
||||
@Published var playerSize: CGSize = .zero { didSet {
|
||||
backend.setSize(playerSize.width, playerSize.height)
|
||||
}}
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float = 1.0 { didSet { backend.setRate(currentRate) } }
|
||||
|
||||
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
|
||||
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
|
||||
@Published var historyVideos = [Video]()
|
||||
|
||||
@Published var preservedTime: CMTime?
|
||||
|
||||
@Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
|
||||
|
||||
@Published var sponsorBlock = SponsorBlockAPI()
|
||||
@Published var segmentRestorationTime: CMTime?
|
||||
@Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } }
|
||||
@Published var restoredSegments = [Segment]()
|
||||
|
||||
@Published var returnYouTubeDislike = ReturnYouTubeDislikeAPI()
|
||||
|
||||
@Published var channelWithDetails: Channel?
|
||||
|
||||
#if os(iOS)
|
||||
@Published var motionManager: CMMotionManager!
|
||||
@Published var lockedOrientation: UIInterfaceOrientation?
|
||||
@Published var lastOrientation: UIInterfaceOrientation?
|
||||
#endif
|
||||
|
||||
var accounts: AccountsModel
|
||||
var comments: CommentsModel
|
||||
var controls: PlayerControlsModel { didSet {
|
||||
backends.forEach { backend in
|
||||
var backend = backend
|
||||
backend.controls = controls
|
||||
}
|
||||
}}
|
||||
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
@Published var playingInPictureInPicture = false
|
||||
|
||||
@Published var presentingErrorDetails = false
|
||||
var playerError: Error? { didSet {
|
||||
#if !os(tvOS)
|
||||
if !playerError.isNil {
|
||||
presentingErrorDetails = true
|
||||
}
|
||||
#endif
|
||||
}}
|
||||
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
|
||||
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
|
||||
self.accounts = accounts ?? AccountsModel()
|
||||
self.comments = comments ?? CommentsModel()
|
||||
self.controls = controls ?? PlayerControlsModel()
|
||||
|
||||
self.avPlayerBackend = AVPlayerBackend(model: self, controls: controls)
|
||||
self.mpvBackend = MPVBackend(model: self)
|
||||
|
||||
self.activeBackend = Defaults[.activeBackend]
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard !presentingPlayer else {
|
||||
#if os(macOS)
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
#endif
|
||||
presentingPlayer = true
|
||||
}
|
||||
|
||||
func hide() {
|
||||
controls.playingFullscreen = false
|
||||
presentingPlayer = false
|
||||
playerNavigationLinkActive = false
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func togglePlayer() {
|
||||
#if os(macOS)
|
||||
if !presentingPlayer {
|
||||
Windows.player.open()
|
||||
}
|
||||
Windows.player.focus()
|
||||
#else
|
||||
if presentingPlayer {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
guard !currentVideo.isNil else {
|
||||
return false
|
||||
}
|
||||
|
||||
return backend.isLoadingVideo
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
backend.isPlaying
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
backend.playerItemDuration
|
||||
}
|
||||
|
||||
var playerItemDurationWithoutSponsorSegments: CMTime? {
|
||||
(backend.playerItemDuration ?? .zero) - .secondsInDefaultTimescale(
|
||||
sponsorBlock.segments.reduce(0) { $0 + $1.duration }
|
||||
)
|
||||
}
|
||||
|
||||
var videoDuration: TimeInterval? {
|
||||
currentItem?.duration ?? currentVideo?.length ?? playerItemDuration?.seconds
|
||||
}
|
||||
|
||||
var time: CMTime? {
|
||||
currentItem?.playbackTime
|
||||
}
|
||||
|
||||
var live: Bool {
|
||||
currentVideo?.live ?? false
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
backend.togglePlay()
|
||||
}
|
||||
|
||||
func play() {
|
||||
backend.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
backend.pause()
|
||||
}
|
||||
|
||||
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
|
||||
playNow(video, at: time)
|
||||
|
||||
guard !playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
playerError = nil
|
||||
if !upgrading {
|
||||
resetSegments()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.sponsorBlock.loadSegments(
|
||||
videoID: video.videoID,
|
||||
categories: Defaults[.sponsorBlockCategories]
|
||||
) {
|
||||
if Defaults[.showChannelSubscribers] {
|
||||
self?.loadCurrentItemChannelDetails()
|
||||
}
|
||||
}
|
||||
|
||||
guard Defaults[.enableReturnYouTubeDislike] else {
|
||||
return
|
||||
}
|
||||
|
||||
self?.returnYouTubeDislike.loadDislikes(videoID: video.videoID) { [weak self] dislikes in
|
||||
self?.currentItem?.video?.dislikes = dislikes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controls.reset()
|
||||
|
||||
backend.playStream(
|
||||
stream,
|
||||
of: video,
|
||||
preservingTime: preservingTime,
|
||||
upgrading: upgrading
|
||||
)
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
func saveTime(completionHandler: @escaping () -> Void = {}) {
|
||||
guard let currentTime = backend.currentTime, currentTime.seconds > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.preservedTime = currentTime
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func upgradeToStream(_ stream: Stream, force: Bool = false) {
|
||||
if !self.stream.isNil, force || self.stream != stream {
|
||||
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAvailableStreamsChange() {
|
||||
rebuildTVMenu()
|
||||
|
||||
guard stream.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let stream = preferredStream(availableStreams) else {
|
||||
return
|
||||
}
|
||||
|
||||
streamSelection = stream
|
||||
playStream(
|
||||
stream,
|
||||
of: currentVideo!,
|
||||
preservingTime: !currentItem.playbackTime.isNil
|
||||
)
|
||||
}
|
||||
|
||||
private func handlePresentationChange() {
|
||||
backend.setNeedsDrawing(presentingPlayer)
|
||||
controls.hide()
|
||||
|
||||
#if !os(macOS)
|
||||
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
|
||||
#endif
|
||||
|
||||
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.closePiP()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer, !pauseOnHidingPlayer, backend.isPlaying {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNavigationViewPlayerPresentationChange() {
|
||||
backend.setNeedsDrawing(playerNavigationLinkActive)
|
||||
controls.hide()
|
||||
|
||||
if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
|
||||
Defaults[.activeBackend] = to
|
||||
self.activeBackend = to
|
||||
|
||||
guard var stream = stream else {
|
||||
return
|
||||
}
|
||||
|
||||
inactiveBackends().forEach { $0.pause() }
|
||||
|
||||
let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
|
||||
if let stream = toBackend.stream, toBackend.video == fromBackend.video {
|
||||
toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
toBackend.play()
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
streamSelection = stream
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !backend.canPlay(stream) {
|
||||
guard let preferredStream = preferredStream(availableStreams) else {
|
||||
return
|
||||
}
|
||||
|
||||
stream = preferredStream
|
||||
streamSelection = preferredStream
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.upgradeToStream(stream, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func inactiveBackends() -> [PlayerBackend] {
|
||||
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
|
||||
}
|
||||
|
||||
func loadCurrentItemChannelDetails() {
|
||||
guard let video = currentVideo,
|
||||
!video.channel.detailsLoaded
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if restoreLoadedChannel() {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in
|
||||
if let channel: Channel = response.typedContent() {
|
||||
self?.channelWithDetails = channel
|
||||
withAnimation {
|
||||
self?.currentItem?.video?.channel = channel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func restoreLoadedChannel() -> Bool {
|
||||
if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id {
|
||||
currentItem.video.channel = channelWithDetails!
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
return "\(formatter.string(from: NSNumber(value: rate))!)×"
|
||||
}
|
||||
|
||||
func closeCurrentItem(finished: Bool = false) {
|
||||
prepareCurrentItemForHistory(finished: finished)
|
||||
currentItem = nil
|
||||
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
func closePiP() {
|
||||
guard playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
pause()
|
||||
|
||||
#if os(tvOS)
|
||||
show()
|
||||
#endif
|
||||
|
||||
backend.closePiP(wasPlaying: wasPlaying)
|
||||
}
|
||||
|
||||
func handleCurrentItemChange() {
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
#endif
|
||||
|
||||
Defaults[.lastPlayed] = currentItem
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
var windowTitle: String {
|
||||
currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)"
|
||||
}
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
show()
|
||||
closePiP()
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
guard !controls.playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("entering fullscreen")
|
||||
|
||||
backend.enterFullScreen()
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
guard controls.playingFullscreen else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
|
||||
backend.exitFullScreen()
|
||||
}
|
||||
#endif
|
||||
|
||||
func updateNowPlayingInfo() {
|
||||
guard let video = currentItem?.video else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: video.author as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
if !video.live {
|
||||
let itemDuration = (backend.playerItemDuration ?? .zero).seconds
|
||||
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
|
||||
|
||||
if !duration.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
func updateCurrentArtwork() {
|
||||
guard let video = currentVideo,
|
||||
let thumbnailData = try? Data(contentsOf: 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 toggleFullscreen(_ isFullScreen: Bool) {
|
||||
controls.resetTimer()
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.toggleFullScreen()
|
||||
#endif
|
||||
|
||||
controls.playingFullscreen = !isFullScreen
|
||||
|
||||
#if os(iOS)
|
||||
if controls.playingFullscreen {
|
||||
guard !(UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true) else {
|
||||
return
|
||||
}
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: .landscapeRight)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
213
Model/Player/PlayerQueue.swift
Normal file
213
Model/Player/PlayerQueue.swift
Normal file
@@ -0,0 +1,213 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
extension PlayerModel {
|
||||
var currentVideo: Video? {
|
||||
currentItem?.video
|
||||
}
|
||||
|
||||
func play(_ videos: [Video], shuffling: Bool = false, inNavigationView: Bool = false) {
|
||||
let videosToPlay = shuffling ? videos.shuffled() : videos
|
||||
|
||||
guard let first = videosToPlay.first else {
|
||||
return
|
||||
}
|
||||
|
||||
enqueueVideo(first, prepending: true) { _, item in
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
|
||||
videosToPlay.dropFirst().reversed().forEach { video in
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
if item.video == first {
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if playingInPictureInPicture, closePiPOnNavigation {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
self.advanceToItem(item, at: time)
|
||||
}
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
|
||||
if !playingInPictureInPicture {
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
comments.reset()
|
||||
stream = nil
|
||||
currentItem = item
|
||||
|
||||
if !time.isNil {
|
||||
currentItem.playbackTime = .secondsInDefaultTimescale(time!)
|
||||
} else if currentItem.playbackTime.isNil {
|
||||
currentItem.playbackTime = .zero
|
||||
}
|
||||
|
||||
if video != nil {
|
||||
currentItem.video = video!
|
||||
}
|
||||
|
||||
preservedTime = currentItem.playbackTime
|
||||
restoreLoadedChannel()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let video = self?.currentVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
self?.loadAvailableStreams(video)
|
||||
}
|
||||
}
|
||||
|
||||
func preferredStream(_ streams: [Stream]) -> Stream? {
|
||||
let quality = Defaults[.quality]
|
||||
var streams = streams
|
||||
|
||||
if let id = Defaults[.playerInstanceID] {
|
||||
streams = streams.filter { $0.instance.id == id }
|
||||
}
|
||||
|
||||
streams = streams.filter { backend.canPlay($0) }
|
||||
|
||||
return backend.bestPlayable(streams, maxResolution: quality)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
|
||||
prepareCurrentItemForHistory()
|
||||
|
||||
remove(newItem)
|
||||
|
||||
currentItem = newItem
|
||||
pause()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
if play {
|
||||
currentItem = item
|
||||
// pause playing current video as it's going to be replaced with next one
|
||||
pause()
|
||||
}
|
||||
|
||||
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 prepareCurrentItemForHistory(finished: Bool = false) {
|
||||
if !currentItem.isNil, Defaults[.saveHistory] {
|
||||
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
|
||||
historyVideos.append(video)
|
||||
}
|
||||
updateWatch(finished: finished)
|
||||
}
|
||||
}
|
||||
|
||||
func playHistory(_ item: PlayerQueueItem) {
|
||||
var time = item.playbackTime
|
||||
|
||||
if item.shouldRestartPlaying {
|
||||
time = .zero
|
||||
}
|
||||
|
||||
let newItem = enqueueVideo(item.video, atTime: time, prepending: true)
|
||||
|
||||
advanceToItem(newItem!)
|
||||
}
|
||||
|
||||
func removeQueueItems() {
|
||||
queue.removeAll()
|
||||
}
|
||||
|
||||
func restoreQueue() {
|
||||
guard !accounts.current.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
queue = ([Defaults[.lastPlayed]] + Defaults[.queue]).compactMap { $0 }
|
||||
Defaults[.lastPlayed] = nil
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Model/Player/PlayerQueueItem.swift
Normal file
45
Model/Player/PlayerQueueItem.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
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?
|
||||
|
||||
static func from(_ watch: Watch, video: Video? = nil) -> Self {
|
||||
.init(
|
||||
video,
|
||||
videoID: watch.videoID,
|
||||
playbackTime: CMTime.secondsInDefaultTimescale(watch.stoppedAt),
|
||||
videoDuration: watch.videoDuration
|
||||
)
|
||||
}
|
||||
|
||||
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 ?? .zero
|
||||
}
|
||||
|
||||
var shouldRestartPlaying: Bool {
|
||||
guard let seconds = playbackTime?.seconds else {
|
||||
return false
|
||||
}
|
||||
|
||||
return duration - seconds <= 10
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
65
Model/Player/PlayerQueueItemBridge.swift
Normal file
65
Model/Player/PlayerQueueItemBridge.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
84
Model/Player/PlayerSponsorBlock.swift
Normal file
84
Model/Player/PlayerSponsorBlock.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
|
||||
backend.seek(to: segment.endTime)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.lastSkipped = segment
|
||||
self?.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)
|
||||
backend.seek(to: time)
|
||||
resetLastSegment()
|
||||
}
|
||||
|
||||
private func resetLastSegment() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.lastSkipped = nil
|
||||
self?.segmentRestorationTime = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetSegments() {
|
||||
resetLastSegment()
|
||||
restoredSegments = []
|
||||
}
|
||||
}
|
||||
78
Model/Player/PlayerStreams.swift
Normal file
78
Model/Player/PlayerStreams.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
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) {
|
||||
availableStreams = []
|
||||
let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first
|
||||
|
||||
guard !playerInstance.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("loading streams from \(playerInstance!.description)")
|
||||
|
||||
fetchStreams(playerInstance!.anonymous.video(video.videoID), instance: playerInstance!, video: video) { _ in
|
||||
InstancesModel.all.filter { $0 != playerInstance }.forEach { instance in
|
||||
self.logger.info("loading streams from \(instance.description)")
|
||||
self.fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
guard video == self.currentVideo else {
|
||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||
return
|
||||
}
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
} else {
|
||||
self.logger.critical("no streams available from \(instance.description)")
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
76
Model/Player/PlayerTVMenu.swift
Normal file
76
Model/Player/PlayerTVMenu.swift
Normal 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)
|
||||
avPlayerBackend.controller?.playerView.transportBarCustomMenuItems = [
|
||||
restoreLastSkippedSegmentAction,
|
||||
rateMenu,
|
||||
streamsMenu
|
||||
].compactMap { $0 }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
34
Model/Player/ScreenSaverManager.swift
Normal file
34
Model/Player/ScreenSaverManager.swift
Normal 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
47
Model/Playlist.swift
Normal 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, videos: [Video] = []) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.visibility = visibility
|
||||
self.updated = updated
|
||||
self.videos = videos
|
||||
}
|
||||
|
||||
init(_ json: JSON) {
|
||||
id = json["playlistId"].stringValue
|
||||
title = json["title"].stringValue
|
||||
visibility = json["isListed"].boolValue ? .public : .private
|
||||
updated = json["updated"].doubleValue
|
||||
}
|
||||
|
||||
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
||||
lhs.id == rhs.id && lhs.updated == rhs.updated
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
85
Model/PlaylistsModel.swift
Normal file
85
Model/PlaylistsModel.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
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 accounts.app.supportsUserPlaylists, accounts.signedIn 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 = {},
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in }
|
||||
) {
|
||||
let resource = accounts.api.playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
166
Model/RecentsModel.swift
Normal file
166
Model/RecentsModel.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
final class RecentsModel: ObservableObject {
|
||||
@Default(.recentlyOpened) var items
|
||||
@Default(.saveRecents) var saveRecents
|
||||
func clear() {
|
||||
items = []
|
||||
}
|
||||
|
||||
func clearQueries() {
|
||||
items.removeAll { $0.type == .query }
|
||||
}
|
||||
|
||||
func add(_ item: RecentItem) {
|
||||
if !saveRecents {
|
||||
clear()
|
||||
|
||||
if item.type == .query {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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, navigation: NavigationModel? = nil) {
|
||||
if !query.isEmpty {
|
||||
navigation?.tabSelection = .search
|
||||
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
|
||||
}
|
||||
|
||||
static func symbolSystemImage(_ name: String) -> String {
|
||||
let firstLetter = name.first?.lowercased()
|
||||
let regex = #"^[a-z0-9]$"#
|
||||
|
||||
let symbolName = firstLetter?.range(of: regex, options: .regularExpression) != nil ? firstLetter! : "questionmark"
|
||||
|
||||
return "\(symbolName).circle"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
48
Model/ReturnYouTubeDislike/ReturnYouTubeDislikeAPI.swift
Normal file
48
Model/ReturnYouTubeDislike/ReturnYouTubeDislikeAPI.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Alamofire
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class ReturnYouTubeDislikeAPI: ObservableObject {
|
||||
let logger = Logger(label: "stream.yattee.app.rytd")
|
||||
|
||||
@Published var videoID: String?
|
||||
@Published var dislikes = -1
|
||||
|
||||
func loadDislikes(videoID: String, completionHandler: @escaping (Int) -> Void = { _ in }) {
|
||||
guard self.videoID != videoID else {
|
||||
completionHandler(dislikes)
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.requestDislikes(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestDislikes(completionHandler: @escaping (Int) -> Void = { _ in }) {
|
||||
AF.request(votesURL).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
let value = JSON(value).dictionaryValue["dislikes"]?.int
|
||||
self.dislikes = value ?? -1
|
||||
|
||||
case let .failure(error):
|
||||
self.logger.error("failed to load dislikes: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
completionHandler(self.dislikes)
|
||||
}
|
||||
}
|
||||
|
||||
private var votesURL: String {
|
||||
"https://returnyoutubedislikeapi.com/Votes?videoId=\(videoID ?? "")"
|
||||
}
|
||||
}
|
||||
140
Model/Search/SearchModel.swift
Normal file
140
Model/Search/SearchModel.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import Defaults
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class SearchModel: ObservableObject {
|
||||
@Published var store = Store<[ContentItem]>()
|
||||
@Published var page: SearchPage?
|
||||
|
||||
var accounts = AccountsModel()
|
||||
@Published var query = SearchQuery()
|
||||
@Published var queryText = ""
|
||||
@Published var querySuggestions = Store<[String]>()
|
||||
@Published var suggestionsText = ""
|
||||
|
||||
@Published var fieldIsFocused = false
|
||||
|
||||
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, page: nil)
|
||||
guard newResource != resource else {
|
||||
return
|
||||
}
|
||||
|
||||
page = nil
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResource()
|
||||
}
|
||||
}
|
||||
|
||||
func resetQuery(_ query: SearchQuery = SearchQuery()) {
|
||||
self.query = query
|
||||
|
||||
let newResource = accounts.api.search(query, page: nil)
|
||||
guard newResource != resource else {
|
||||
return
|
||||
}
|
||||
|
||||
page = nil
|
||||
store.replace([])
|
||||
|
||||
resource = newResource
|
||||
resource.addObserver(store)
|
||||
|
||||
if !query.isEmpty {
|
||||
loadResource()
|
||||
}
|
||||
}
|
||||
|
||||
func loadResource() {
|
||||
let currentResource = resource!
|
||||
|
||||
resource.load().onSuccess { response in
|
||||
if let page: SearchPage = response.typedContent() {
|
||||
self.page = page
|
||||
self.replace(page.results, for: currentResource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replace(_ items: [ContentItem], for resource: Resource) {
|
||||
if self.resource == resource {
|
||||
store = Store<[ContentItem]>(items)
|
||||
}
|
||||
}
|
||||
|
||||
private var suggestionsDebounceTimer: Timer?
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard !query.isEmpty else {
|
||||
querySuggestions.replace([])
|
||||
return
|
||||
}
|
||||
|
||||
suggestionsDebounceTimer?.invalidate()
|
||||
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.2, 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)
|
||||
}
|
||||
self.suggestionsText = query
|
||||
}
|
||||
} else {
|
||||
self.querySuggestions = Store<[String]>(self.querySuggestions.collection)
|
||||
self.suggestionsText = query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard var pageToLoad = page, !pageToLoad.last else {
|
||||
return
|
||||
}
|
||||
|
||||
if pageToLoad.nextPage.isNil, accounts.app.searchUsesIndexedPages {
|
||||
pageToLoad.nextPage = "2"
|
||||
}
|
||||
|
||||
resource?.removeObservers(ownedBy: store)
|
||||
|
||||
resource = accounts.api.search(query, page: pageToLoad.nextPage)
|
||||
resource.addObserver(store)
|
||||
|
||||
resource
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let page: SearchPage = response.typedContent() {
|
||||
var nextPage: Int?
|
||||
if self.accounts.app.searchUsesIndexedPages {
|
||||
nextPage = Int(pageToLoad.nextPage ?? "0")
|
||||
}
|
||||
|
||||
self.page = page
|
||||
|
||||
if self.accounts.app.searchUsesIndexedPages {
|
||||
self.page?.nextPage = String((nextPage ?? 1) + 1)
|
||||
}
|
||||
|
||||
self.replace(self.store.collection + page.results, for: self.resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
Model/Search/SearchPage.swift
Normal file
7
Model/Search/SearchPage.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct SearchPage {
|
||||
var results = [ContentItem]()
|
||||
var nextPage: String?
|
||||
var last = false
|
||||
}
|
||||
74
Model/Search/SearchQuery.swift
Normal file
74
Model/Search/SearchQuery.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
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?
|
||||
|
||||
init(query: String = "", sortBy: SearchQuery.SortOrder = .relevance, date: SearchQuery.Date? = nil, duration: SearchQuery.Duration? = nil) {
|
||||
self.query = query
|
||||
self.sortBy = sortBy
|
||||
self.date = date
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
query.isEmpty
|
||||
}
|
||||
}
|
||||
50
Model/Segment.swift
Normal file
50
Model/Segment.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
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 duration: Double {
|
||||
end - start
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
12
Model/SingleAssetStream.swift
Normal file
12
Model/SingleAssetStream.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
116
Model/SponsorBlock/SponsorBlockAPI.swift
Normal file
116
Model/SponsorBlock/SponsorBlockAPI.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
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: "stream.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
|
||||
}
|
||||
}
|
||||
|
||||
static func categoryDetails(_ name: String) -> String? {
|
||||
guard SponsorBlockAPI.categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "sponsor":
|
||||
return "Part of a video promoting a product or service not directly related to the creator. " +
|
||||
"The creator will receive payment or compensation in the form of money or free products."
|
||||
|
||||
case "selfpromo":
|
||||
return "Promoting a product or service that is directly related to the creator themselves. " +
|
||||
"This usually includes merchandise or promotion of monetized platforms."
|
||||
|
||||
case "intro":
|
||||
return "Segments typically found at the start of a video that include an animation, " +
|
||||
"still frame or clip which are also seen in other videos by the same creator."
|
||||
|
||||
case "outro":
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown."
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)."
|
||||
|
||||
case "music_offtopic":
|
||||
return "For videos which feature music as the primary content."
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadSegments(videoID: String, categories: Set<String>, completionHandler: @escaping () -> Void = {}) {
|
||||
guard !skipSegmentsURL.isNil, self.videoID != videoID else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
self.videoID = videoID
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.requestSegments(categories: categories, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestSegments(categories: Set<String>, completionHandler: @escaping () -> Void = {}) {
|
||||
guard let url = skipSegmentsURL, !categories.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
AF.request(url, parameters: parameters(categories: categories)).responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
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)!
|
||||
]
|
||||
}
|
||||
}
|
||||
24
Model/SponsorBlock/SponsorBlockSegment.swift
Normal file
24
Model/SponsorBlock/SponsorBlockSegment.swift
Normal 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
25
Model/Store.swift
Normal 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
|
||||
}
|
||||
}
|
||||
159
Model/Stream.swift
Normal file
159
Model/Stream.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable:next final_class
|
||||
class Stream: Equatable, Hashable, Identifiable {
|
||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case hd2160p
|
||||
case hd1440p60
|
||||
case hd1440p
|
||||
case hd1080p60
|
||||
case hd1080p
|
||||
case hd720p60
|
||||
case hd720p
|
||||
case sd480p
|
||||
case sd360p
|
||||
case sd240p
|
||||
case sd144p
|
||||
case 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!
|
||||
var videoFormat: String!
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
audioAsset: AVURLAsset? = nil,
|
||||
videoAsset: AVURLAsset? = nil,
|
||||
hlsURL: URL? = nil,
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
self.videoAsset = videoAsset
|
||||
self.hlsURL = hlsURL
|
||||
self.resolution = resolution
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
self.videoFormat = videoFormat
|
||||
}
|
||||
|
||||
var quality: String {
|
||||
if resolution == .hd2160p {
|
||||
return "4K (2160p)"
|
||||
}
|
||||
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
}
|
||||
|
||||
var format: String {
|
||||
let lowercasedFormat = (videoFormat ?? "unknown").lowercased()
|
||||
if lowercasedFormat.contains("webm") {
|
||||
return "WEBM"
|
||||
} else if lowercasedFormat.contains("avc1") {
|
||||
return "avc1"
|
||||
} else if lowercasedFormat.contains("av01") {
|
||||
return "AV1"
|
||||
} else if lowercasedFormat.contains("mpeg_4") || lowercasedFormat.contains("mp4") {
|
||||
return "MP4"
|
||||
} else {
|
||||
return lowercasedFormat
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
let formatString = format == "unknown" ? "" : " (\(format))"
|
||||
return "\(quality)\(formatString) - \(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)
|
||||
}
|
||||
}
|
||||
62
Model/SubscriptionsModel.swift
Normal file
62
Model/SubscriptionsModel.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
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, accounts.signedIn else {
|
||||
channels = []
|
||||
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
39
Model/Thumbnail.swift
Normal 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
|
||||
}
|
||||
}
|
||||
30
Model/ThumbnailsModel.swift
Normal file
30
Model/ThumbnailsModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
21
Model/TrendingCategory.swift
Normal file
21
Model/TrendingCategory.swift
Normal 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
|
||||
}
|
||||
}
|
||||
129
Model/Video.swift
Normal file
129
Model/Video.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
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 ?? 0) > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return likes?.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
var dislikesCount: String? {
|
||||
guard (dislikes ?? 0) > 0 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 {
|
||||
let videoIDIsEqual = lhs.videoID == rhs.videoID
|
||||
|
||||
if !lhs.indexID.isNil, !rhs.indexID.isNil {
|
||||
return videoIDIsEqual && lhs.indexID == rhs.indexID
|
||||
}
|
||||
|
||||
return videoIDIsEqual
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
var watchFetchRequest: FetchRequest<Watch> {
|
||||
FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
predicate: NSPredicate(format: "videoID = %@", videoID)
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user