Compare commits
40 Commits
2.0.0-beta
...
2.0.0-252
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1a0adbea0 | ||
|
|
02c0b637cb | ||
|
|
49b8599a67 | ||
|
|
8379006630 | ||
|
|
6855210608 | ||
|
|
c339597f8a | ||
|
|
79e4a24f75 | ||
|
|
b559886e2e | ||
|
|
9587ab37f8 | ||
|
|
3aadc9be70 | ||
|
|
e51ebd7ab2 | ||
|
|
9eb6812fb2 | ||
|
|
089e5842de | ||
|
|
bce72d776c | ||
|
|
b54e0440ec | ||
|
|
56ac6d8848 | ||
|
|
bcbd8f3350 | ||
|
|
a8c59e61cd | ||
|
|
4f954b7c9c | ||
|
|
0cfe365d4f | ||
|
|
284aec679f | ||
|
|
d8d16280ff | ||
|
|
d546c0a976 | ||
|
|
fd41833532 | ||
|
|
d1d7edb5ec | ||
|
|
24e60916f8 | ||
|
|
cd2e0a6e81 | ||
|
|
ded130a8c3 | ||
|
|
78bc7e0063 | ||
|
|
6c30e745d9 | ||
|
|
b6b6d280e1 | ||
|
|
7ac45b46a3 | ||
|
|
288113d177 | ||
|
|
cae1226cfe | ||
|
|
9cd9506dcf | ||
|
|
3905fd8b18 | ||
|
|
13f7a0f0ca | ||
|
|
612dce6b9f | ||
|
|
8464464199 | ||
|
|
05f921d605 |
186
.github/workflows/release.yml
vendored
@@ -9,22 +9,15 @@ on:
|
||||
build_tvos:
|
||||
description: 'Build tvOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
build_mac_beta:
|
||||
description: 'Build macOS (TestFlight)'
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
build_mac_notarized:
|
||||
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
|
||||
description: 'Build macOS (notarized)'
|
||||
type: boolean
|
||||
default: true
|
||||
release_channel:
|
||||
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
|
||||
type: choice
|
||||
options:
|
||||
- beta
|
||||
- stable
|
||||
default: beta
|
||||
default: false
|
||||
create_release:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
@@ -49,11 +42,12 @@ env:
|
||||
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
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
build_number: ${{ steps.calc.outputs.build_number }}
|
||||
version_number: ${{ steps.version.outputs.version_number }}
|
||||
@@ -83,7 +77,7 @@ jobs:
|
||||
if: ${{ inputs.build_ios }}
|
||||
needs: [determine_build_number]
|
||||
name: Release iOS to TestFlight
|
||||
runs-on: macos-26
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
@@ -91,6 +85,9 @@ jobs:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set signing to manual for CI
|
||||
run: |
|
||||
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
|
||||
- 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
|
||||
@@ -109,7 +106,7 @@ jobs:
|
||||
if: ${{ inputs.build_tvos }}
|
||||
needs: [determine_build_number]
|
||||
name: Release tvOS to TestFlight
|
||||
runs-on: macos-26
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
@@ -117,6 +114,9 @@ jobs:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set signing to manual for CI
|
||||
run: |
|
||||
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
|
||||
- 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
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
if: ${{ inputs.build_mac_beta }}
|
||||
needs: [determine_build_number]
|
||||
name: Release macOS to TestFlight
|
||||
runs-on: macos-26
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
@@ -143,6 +143,9 @@ jobs:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set signing to manual for CI
|
||||
run: |
|
||||
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
|
||||
- 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
|
||||
@@ -161,7 +164,7 @@ jobs:
|
||||
if: ${{ inputs.build_mac_notarized }}
|
||||
needs: [determine_build_number]
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-26
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
|
||||
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
|
||||
@@ -172,6 +175,9 @@ jobs:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Set signing to manual with Developer ID
|
||||
run: |
|
||||
sed -i '' 's/CODE_SIGN_STYLE = Automatic/CODE_SIGN_STYLE = Manual/' Yattee.xcodeproj/project.pbxproj
|
||||
- name: Set build number
|
||||
run: |
|
||||
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
|
||||
@@ -180,176 +186,50 @@ jobs:
|
||||
- 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
|
||||
- run: |
|
||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: |
|
||||
${{ env.ZIP_PATH }}
|
||||
${{ env.DMG_PATH }}
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
if: ${{ inputs.create_release && !cancelled() && !failure() }}
|
||||
if: ${{ inputs.create_release && !cancelled() }}
|
||||
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 }}
|
||||
token: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
- 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
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg
|
||||
commit: ${{ github.ref_name }}
|
||||
tag: ${{ steps.compute_tag.outputs.tag }}
|
||||
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
|
||||
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
|
||||
prerelease: true
|
||||
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 }}
|
||||
|
||||
35
.github/workflows/update-altstore.yml
vendored
@@ -1,16 +1,7 @@
|
||||
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:
|
||||
@@ -22,32 +13,18 @@ jobs:
|
||||
- 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
|
||||
- name: Get version info from latest 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
|
||||
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
|
||||
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq '.assets[] | select(.name == "Yattee.ipa") | .size')
|
||||
echo "IPA_SIZE=${SIZE:-0}" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Update altstore-source.json
|
||||
@@ -56,7 +33,7 @@ jobs:
|
||||
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 }}" \
|
||||
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/Yattee.ipa" \
|
||||
--argjson size "${{ env.IPA_SIZE }}" \
|
||||
'.apps[0].versions = [{
|
||||
version: $version,
|
||||
|
||||
12
AGENTS.md
@@ -13,18 +13,6 @@ This project targets the latest OS versions only - use newest APIs freely withou
|
||||
**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)
|
||||
|
||||
52
CHANGELOG.md
@@ -1,29 +1,37 @@
|
||||
|
||||
|
||||
## What's Changed
|
||||
|
||||
### New Features
|
||||
* Persist media browser view options per source
|
||||
* Add Enable All / Disable All menu to channel notifications settings
|
||||
* Add context menu and swipe actions to related videos in Video Info View
|
||||
* Persist author cache to disk for instant channel info across restarts
|
||||
|
||||
* Add embedded audio/subtitle track selection for multi-track files
|
||||
* Add loading external subtitle files for media-source playback
|
||||
* Add view options to playlists list view
|
||||
* Add search to playlists list view
|
||||
* Show and label WebDAV/SMB video streams in the quality selector
|
||||
### Improvements
|
||||
* Change default player layout settings
|
||||
* Show video thumbnail in mini player during PiP
|
||||
* Update media browser view options sheet layout
|
||||
* Move close video button from toolbar into now playing card in Remote Control
|
||||
|
||||
### Bug Fixes
|
||||
* Fix deleted playlists resurrecting from iCloud after app restart
|
||||
* Fix feed channel filter avatars showing placeholders instead of images
|
||||
* Fix Invidious login failing for passwords with special characters
|
||||
* Fix subscriber count layout shift in Video Info View channel row
|
||||
* Fix Feed tab flashing Content Unavailable View on initial load
|
||||
* Fix blurred background gradient not using DeArrow thumbnail
|
||||
* Fix playlist rows in Channel View not tappable in empty space
|
||||
* Fix lock screen always showing 10s seek regardless of system controls setting
|
||||
* Fix player dismiss gesture stuck after panel dismiss with comments expanded
|
||||
* Fix incomplete playlist loading by paginating through all pages
|
||||
* Fix pull-to-refresh scroll offset not resetting in Instance Browse View
|
||||
* Fix URL scheme UI tests for YouTube deep links and content loading
|
||||
* Fix UI tests for onboarding flow and AddRemoteServer redesign
|
||||
* Fix panscan zoom pushing controls off screen for portrait videos
|
||||
|
||||
* Fix timed links resuming at watch position instead of URL timestamp
|
||||
* Fix autoplay countdown showing in repeat one queue mode
|
||||
* Fix double-tap fullscreen gesture rotating on portrait videos
|
||||
* Fix sending extracted videos (Twitch streams) to other devices
|
||||
* Fix missing thumbnails for videos saved to library
|
||||
* Fix #955: make subtitle appearance settings adjustable on tvOS
|
||||
* Fix #960: scope subscription counts, import/export to active account
|
||||
* Fix #958: switch MPVKit to yattee fork with AV1 VideoToolbox session recovery
|
||||
* Fix startup crash in sideloaded builds without iCloud entitlements
|
||||
* Allow screen sleep during audio-only playback
|
||||
* Apply thumbnail fallback everywhere a single URL was rendered
|
||||
* Stop tracking resume progress for live streams
|
||||
* Label local-file video quality from mpv track info instead of Unknown
|
||||
* Keep macOS player fullscreen when queue advances to different-aspect video
|
||||
* Guard PiP bridge geometry writes against non-finite rects
|
||||
### Development
|
||||
* Add Fastlane config and update release workflow for v2
|
||||
* Add DEV badge on iCloud settings for debug builds
|
||||
* Add git-cliff based changelog generator
|
||||
* Add AltStore source and separate update workflow from release pipeline
|
||||
* Add URL scheme UI tests for deep link navigation
|
||||
* Refactor views
|
||||
|
||||
162
CLAUDE.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Yattee Development Notes
|
||||
|
||||
## Testing Instances
|
||||
|
||||
- **Invidious**: `https://invidious.home.arekf.net/` - Use this instance for testing API calls
|
||||
- **Yattee Server**: `https://main.s.yattee.stream` - Local self-hosted Yattee server for backend testing
|
||||
|
||||
## Related Projects
|
||||
|
||||
### Yattee Server
|
||||
|
||||
Location: `~/Developer/yattee-server`
|
||||
|
||||
A self-hosted API server powered by yt-dlp that provides an Invidious-compatible API for YouTube content. Used as an alternative backend when Invidious/Piped instances are blocked or unavailable.
|
||||
|
||||
**Key features:**
|
||||
- Invidious-compatible API endpoints (`/api/v1/videos`, `/api/v1/channels`, `/api/v1/search`, etc.)
|
||||
- Uses yt-dlp with deno for YouTube JS challenge solving
|
||||
- Returns direct YouTube CDN stream URLs
|
||||
- Optional backing Invidious instance for trending, popular, and search suggestions
|
||||
|
||||
**API endpoints:**
|
||||
- `GET /api/v1/videos/{video_id}` - Video metadata and streams
|
||||
- `GET /api/v1/channels/{channel_id}` - Channel info
|
||||
- `GET /api/v1/channels/{channel_id}/videos` - Channel videos
|
||||
- `GET /api/v1/search?q={query}` - Search
|
||||
- `GET /api/v1/playlists/{playlist_id}` - Playlist info
|
||||
|
||||
**Limitations:**
|
||||
- No comments support
|
||||
- Stream URLs expire after a few hours
|
||||
- Trending/popular/suggestions require backing Invidious instance
|
||||
- scheme name to build is Yattee. use generic platform build instead of specific sim/device id
|
||||
|
||||
## UI Testing with AXe
|
||||
|
||||
The project uses a Ruby/RSpec-based UI testing framework with [AXe](https://github.com/cameroncooke/AXe) for simulator automation and visual regression testing.
|
||||
|
||||
### Running UI Tests
|
||||
|
||||
```bash
|
||||
# Install dependencies (first time)
|
||||
bundle install
|
||||
|
||||
# Run all UI tests
|
||||
./bin/ui-test
|
||||
|
||||
# Skip build (faster iteration)
|
||||
./bin/ui-test --skip-build
|
||||
|
||||
# Keep simulator running after tests
|
||||
./bin/ui-test --keep-simulator
|
||||
|
||||
# Generate new baseline screenshots
|
||||
./bin/ui-test --generate-baseline
|
||||
|
||||
# Run on a different device
|
||||
./bin/ui-test --device "iPad Pro 13-inch (M5)"
|
||||
```
|
||||
|
||||
### Creating Tests for New Features
|
||||
|
||||
When implementing a new feature, create a UI test to verify it works:
|
||||
|
||||
1. **Create a new spec file** in `spec/ui/smoke/`:
|
||||
```ruby
|
||||
# spec/ui/smoke/my_feature_spec.rb
|
||||
require_relative '../spec_helper'
|
||||
|
||||
RSpec.describe 'My New Feature', :smoke do
|
||||
before(:all) do
|
||||
@udid = UITest::Simulator.boot(UITest::Config.device)
|
||||
UITest::App.build(device: UITest::Config.device, skip: UITest::Config.skip_build?)
|
||||
UITest::App.install(udid: @udid)
|
||||
UITest::App.launch(udid: @udid)
|
||||
sleep UITest::Config.app_launch_wait
|
||||
@axe = UITest::Axe.new(@udid)
|
||||
end
|
||||
|
||||
after(:all) do
|
||||
UITest::App.terminate(udid: @udid, silent: true) if @udid
|
||||
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
|
||||
end
|
||||
|
||||
it 'displays the new feature element' do
|
||||
# Navigate to the feature if needed
|
||||
@axe.tap_label('Settings')
|
||||
sleep 1
|
||||
|
||||
# Check for expected elements
|
||||
expect(@axe).to have_text('My New Feature')
|
||||
end
|
||||
|
||||
it 'matches baseline screenshot', :visual do
|
||||
screenshot = @axe.screenshot('my-feature-screen')
|
||||
expect(screenshot).to match_baseline
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
2. **Available AXe actions:**
|
||||
```ruby
|
||||
@axe.tap_label('Button Text') # Tap by accessibility label
|
||||
@axe.tap_id('accessibilityId') # Tap by accessibility identifier
|
||||
@axe.tap_coordinates(x: 100, y: 200)
|
||||
@axe.swipe(start_x: 200, start_y: 400, end_x: 200, end_y: 100)
|
||||
@axe.gesture('scroll-down') # Presets: scroll-up, scroll-down, scroll-left, scroll-right
|
||||
@axe.type('search text') # Type text
|
||||
@axe.home_button # Press home
|
||||
@axe.screenshot('name') # Take screenshot
|
||||
```
|
||||
|
||||
3. **Available matchers:**
|
||||
```ruby
|
||||
expect(@axe).to have_element('AXUniqueId') # Check by accessibility identifier
|
||||
expect(@axe).to have_text('Visible Text') # Check by accessibility label
|
||||
expect(screenshot_path).to match_baseline # Visual comparison (2% threshold)
|
||||
```
|
||||
|
||||
4. **Run with baseline generation:**
|
||||
```bash
|
||||
./bin/ui-test --generate-baseline --keep-simulator
|
||||
```
|
||||
|
||||
5. **Inspect accessibility tree** to find element identifiers:
|
||||
```bash
|
||||
# Boot simulator and launch app first, then:
|
||||
axe describe-ui --udid <SIMULATOR_UDID>
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
spec/
|
||||
├── ui/
|
||||
│ ├── spec_helper.rb # RSpec configuration
|
||||
│ ├── support/
|
||||
│ │ ├── config.rb # Test configuration
|
||||
│ │ ├── simulator.rb # Simulator management
|
||||
│ │ ├── app.rb # App build/install/launch
|
||||
│ │ ├── axe.rb # AXe CLI wrapper
|
||||
│ │ ├── axe_matchers.rb # Custom RSpec matchers
|
||||
│ │ └── screenshot_comparison.rb
|
||||
│ └── smoke/
|
||||
│ └── app_launch_spec.rb # Example test
|
||||
└── ui_snapshots/
|
||||
├── baseline/ # Reference screenshots (by device/iOS version)
|
||||
│ └── iPhone_17_Pro/
|
||||
│ └── iOS_26_2/
|
||||
│ └── app-launch-library.png
|
||||
├── current/ # Current test run screenshots
|
||||
├── diff/ # Visual diff images
|
||||
└── false_positives.yml # Mark expected differences
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `have_text` matcher for most checks - it's more reliable than `have_element` since iOS doesn't always expose accessibility identifiers
|
||||
- Add `sleep 1` after navigation actions to let UI settle
|
||||
- Use `--keep-simulator` during development to speed up iteration
|
||||
- Check `spec/ui_snapshots/diff/` for visual diff images when tests fail
|
||||
- Add entries to `false_positives.yml` for screenshots with expected dynamic content
|
||||
48
Gemfile.lock
@@ -3,14 +3,14 @@ GEM
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.9)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
ast (2.4.3)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1231.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -18,11 +18,11 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.217.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@@ -30,7 +30,7 @@ GEM
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -70,11 +70,11 @@ GEM
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.232.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
@@ -124,9 +124,10 @@ GEM
|
||||
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)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.98.0)
|
||||
google-apis-androidpublisher_v3 (0.96.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
@@ -140,15 +141,15 @@ GEM
|
||||
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-storage_v1 (0.60.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)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
@@ -170,7 +171,7 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.3)
|
||||
json (2.18.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
@@ -188,12 +189,12 @@ GEM
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.11.1)
|
||||
parser (3.3.10.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
plist (3.7.2)
|
||||
prism (1.9.0)
|
||||
public_suffix (7.0.5)
|
||||
public_suffix (7.0.2)
|
||||
racc (1.8.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
@@ -202,7 +203,7 @@ GEM
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rspec (3.13.2)
|
||||
@@ -214,13 +215,13 @@ GEM
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.8)
|
||||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-retry (0.6.2)
|
||||
rspec-core (> 3.3)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.86.0)
|
||||
rubocop (1.84.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -231,7 +232,7 @@ GEM
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
rubocop-ast (1.49.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-rspec (3.9.0)
|
||||
@@ -249,6 +250,7 @@ GEM
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
|
||||
111
README.md
@@ -1,26 +1,113 @@
|
||||
<div align="center">
|
||||
<img src="assets/yattee-logo.png" width="128" height="128" alt="Yattee logo" />
|
||||
<img src="Assets/yattee-logo.png" width="150" height="150" alt="Yattee logo">
|
||||
<h1>Yattee</h1>
|
||||
<p>Privacy oriented video player for iOS, tvOS and macOS<br /></p>
|
||||
<p>Privacy-focused video player for iPhone, iPad, Mac, and Apple TV</p>
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0.en.html)
|
||||
[](https://github.com/yattee/yattee/issues)
|
||||
[](https://github.com/yattee/yattee/pulls)
|
||||
|
||||
|
||||
[](https://yattee.stream/discord)
|
||||
|
||||
</div>
|
||||
|
||||
> **Yattee 2 is in the works!** A new version of the app is being built with a refreshed experience.
|
||||
> It pairs with the new [Yattee Server](https://github.com/yattee/yattee-server) — a self-hosted backend powered by yt-dlp that supports 1000+ sites.
|
||||
> Join the [TestFlight beta](https://yattee.stream/beta2) to try early builds, and check the new documentation site at [docs.yattee.stream](https://docs.yattee.stream) for guides, roadmap and changelog.
|
||||
## Install
|
||||
|
||||
<a href="https://yattee.stream/beta2">
|
||||
<img src="https://developer.apple.com/assets/elements/icons/testflight/testflight-64x64_2x.png" height="40" alt="TestFlight">
|
||||
</a>
|
||||
|
||||
[Join the TestFlight beta](https://yattee.stream/beta2)
|
||||
|
||||
<!-- App Store link coming soon -->
|
||||
|
||||
<!-- TODO: new screenshot assets -->
|
||||
|
||||
## Features
|
||||
* Native user interface built with [SwiftUI](https://developer.apple.com/swiftui/) with customization settings
|
||||
* Player queue and history
|
||||
* Player component with custom controls, gestures and support for 4K playback
|
||||
* Fullscreen, Picture in Picture and background audio playback
|
||||
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
|
||||
|
||||
**Playback**
|
||||
- 4K video with custom MPV-based player (H.264, H.265, VP9, AV1)
|
||||
- Picture in Picture, background audio, fullscreen
|
||||
- Playback queue, history, resume from last position
|
||||
- Chapter navigation, playback speed, subtitles and captions
|
||||
- Gesture controls (seek, volume, brightness)
|
||||
- Seek preview with storyboards
|
||||
|
||||
**Content Sources**
|
||||
- YouTube via Invidious, Piped, or self-hosted Yattee Server
|
||||
- PeerTube instances (federated video)
|
||||
- Local files, SMB network shares, WebDAV servers
|
||||
|
||||
**Integrations**
|
||||
- [SponsorBlock](https://sponsor.ajay.app/) (configurable skip categories)
|
||||
- [DeArrow](https://dearrow.ajay.app/) (crowdsourced titles and thumbnails)
|
||||
- [Return YouTube Dislike](https://returnyoutubedislike.com/)
|
||||
|
||||
**Privacy**
|
||||
- No tracking, no ads, no account required
|
||||
- All traffic goes through your chosen instances
|
||||
|
||||
**Library**
|
||||
- Subscriptions with per-channel notifications
|
||||
- Bookmarks with tags and notes, playlists, watch history
|
||||
- Unified search across all configured sources
|
||||
- Import/export subscriptions (JSON, CSV, OPML)
|
||||
|
||||
**Downloads & Sync**
|
||||
- Offline video and audio downloads with quality selection
|
||||
- iCloud sync for bookmarks, subscriptions, history, and settings across devices
|
||||
- Handoff continuity between iPhone, iPad, Mac, and Apple TV
|
||||
|
||||
**Apple Ecosystem**
|
||||
- iOS 18+ / macOS 15+ / tvOS 18+
|
||||
- Native SwiftUI on every platform
|
||||
- Customizable home layout, accent colors, player controls, and app icon
|
||||
- Clipboard URL detection and deep linking (`yattee://`)
|
||||
- Remote control between devices on your network
|
||||
|
||||
## Yattee Server
|
||||
|
||||
A self-hosted backend powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp) that gives Yattee superpowers.
|
||||
|
||||
- **Direct stream URLs** — gets fresh YouTube CDN URLs, bypassing Invidious/Piped blocks and rate limits
|
||||
- **Play from 1000+ sites** — Vimeo, TikTok, Twitch, Dailymotion, Twitter/X, and anything else yt-dlp supports
|
||||
- **Invidious-compatible API** — drop-in replacement, works alongside existing Invidious/Piped instances
|
||||
- **Self-hosted & private** — run on your own hardware, no data leaves your network
|
||||
- **Fast parallel streaming** — yt-dlp parallel downloading streams video while it downloads
|
||||
- **Admin panel** — web UI for settings, credentials, and monitoring
|
||||
- **Docker ready** — single container deployment
|
||||
|
||||
Check out the [yattee-server](https://github.com/yattee/yattee-server) repository to get started.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
|
||||
- [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
|
||||
- [Features](https://github.com/yattee/yattee/wiki/Features)
|
||||
- [FAQ](https://github.com/yattee/yattee/wiki/FAQ)
|
||||
- [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
|
||||
- [Tips](https://github.com/yattee/yattee/wiki/Tips)
|
||||
- [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
|
||||
- [Donations](https://github.com/yattee/yattee/wiki/Donations)
|
||||
|
||||
## Contributing
|
||||
|
||||
Browse the [issues](https://github.com/yattee/yattee/issues) list or open a new one to discuss your idea. Every contribution is welcome.
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for developer setup and project architecture.
|
||||
|
||||
Join [Discord](https://yattee.stream/discord) or the [Matrix channel](https://matrix.to/#/#yattee:matrix.org) if you need advice or want to discuss the project.
|
||||
|
||||
## Translations
|
||||
|
||||
Help make Yattee accessible to everyone by contributing translations.
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/yattee/">
|
||||
<img src="https://hosted.weblate.org/widgets/yattee/-/localizable-strings/multi-auto.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
Localization hosting provided by [Weblate](https://weblate.org/en/).
|
||||
|
||||
## License
|
||||
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
Yattee is shared under the [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
|
||||
|
||||
@@ -7,14 +7,12 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = 370E71972F9A1A41000E04B2 /* Sparkle */; };
|
||||
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
|
||||
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
|
||||
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
|
||||
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF3002EF21783002C1CD7 /* MPVKit-GPL */; };
|
||||
37BA19A62EE4DFEE001D7B0F /* Yattee2.icon in Resources */ = {isa = PBXBuildFile; fileRef = 37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */; };
|
||||
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */; platformFilters = (tvos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -25,13 +23,6 @@
|
||||
remoteGlobalIDString = 37BA19AA2EE4EB7F001D7B0F;
|
||||
remoteInfo = YatteeShareExtension;
|
||||
};
|
||||
37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 37C0AAAA00000000AAAA0001;
|
||||
remoteInfo = YatteeTopShelf;
|
||||
};
|
||||
37D0B2982EDB23BD00B9C4ED /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
|
||||
@@ -49,7 +40,6 @@
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */,
|
||||
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -60,7 +50,6 @@
|
||||
372D1A272EDB163800F58F7A /* Yattee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Yattee.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Yattee2.icon; sourceTree = "<group>"; };
|
||||
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeTopShelf.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = YatteeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -68,7 +57,6 @@
|
||||
37A11F692EDC5DD700213864 /* Exceptions for "Yattee" folder in "Yattee" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
"Info-tvOS.plist",
|
||||
Info.plist,
|
||||
);
|
||||
target = 372D1A262EDB163800F58F7A /* Yattee */;
|
||||
@@ -80,13 +68,6 @@
|
||||
);
|
||||
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
|
||||
};
|
||||
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -106,14 +87,6 @@
|
||||
path = YatteeShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */,
|
||||
);
|
||||
path = YatteeTopShelf;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
37D0B2952EDB23BD00B9C4ED /* YatteeTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = YatteeTests;
|
||||
@@ -126,7 +99,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */,
|
||||
37767AB32F05766100D248FC /* Nuke in Frameworks */,
|
||||
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
|
||||
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
|
||||
@@ -141,13 +113,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37C0AAAA00000000AAAA0005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37D0B2912EDB23BD00B9C4ED /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -164,7 +129,6 @@
|
||||
372D1A292EDB163800F58F7A /* Yattee */,
|
||||
37D0B2952EDB23BD00B9C4ED /* YatteeTests */,
|
||||
37BA19AC2EE4EB7F001D7B0F /* YatteeShareExtension */,
|
||||
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
|
||||
372D1A282EDB163800F58F7A /* Products */,
|
||||
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */,
|
||||
);
|
||||
@@ -176,7 +140,6 @@
|
||||
372D1A272EDB163800F58F7A /* Yattee.app */,
|
||||
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */,
|
||||
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */,
|
||||
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -192,13 +155,11 @@
|
||||
372D1A242EDB163800F58F7A /* Frameworks */,
|
||||
372D1A252EDB163800F58F7A /* Resources */,
|
||||
37BA19BA2EE4EB7F001D7B0F /* Embed Foundation Extensions */,
|
||||
37C0FEE100000000FEE100A1 /* Strip Sparkle (non-DeveloperID) */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
37BA19B42EE4EB7F001D7B0F /* PBXTargetDependency */,
|
||||
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
372D1A292EDB163800F58F7A /* Yattee */,
|
||||
@@ -209,7 +170,6 @@
|
||||
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
|
||||
37767AB22F05766100D248FC /* Nuke */,
|
||||
37767AB42F05766100D248FC /* NukeUI */,
|
||||
370E71972F9A1A41000E04B2 /* Sparkle */,
|
||||
);
|
||||
productName = Yattee;
|
||||
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
|
||||
@@ -237,28 +197,6 @@
|
||||
productReference = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */;
|
||||
buildPhases = (
|
||||
37C0AAAA00000000AAAA0004 /* Sources */,
|
||||
37C0AAAA00000000AAAA0005 /* Frameworks */,
|
||||
37C0AAAA00000000AAAA0006 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
|
||||
);
|
||||
name = YatteeTopShelf;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = YatteeTopShelf;
|
||||
productReference = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */;
|
||||
productType = "com.apple.product-type.tv-app-extension";
|
||||
};
|
||||
37D0B2932EDB23BD00B9C4ED /* YatteeTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 37D0B29A2EDB23BD00B9C4ED /* Build configuration list for PBXNativeTarget "YatteeTests" */;
|
||||
@@ -298,9 +236,6 @@
|
||||
37BA19AA2EE4EB7F001D7B0F = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
37C0AAAA00000000AAAA0001 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
37D0B2932EDB23BD00B9C4ED = {
|
||||
CreatedOnToolsVersion = 26.1.1;
|
||||
TestTargetID = 372D1A262EDB163800F58F7A;
|
||||
@@ -319,7 +254,6 @@
|
||||
packageReferences = (
|
||||
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
|
||||
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
|
||||
@@ -329,7 +263,6 @@
|
||||
372D1A262EDB163800F58F7A /* Yattee */,
|
||||
37D0B2932EDB23BD00B9C4ED /* YatteeTests */,
|
||||
37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */,
|
||||
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -350,13 +283,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37C0AAAA00000000AAAA0006 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37D0B2922EDB23BD00B9C4ED /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -366,28 +292,6 @@
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
37C0FEE100000000FEE100A1 /* Strip Sparkle (non-DeveloperID) */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Strip Sparkle (non-DeveloperID)";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [ \"${CONFIGURATION}\" = \"Release-DeveloperID\" ] || [ \"${CONFIGURATION}\" = \"Debug\" ]; then\n echo \"Skipping Sparkle strip (config=${CONFIGURATION})\"\n exit 0\nfi\nFW=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework\"\nif [ -d \"$FW\" ]; then\n echo \"Stripping Sparkle.framework (config=${CONFIGURATION})\"\n chflags -R nouchg \"$FW\" 2>/dev/null || true\n chmod -R u+w \"$FW\" 2>/dev/null || true\n TMP=\"${FW}.stripping.$$\"\n if mv \"$FW\" \"$TMP\" 2>/dev/null; then\n TARGET=\"$TMP\"\n else\n TARGET=\"$FW\"\n fi\n for i in 1 2 3 4 5; do\n rm -rf \"$TARGET\" && break\n sleep 0.2\n done\n if [ -e \"$TARGET\" ]; then\n echo \"warning: failed to fully remove $TARGET\" >&2\n exit 1\n fi\nfi\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
372D1A232EDB163800F58F7A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
@@ -403,13 +307,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37C0AAAA00000000AAAA0004 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37D0B2902EDB23BD00B9C4ED /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -426,14 +323,6 @@
|
||||
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
|
||||
targetProxy = 37BA19B32EE4EB7F001D7B0F /* PBXContainerItemProxy */;
|
||||
};
|
||||
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilters = (
|
||||
tvos,
|
||||
);
|
||||
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
|
||||
targetProxy = 37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */;
|
||||
};
|
||||
37D0B2992EDB23BD00B9C4ED /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 372D1A262EDB163800F58F7A /* Yattee */;
|
||||
@@ -570,7 +459,7 @@
|
||||
AUTOMATION_APPLE_EVENTS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
CURRENT_PROJECT_VERSION = 252;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -587,8 +476,7 @@
|
||||
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
@@ -601,11 +489,8 @@
|
||||
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
|
||||
);
|
||||
INFOPLIST_FILE = Yattee/Info.plist;
|
||||
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
|
||||
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
@@ -654,9 +539,8 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
AUTOMATION_APPLE_EVENTS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
CURRENT_PROJECT_VERSION = 252;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
@@ -673,8 +557,7 @@
|
||||
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"GLES_SILENCE_DEPRECATION=1",
|
||||
"GL_SILENCE_DEPRECATION=1",
|
||||
@@ -685,11 +568,8 @@
|
||||
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
|
||||
);
|
||||
INFOPLIST_FILE = Yattee/Info.plist;
|
||||
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
|
||||
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
@@ -706,10 +586,6 @@
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
"OTHER_LDFLAGS[sdk=macosx*]" = (
|
||||
"$(inherited)",
|
||||
"-Wl,-dead_strip_dylibs",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -735,249 +611,12 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
376D70452F996E0700255B09 /* Release-DeveloperID */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = "Release-DeveloperID";
|
||||
};
|
||||
376D70462F996E0700255B09 /* Release-DeveloperID */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Yattee2;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
AUTOMATION_APPLE_EVENTS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"GLES_SILENCE_DEPRECATION=1",
|
||||
"GL_SILENCE_DEPRECATION=1",
|
||||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/Libsmbclient.framework/Headers/samba-4.0",
|
||||
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
|
||||
);
|
||||
INFOPLIST_FILE = Yattee/Info.plist;
|
||||
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
|
||||
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
|
||||
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
|
||||
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES;
|
||||
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
|
||||
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
|
||||
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "SPARKLE $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/Yattee/Yattee-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
XROS_DEPLOYMENT_TARGET = 26.1;
|
||||
};
|
||||
name = "Release-DeveloperID";
|
||||
};
|
||||
376D70472F996E0700255B09 /* Release-DeveloperID */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.YatteeTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Yattee.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Yattee";
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
XROS_DEPLOYMENT_TARGET = 26.1;
|
||||
};
|
||||
name = "Release-DeveloperID";
|
||||
};
|
||||
376D70482F996E0700255B09 /* Release-DeveloperID */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Share Extension";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = "Release-DeveloperID";
|
||||
};
|
||||
376D70492F996E0700255B09 /* Release-DeveloperID */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_ENTRY_POINT = _NSExtensionMain;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = "Release-DeveloperID";
|
||||
};
|
||||
37BA19B72EE4EB7F001D7B0F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
CURRENT_PROJECT_VERSION = 252;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
||||
@@ -1008,7 +647,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
CURRENT_PROJECT_VERSION = 252;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
||||
@@ -1035,81 +674,12 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
37C0AAAA00000000AAAA000A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_ENTRY_POINT = _NSExtensionMain;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
37C0AAAA00000000AAAA000B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_ENTRY_POINT = _NSExtensionMain;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
37D0B29B2EDB23BD00B9C4ED /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
CURRENT_PROJECT_VERSION = 252;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1138,7 +708,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 269;
|
||||
CURRENT_PROJECT_VERSION = 252;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1170,7 +740,6 @@
|
||||
buildConfigurations = (
|
||||
372D1A302EDB163900F58F7A /* Debug */,
|
||||
372D1A312EDB163900F58F7A /* Release */,
|
||||
376D70452F996E0700255B09 /* Release-DeveloperID */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -1180,7 +749,6 @@
|
||||
buildConfigurations = (
|
||||
372D1A332EDB163900F58F7A /* Debug */,
|
||||
372D1A342EDB163900F58F7A /* Release */,
|
||||
376D70462F996E0700255B09 /* Release-DeveloperID */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -1190,17 +758,6 @@
|
||||
buildConfigurations = (
|
||||
37BA19B72EE4EB7F001D7B0F /* Debug */,
|
||||
37BA19B82EE4EB7F001D7B0F /* Release */,
|
||||
376D70482F996E0700255B09 /* Release-DeveloperID */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
37C0AAAA00000000AAAA000A /* Debug */,
|
||||
37C0AAAA00000000AAAA000B /* Release */,
|
||||
376D70492F996E0700255B09 /* Release-DeveloperID */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -1210,7 +767,6 @@
|
||||
buildConfigurations = (
|
||||
37D0B29B2EDB23BD00B9C4ED /* Debug */,
|
||||
37D0B29C2EDB23BD00B9C4ED /* Release */,
|
||||
376D70472F996E0700255B09 /* Release-DeveloperID */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -1218,14 +774,6 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.9.1;
|
||||
};
|
||||
};
|
||||
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/kean/Nuke";
|
||||
@@ -1236,20 +784,15 @@
|
||||
};
|
||||
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/yattee/MPVKit.git";
|
||||
repositoryURL = "https://github.com/mpvkit/MPVKit.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 1.0.1;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.41.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
370E71972F9A1A41000E04B2 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
37767AB22F05766100D248FC /* Nuke */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "3936a4172c132dff2ced455a1771e12f4759645440fa59d5b93f5a78b68d3ee4",
|
||||
"originHash" : "50421f80aba6d558399198148743d5d479edb3f6cc10024acd55828f8cf63959",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/yattee/MPVKit.git",
|
||||
"location" : "https://github.com/mpvkit/MPVKit.git",
|
||||
"state" : {
|
||||
"revision" : "671d3484b00f438407564573a4e188631d81bc90",
|
||||
"version" : "1.0.1"
|
||||
"revision" : "613c0ccc3acf70e136aaff880a9b5fe8fdfaf5b8",
|
||||
"version" : "0.41.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -15,17 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kean/Nuke",
|
||||
"state" : {
|
||||
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
|
||||
"version" : "12.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
|
||||
"version" : "2.9.4"
|
||||
"revision" : "0ead44350d2737db384908569c012fe67c421e4d",
|
||||
"version" : "12.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id" : "8948cba2-509e-4ad8-bcb9-592de5bff7d1",
|
||||
"targets" : [
|
||||
{
|
||||
"id" : "9FC1C0A4-CE29-437C-BB50-C93FE779B691",
|
||||
"name" : "Yattee"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,6 +21,20 @@
|
||||
ReferencedContainer = "container:Yattee.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "37BA19AA2EE4EB7F001D7B0F"
|
||||
BuildableName = "YatteeShareExtension.appex"
|
||||
BlueprintName = "YatteeShareExtension"
|
||||
ReferencedContainer = "container:Yattee.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
|
||||
@@ -78,11 +78,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
LoggingService.shared.logCloudKit("Requesting remote notification registration...")
|
||||
NSApplication.shared.registerForRemoteNotifications()
|
||||
|
||||
if let rawValue = UserDefaults.standard.string(forKey: "appIcon"),
|
||||
let icon = AppIcon(rawValue: rawValue) {
|
||||
SettingsManager.applyMacAppIcon(icon)
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 95 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tvOS-AppStore-1280x768.png",
|
||||
"idiom" : "tv"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 137 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"layers" : [
|
||||
{
|
||||
"filename" : "Front.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Middle.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Back.imagestacklayer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tvOS-AppStore-1280x768.png",
|
||||
"idiom" : "tv"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 137 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"images" : [],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tvOS-Small@1x.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "tvOS-Small@2x.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 65 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"layers" : [
|
||||
{
|
||||
"filename" : "Front.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Middle.imagestacklayer"
|
||||
},
|
||||
{
|
||||
"filename" : "Back.imagestacklayer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tvOS-Small@1x.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "tvOS-Small@2x.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 65 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"images" : [],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"assets" : [
|
||||
{
|
||||
"filename" : "App Icon - App Store.imagestack",
|
||||
"idiom" : "tv",
|
||||
"role" : "primary-app-icon",
|
||||
"size" : "1280x768"
|
||||
},
|
||||
{
|
||||
"filename" : "App Icon.imagestack",
|
||||
"idiom" : "tv",
|
||||
"role" : "primary-app-icon",
|
||||
"size" : "400x240"
|
||||
},
|
||||
{
|
||||
"filename" : "Top Shelf Image Wide.imageset",
|
||||
"idiom" : "tv",
|
||||
"role" : "top-shelf-image-wide",
|
||||
"size" : "2320x720"
|
||||
},
|
||||
{
|
||||
"filename" : "Top Shelf Image.imageset",
|
||||
"idiom" : "tv",
|
||||
"role" : "top-shelf-image",
|
||||
"size" : "1920x720"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "TopShelf-Wide.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "TopShelf.png",
|
||||
"idiom" : "tv",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "tv",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -57,34 +57,11 @@ struct ContentView: View {
|
||||
// Mini player overlay (macOS only)
|
||||
#if os(macOS)
|
||||
miniPlayerOverlay(appEnvironment: appEnvironment)
|
||||
|
||||
// Inline expanded player: presented as a full-window overlay
|
||||
// (no native sheet — a window with an attached sheet can't go
|
||||
// fullscreen, so fullscreen is just the window's native toggle).
|
||||
if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) {
|
||||
ZStack {
|
||||
Color.black
|
||||
ExpandedPlayerSheet()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.transition(.opacity)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
// Registers the hosting window so the inline overlay targets the real
|
||||
// main window even when another window (e.g. Settings) has focus.
|
||||
.background(MainContentWindowReader())
|
||||
.animation(.easeInOut(duration: 0.2), value: isInlinePlayerOverlayActive(appEnvironment: appEnvironment))
|
||||
.onChange(of: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) { _, active in
|
||||
if active {
|
||||
ExpandedPlayerWindowManager.shared.beginInlineOverlay()
|
||||
} else {
|
||||
ExpandedPlayerWindowManager.shared.endInlineOverlay()
|
||||
}
|
||||
}
|
||||
.onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in
|
||||
if appEnvironment.settingsManager.macPlayerSeparateWindow {
|
||||
if appEnvironment.settingsManager.macPlayerMode.usesWindow {
|
||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||
}
|
||||
}
|
||||
@@ -93,37 +70,32 @@ struct ContentView: View {
|
||||
ExpandedPlayerWindowManager.shared.hide()
|
||||
}
|
||||
}
|
||||
.onChange(of: appEnvironment.settingsManager.macPlayerSeparateWindow) { _, separateWindow in
|
||||
.onChange(of: appEnvironment.settingsManager.macPlayerMode) { oldMode, newMode in
|
||||
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
|
||||
|
||||
if separateWindow {
|
||||
// Inline overlay → separate window: the overlay unmounts via the
|
||||
// presentation condition; present the window directly.
|
||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||
} else {
|
||||
// Separate window → inline overlay: hide the window so the overlay
|
||||
// (isPlayerExpanded && !separateWindow) takes over.
|
||||
if oldMode.usesWindow && newMode.usesWindow {
|
||||
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: newMode.isFloating)
|
||||
} else if oldMode.usesWindow && !newMode.usesWindow {
|
||||
ExpandedPlayerWindowManager.shared.hide(animated: false)
|
||||
} else if !oldMode.usesWindow && newMode.usesWindow {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
if appEnvironment.navigationCoordinator.isPlayerExpanded {
|
||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: appEnvironment.settingsManager.macPlayerFloating) { _, floating in
|
||||
guard appEnvironment.navigationCoordinator.isPlayerExpanded,
|
||||
appEnvironment.settingsManager.macPlayerSeparateWindow else { return }
|
||||
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented },
|
||||
set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 }
|
||||
get: {
|
||||
appEnvironment.navigationCoordinator.isPlayerExpanded &&
|
||||
!appEnvironment.settingsManager.macPlayerMode.usesWindow
|
||||
},
|
||||
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
|
||||
)) {
|
||||
QueueManagementSheet()
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented },
|
||||
set: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = $0 }
|
||||
)) {
|
||||
if let video = appEnvironment.playerService.state.currentVideo {
|
||||
PlaylistSelectorSheet(video: video)
|
||||
}
|
||||
ExpandedPlayerSheet()
|
||||
.frame(minWidth: 640, minHeight: 480)
|
||||
.presentationSizing(.fitted)
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
@@ -139,14 +111,6 @@ struct ContentView: View {
|
||||
private func presentExpandedPlayerWindow(appEnvironment: AppEnvironment) {
|
||||
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
|
||||
}
|
||||
|
||||
/// Whether the inline (non-separate-window) expanded player overlay should
|
||||
/// be presented. Shared by the overlay mount and the begin/end onChange so
|
||||
/// the two can't drift apart.
|
||||
private func isInlinePlayerOverlayActive(appEnvironment: AppEnvironment) -> Bool {
|
||||
appEnvironment.navigationCoordinator.isPlayerExpanded &&
|
||||
!appEnvironment.settingsManager.macPlayerSeparateWindow
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@@ -155,17 +119,14 @@ struct ContentView: View {
|
||||
let playerState = appEnvironment.playerService.state
|
||||
let hasActiveVideo = playerState.currentVideo != nil
|
||||
let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded
|
||||
// The expanded player is a separate window in window mode, so keep the capsule
|
||||
// visible alongside it. The inline overlay covers the window, so hide it there.
|
||||
let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow
|
||||
|
||||
if hasActiveVideo && (!isExpanded || usesWindow) {
|
||||
if hasActiveVideo && !isExpanded {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
MiniPlayerView()
|
||||
}
|
||||
// Float the capsule above the bottom edge (macOS uses a sidebar, not a tab bar)
|
||||
.padding(.bottom, 16)
|
||||
// Add padding for tab bar
|
||||
.padding(.bottom, 49)
|
||||
// Use move-only transition (no opacity) to prevent thumbnail flash during collapse
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.spring(response: 0.3), value: hasActiveVideo)
|
||||
|
||||
@@ -47,7 +47,7 @@ final class AppEnvironment {
|
||||
let handoffManager: HandoffManager
|
||||
let invidiousCredentialsManager: InvidiousCredentialsManager
|
||||
let pipedCredentialsManager: PipedCredentialsManager
|
||||
let basicAuthCredentialsManager: BasicAuthCredentialsManager
|
||||
let yatteeServerCredentialsManager: YatteeServerCredentialsManager
|
||||
let homeInstanceCache: HomeInstanceCache
|
||||
let invidiousAPI: InvidiousAPI
|
||||
let pipedAPI: PipedAPI
|
||||
@@ -56,28 +56,6 @@ final class AppEnvironment {
|
||||
let legacyMigrationService: LegacyDataMigrationService
|
||||
let sourcesSettings: SourcesSettings
|
||||
|
||||
/// Center-section settings of the active player controls preset, cached for
|
||||
/// synchronous access. Menu bar commands read seek durations from here since
|
||||
/// they cannot await the layout service actor.
|
||||
private(set) var activeControlsCenterSettings: CenterSectionSettings = .default
|
||||
|
||||
@ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = []
|
||||
|
||||
// MARK: - Shared Instance
|
||||
|
||||
/// The single, process-wide app environment.
|
||||
///
|
||||
/// SwiftUI may evaluate a `@State` property's default-value autoclosure more
|
||||
/// than once (it keeps only the first result but still runs the side effects
|
||||
/// of the discarded instances). Constructing `AppEnvironment` more than once
|
||||
/// would create multiple `DownloadManager`s — and therefore multiple
|
||||
/// background `URLSession`s registered under the same identifier — causing
|
||||
/// download-completion delegate callbacks to be delivered to an instance
|
||||
/// whose `activeDownloads` is empty (the finished file is then dropped).
|
||||
/// Referencing this `static let` from the App's `@State` guarantees exactly
|
||||
/// one instance for the lifetime of the process.
|
||||
static let shared = AppEnvironment()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
@@ -104,12 +82,12 @@ final class AppEnvironment {
|
||||
instances.setSettingsManager(settings)
|
||||
self.instancesManager = instances
|
||||
|
||||
// Initialize Basic Auth Credentials Manager early (needed for ContentService)
|
||||
let basicAuthCreds = BasicAuthCredentialsManager()
|
||||
basicAuthCreds.settingsManager = settings
|
||||
self.basicAuthCredentialsManager = basicAuthCreds
|
||||
// Initialize Yattee Server Credentials Manager early (needed for ContentService)
|
||||
let yatteeServerCreds = YatteeServerCredentialsManager()
|
||||
yatteeServerCreds.settingsManager = settings
|
||||
self.yatteeServerCredentialsManager = yatteeServerCreds
|
||||
|
||||
let contentSvc = ContentService(httpClient: client, basicAuthCredentialsManager: basicAuthCreds)
|
||||
let contentSvc = ContentService(httpClient: client, yatteeServerCredentialsManager: yatteeServerCreds)
|
||||
self.contentService = contentSvc
|
||||
self.instanceDetector = InstanceDetector(httpClient: client)
|
||||
self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator()
|
||||
@@ -236,7 +214,7 @@ final class AppEnvironment {
|
||||
// Wire up SMB client to check if SMB playback is active
|
||||
// This prevents crashes from concurrent libsmbclient usage
|
||||
let smbClientRef = self.smbClient
|
||||
Task { [weak player] in
|
||||
Task {
|
||||
await smbClientRef.setPlaybackActiveCallback { [weak player] in
|
||||
player?.state.isSMBPlaybackActive ?? false
|
||||
}
|
||||
@@ -299,11 +277,7 @@ final class AppEnvironment {
|
||||
// Initialize Legacy Migration Service
|
||||
self.legacyMigrationService = LegacyDataMigrationService(
|
||||
instancesManager: instances,
|
||||
basicAuthCredentialsManager: basicAuthCreds,
|
||||
invidiousCredentialsManager: invidiousCreds,
|
||||
pipedCredentialsManager: pipedCreds,
|
||||
invidiousAPI: invidiousAPI,
|
||||
pipedAPI: pipedAPI
|
||||
httpClient: client
|
||||
)
|
||||
|
||||
// Initialize Sources Settings
|
||||
@@ -318,25 +292,9 @@ final class AppEnvironment {
|
||||
// Wire up player controls layout service to player service (for preset-based settings)
|
||||
player.setPlayerControlsLayoutService(layoutService)
|
||||
|
||||
// Cache active preset's center settings and keep them in sync
|
||||
Task { await self.refreshActiveControlsSettings() }
|
||||
for name: Notification.Name in [.playerControlsActivePresetDidChange, .playerControlsPresetsDidChange] {
|
||||
controlsSettingsObservers.append(
|
||||
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.refreshActiveControlsSettings()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Set up circular dependencies after all properties are initialized
|
||||
bgRefreshManager.setAppEnvironment(self)
|
||||
|
||||
// Backfill credential flags for instances created before the flags
|
||||
// existed, so existing setups publish them to iCloud without a re-login
|
||||
backfillCredentialFlags()
|
||||
|
||||
// Log device capabilities on startup for debugging
|
||||
HardwareCapabilities.shared.logCapabilities()
|
||||
|
||||
@@ -373,12 +331,6 @@ final class AppEnvironment {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Refreshes the cached center-section settings from the active player controls preset.
|
||||
func refreshActiveControlsSettings() async {
|
||||
let layout = await playerControlsLayoutService.activeLayout()
|
||||
activeControlsCenterSettings = layout.centerSettings
|
||||
}
|
||||
|
||||
/// Updates the HTTP client's User-Agent configuration from current settings.
|
||||
/// Call this after changing User-Agent related settings.
|
||||
func updateUserAgent() {
|
||||
@@ -422,53 +374,12 @@ final class AppEnvironment {
|
||||
case .piped:
|
||||
return pipedCredentialsManager
|
||||
case .yatteeServer:
|
||||
return basicAuthCredentialsManager
|
||||
return yatteeServerCredentialsManager
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Backfills `usesBasicAuth`/`usesAccountLogin` on instances that were created
|
||||
/// before these flags existed, based on credentials currently in the Keychain.
|
||||
/// Only ever sets the flags — a missing Keychain entry (e.g. right after a
|
||||
/// reinstall) must not clear a previously recorded flag.
|
||||
private func backfillCredentialFlags() {
|
||||
for instance in instancesManager.instances {
|
||||
if !instance.usesBasicAuth,
|
||||
instance.supportsHTTPBasicAuthProxy,
|
||||
basicAuthCredentialsManager.hasCredentials(for: instance) {
|
||||
instancesManager.setUsesBasicAuth(true, for: instance)
|
||||
}
|
||||
|
||||
if !instance.usesAccountLogin,
|
||||
instance.supportsAuthentication,
|
||||
let manager = credentialsManager(for: instance),
|
||||
manager.isLoggedIn(for: instance) {
|
||||
instancesManager.setUsesAccountLogin(true, for: instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an instance is known to require credentials that are missing from
|
||||
/// the Keychain (e.g. after reinstalling the app and importing sources from iCloud).
|
||||
/// - Yattee Server always requires basic auth; other types require it when
|
||||
/// `usesBasicAuth` was recorded.
|
||||
/// - Invidious/Piped account logins are checked when `usesAccountLogin` was recorded.
|
||||
func needsCredentials(for instance: Instance) -> Bool {
|
||||
if instance.type == .yatteeServer || instance.usesBasicAuth,
|
||||
!basicAuthCredentialsManager.hasCredentials(for: instance) {
|
||||
return true
|
||||
}
|
||||
|
||||
if instance.usesAccountLogin,
|
||||
let manager = credentialsManager(for: instance),
|
||||
!manager.isLoggedIn(for: instance) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Preview/Testing Support
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum AppGroup {
|
||||
static let identifier = "group.stream.yattee.app.shared"
|
||||
|
||||
/// UserDefaults key holding an ordered [String] of enabled TopShelfSection raw values.
|
||||
static let enabledSectionsKey = "topShelf.enabledSections"
|
||||
|
||||
static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: identifier) ?? .standard
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,3 @@ struct FileCommands: Commands {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
/// App menu Settings… item that opens the dedicated Settings window.
|
||||
struct SettingsWindowMenuItem: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
Button(String(localized: "menu.app.settings")) {
|
||||
openWindow(id: "settings")
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -145,17 +145,12 @@ final class InstancesManager {
|
||||
}
|
||||
|
||||
saveInstances()
|
||||
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
|
||||
}
|
||||
|
||||
func update(_ instance: Instance) {
|
||||
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
|
||||
instances[index] = instance
|
||||
saveInstances()
|
||||
// Editing a source can change the proxy answer (URL change, toggle
|
||||
// flip). Drop the cached auto-detect verdict so the next playback
|
||||
// re-probes.
|
||||
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,26 +169,6 @@ final class InstancesManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records whether an instance sits behind HTTP Basic Auth.
|
||||
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
|
||||
/// detected after a reinstall.
|
||||
func setUsesBasicAuth(_ value: Bool, for instance: Instance) {
|
||||
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
|
||||
instances[index].usesBasicAuth != value else { return }
|
||||
instances[index].usesBasicAuth = value
|
||||
saveInstances()
|
||||
}
|
||||
|
||||
/// Records whether the user has logged into an account on an instance.
|
||||
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
|
||||
/// detected after a reinstall.
|
||||
func setUsesAccountLogin(_ value: Bool, for instance: Instance) {
|
||||
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
|
||||
instances[index].usesAccountLogin != value else { return }
|
||||
instances[index].usesAccountLogin = value
|
||||
saveInstances()
|
||||
}
|
||||
|
||||
/// Sets the given instance as the primary (first) instance.
|
||||
func setPrimary(_ instance: Instance) {
|
||||
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// Notifications.swift
|
||||
// Yattee
|
||||
//
|
||||
// App-wide notification names.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let showSettings = Notification.Name("showSettings")
|
||||
static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet")
|
||||
static let openDescriptionLink = Notification.Name("openDescriptionLink")
|
||||
/// Posted when a URL shortener (bit.ly, etc.) has been resolved to an
|
||||
/// ambiguous destination — the app isn't certain it can play it, so the
|
||||
/// user is prompted whether to try opening it in Yattee or in the browser.
|
||||
/// `object` is the resolved `URL`.
|
||||
static let promptResolvedShortLink = Notification.Name("promptResolvedShortLink")
|
||||
/// Posted when a tapped link is not confidently a video (no YouTube /
|
||||
/// PeerTube / direct-media match) but could potentially be extracted via
|
||||
/// the Yattee server / yt-dlp. User is prompted whether to try extracting
|
||||
/// or open it in the system browser instead.
|
||||
/// `object` is the candidate `URL`.
|
||||
static let promptAmbiguousExternalLink = Notification.Name("promptAmbiguousExternalLink")
|
||||
}
|
||||
@@ -50,10 +50,10 @@ struct PlaybackCommands: Commands {
|
||||
Divider()
|
||||
|
||||
// Seeking
|
||||
seekBackwardButton
|
||||
seekForwardButton
|
||||
secondarySeekBackwardButton
|
||||
secondarySeekForwardButton
|
||||
seekBackward10Button
|
||||
seekForward10Button
|
||||
seekBackward30Button
|
||||
seekForward30Button
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -121,59 +121,41 @@ struct PlaybackCommands: Commands {
|
||||
|
||||
// MARK: - Seeking
|
||||
|
||||
/// Seek durations follow the active player controls preset, matching the
|
||||
/// in-player arrow key shortcuts.
|
||||
private var seekBackwardSeconds: Int {
|
||||
appEnvironment.activeControlsCenterSettings.seekBackwardSeconds
|
||||
}
|
||||
|
||||
private var seekForwardSeconds: Int {
|
||||
appEnvironment.activeControlsCenterSettings.seekForwardSeconds
|
||||
}
|
||||
|
||||
private var seekBackwardButton: some View {
|
||||
private var seekBackward10Button: some View {
|
||||
Button {
|
||||
playerService.seekBackward(by: TimeInterval(seekBackwardSeconds))
|
||||
playerService.seekBackward(by: 10)
|
||||
} label: {
|
||||
Text(String(localized: "menu.playback.seekBackward \(seekBackwardSeconds)"))
|
||||
Text(String(localized: "menu.playback.seekBackward10"))
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: [.command])
|
||||
.disabled(!hasActiveVideo)
|
||||
}
|
||||
|
||||
private var seekForwardButton: some View {
|
||||
private var seekForward10Button: some View {
|
||||
Button {
|
||||
playerService.seekForward(by: TimeInterval(seekForwardSeconds))
|
||||
playerService.seekForward(by: 10)
|
||||
} label: {
|
||||
Text(String(localized: "menu.playback.seekForward \(seekForwardSeconds)"))
|
||||
Text(String(localized: "menu.playback.seekForward10"))
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: [.command])
|
||||
.disabled(!hasActiveVideo)
|
||||
}
|
||||
|
||||
private var secondarySeekBackwardSeconds: Int {
|
||||
appEnvironment.activeControlsCenterSettings.secondarySeekBackwardSeconds
|
||||
}
|
||||
|
||||
private var secondarySeekForwardSeconds: Int {
|
||||
appEnvironment.activeControlsCenterSettings.secondarySeekForwardSeconds
|
||||
}
|
||||
|
||||
private var secondarySeekBackwardButton: some View {
|
||||
private var seekBackward30Button: some View {
|
||||
Button {
|
||||
playerService.seekBackward(by: TimeInterval(secondarySeekBackwardSeconds))
|
||||
playerService.seekBackward(by: 30)
|
||||
} label: {
|
||||
Text(String(localized: "menu.playback.seekBackward \(secondarySeekBackwardSeconds)"))
|
||||
Text(String(localized: "menu.playback.seekBackward30"))
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: [.command, .shift])
|
||||
.disabled(!hasActiveVideo)
|
||||
}
|
||||
|
||||
private var secondarySeekForwardButton: some View {
|
||||
private var seekForward30Button: some View {
|
||||
Button {
|
||||
playerService.seekForward(by: TimeInterval(secondarySeekForwardSeconds))
|
||||
playerService.seekForward(by: 30)
|
||||
} label: {
|
||||
Text(String(localized: "menu.playback.seekForward \(secondarySeekForwardSeconds)"))
|
||||
Text(String(localized: "menu.playback.seekForward30"))
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: [.command, .shift])
|
||||
.disabled(!hasActiveVideo)
|
||||
@@ -306,7 +288,7 @@ struct PlaybackCommands: Commands {
|
||||
Text(String(localized: "menu.playback.pip"))
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: [.command, .shift])
|
||||
.disabled(!hasActiveVideo || !state.isPiPPossible || state.currentStream?.isAudioOnly == true)
|
||||
.disabled(!hasActiveVideo || !state.isPiPPossible)
|
||||
}
|
||||
|
||||
// MARK: - Close video button
|
||||
|
||||
@@ -13,10 +13,6 @@ enum SettingsKey: String, CaseIterable {
|
||||
// General
|
||||
case theme
|
||||
case accentColor
|
||||
case customAccentColor
|
||||
case accentColorDark
|
||||
case customAccentColorDark
|
||||
case useSeparateDarkAccentColor
|
||||
case showWatchedCheckmark
|
||||
|
||||
// Playback
|
||||
@@ -24,12 +20,10 @@ enum SettingsKey: String, CaseIterable {
|
||||
case cellularQuality
|
||||
case autoplay
|
||||
case backgroundPlayback
|
||||
case dashEnabled
|
||||
case preferredAudioLanguage
|
||||
case preferredSubtitlesLanguage
|
||||
case resumeAction
|
||||
case tvOSMenuButtonClosesVideo
|
||||
case allowSoftwareDecodedFormats
|
||||
case audioOnlyMode
|
||||
|
||||
// SponsorBlock
|
||||
case sponsorBlockEnabled
|
||||
@@ -46,14 +40,8 @@ enum SettingsKey: String, CaseIterable {
|
||||
case deArrowAPIURL
|
||||
case deArrowThumbnailAPIURL
|
||||
|
||||
// Short link resolution
|
||||
case resolveShortLinksEnabled
|
||||
|
||||
// Platform-specific
|
||||
case macPlayerSeparateWindow
|
||||
case macPlayerFloating
|
||||
case macControlsBarOffsetX // Normalized X offset of macOS control bar from default position
|
||||
case macControlsBarOffsetY // Normalized Y offset of macOS control bar from default position
|
||||
case macPlayerMode
|
||||
case playerSheetAutoResize
|
||||
case listStyle
|
||||
|
||||
@@ -72,17 +60,9 @@ enum SettingsKey: String, CaseIterable {
|
||||
case homeShortcutOrder
|
||||
case homeShortcutVisibility
|
||||
case homeShortcutLayout
|
||||
case homeShortcutCardStyle
|
||||
case homeShortcutCardColor
|
||||
case homeShortcutColorfulPalette
|
||||
case homeShortcutCustomPaletteColors
|
||||
case homeSectionOrder
|
||||
case homeSectionVisibility
|
||||
case homeSectionItemsLimit
|
||||
case homeSectionLayout
|
||||
|
||||
// Top Shelf (tvOS)
|
||||
case topShelfSections
|
||||
|
||||
// Tab Bar (compact size class)
|
||||
case tabBarItemOrder
|
||||
@@ -113,16 +93,11 @@ enum SettingsKey: String, CaseIterable {
|
||||
// Advanced
|
||||
case showAdvancedStreamDetails
|
||||
case showPlayerAreaDebug
|
||||
case showTVDebugButton
|
||||
case verboseMPVLogging
|
||||
case verboseRemoteControlLogging
|
||||
case mpvBufferSeconds
|
||||
case mpvUseEDLStreams
|
||||
case zoomTransitionsEnabled
|
||||
case tvMatchDisplayFrameRate
|
||||
case tvMatchDisplayDynamicRange
|
||||
case tvAudioDelayMs
|
||||
case tvVideoSyncMode
|
||||
|
||||
// Details panel
|
||||
case floatingDetailsPanelSide // Landscape only - which side the panel appears on
|
||||
@@ -141,31 +116,9 @@ enum SettingsKey: String, CaseIterable {
|
||||
case onboardingCompleted
|
||||
|
||||
/// Whether this key should have platform-specific prefixes.
|
||||
/// Platform-specific keys are stored under a `iOS.` / `macOS.` / `tvOS.` prefix
|
||||
/// in both UserDefaults and iCloud, so each platform family syncs independently.
|
||||
var isPlatformSpecific: Bool {
|
||||
switch self {
|
||||
case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .audioOnlyMode,
|
||||
.macPlayerSeparateWindow, .macPlayerFloating, .listStyle,
|
||||
.macControlsBarOffsetX, .macControlsBarOffsetY,
|
||||
// Home layout — different UI paradigms per platform
|
||||
.homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle,
|
||||
.homeShortcutCardColor, .homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors,
|
||||
.homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, .homeSectionLayout,
|
||||
// Top Shelf — tvOS only
|
||||
.topShelfSections,
|
||||
// Tab bar (compact size class) layout
|
||||
.tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab,
|
||||
// Sidebar layout/selection
|
||||
.sidebarMainItemOrder, .sidebarMainItemVisibility, .sidebarStartupTab,
|
||||
.sidebarSourcesEnabled, .sidebarSourceSort, .sidebarSourcesLimitEnabled, .sidebarMaxSources,
|
||||
.sidebarChannelsEnabled, .sidebarMaxChannels, .sidebarChannelSort, .sidebarChannelsLimitEnabled,
|
||||
.sidebarPlaylistsEnabled, .sidebarMaxPlaylists, .sidebarPlaylistSort, .sidebarPlaylistsLimitEnabled,
|
||||
// Player details panel — iOS/iPadOS only, different on other platforms
|
||||
.floatingDetailsPanelSide, .floatingDetailsPanelWidth,
|
||||
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
|
||||
// Video swipe actions — touch-gesture feature
|
||||
.videoSwipeActionOrder, .videoSwipeActionVisibility:
|
||||
case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -39,19 +39,6 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to show the Debug button in the tvOS player bottom controls.
|
||||
/// Opens the MPV debug overlay. Default is false (hidden).
|
||||
var showTVDebugButton: Bool {
|
||||
get {
|
||||
if let cached = _showTVDebugButton { return cached }
|
||||
return bool(for: .showTVDebugButton, default: false)
|
||||
}
|
||||
set {
|
||||
_showTVDebugButton = newValue
|
||||
set(newValue, for: .showTVDebugButton)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether verbose MPV rendering logging is enabled.
|
||||
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state
|
||||
/// to help diagnose rendering issues. Default is false (disabled).
|
||||
@@ -141,82 +128,6 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
|
||||
/// playing video's frame rate. Has no effect unless the user also enables
|
||||
/// "Match Content → Frame Rate" in tvOS Settings → Video and Audio.
|
||||
/// Default is true on tvOS (no-op on other platforms).
|
||||
var tvMatchDisplayFrameRate: Bool {
|
||||
get {
|
||||
if let cached = _tvMatchDisplayFrameRate { return cached }
|
||||
return bool(for: .tvMatchDisplayFrameRate, default: false)
|
||||
}
|
||||
set {
|
||||
_tvMatchDisplayFrameRate = newValue
|
||||
set(newValue, for: .tvMatchDisplayFrameRate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed audio-pipeline offset (in milliseconds) applied as MPV's `audio-delay`
|
||||
/// on tvOS. Useful for compensating fixed HDMI/AVR output latency where MPV's
|
||||
/// internal `avsync` reads 0 but audio is perceptibly ahead/behind video.
|
||||
/// Positive values shift audio later; negative values shift it earlier.
|
||||
/// Default is 0 (no offset). Setting is local-only and tvOS-only.
|
||||
var tvAudioDelayMs: Double {
|
||||
get {
|
||||
if let cached = _tvAudioDelayMs { return cached }
|
||||
let raw = localDefaults.object(forKey: "tvAudioDelayMs") as? Double
|
||||
return raw ?? 0
|
||||
}
|
||||
set {
|
||||
_tvAudioDelayMs = newValue
|
||||
localDefaults.set(newValue, forKey: "tvAudioDelayMs")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read tvOS audio-delay setting from a nonisolated context (e.g. MPVClient init).
|
||||
/// Returns milliseconds; convert to seconds before passing to MPV.
|
||||
nonisolated static func tvAudioDelayMsSync() -> Double {
|
||||
guard UserDefaults.standard.object(forKey: "tvAudioDelayMs") != nil else { return 0 }
|
||||
return UserDefaults.standard.double(forKey: "tvAudioDelayMs")
|
||||
}
|
||||
|
||||
/// MPV `video-sync` mode override for tvOS (debug toggle). The shipped default
|
||||
/// remains `display-vdrop`; this is exposed so we can A/B alternative modes on
|
||||
/// real hardware when investigating A/V sync issues.
|
||||
var tvVideoSyncMode: TVVideoSyncMode {
|
||||
get {
|
||||
if let cached = _tvVideoSyncMode { return cached }
|
||||
guard let raw = localDefaults.string(forKey: "tvVideoSyncMode"),
|
||||
let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop }
|
||||
return mode
|
||||
}
|
||||
set {
|
||||
_tvVideoSyncMode = newValue
|
||||
localDefaults.set(newValue.rawValue, forKey: "tvVideoSyncMode")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func tvVideoSyncModeSync() -> TVVideoSyncMode {
|
||||
guard let raw = UserDefaults.standard.string(forKey: "tvVideoSyncMode"),
|
||||
let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop }
|
||||
return mode
|
||||
}
|
||||
|
||||
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
|
||||
/// playing video's dynamic range (SDR / HDR10 / HLG). Has no effect unless the
|
||||
/// user also enables "Match Content → Dynamic Range" in tvOS Settings.
|
||||
/// Default is true on tvOS (no-op on other platforms).
|
||||
var tvMatchDisplayDynamicRange: Bool {
|
||||
get {
|
||||
if let cached = _tvMatchDisplayDynamicRange { return cached }
|
||||
return bool(for: .tvMatchDisplayDynamicRange, default: false)
|
||||
}
|
||||
set {
|
||||
_tvMatchDisplayDynamicRange = newValue
|
||||
set(newValue, for: .tvMatchDisplayDynamicRange)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether zoom navigation transitions are enabled (iOS only).
|
||||
/// When enabled, navigating to video/channel/playlist details shows a zoom animation
|
||||
/// from the source thumbnail. Disable if experiencing visual glitches with swipe-back gestures.
|
||||
@@ -297,9 +208,6 @@ extension SettingsManager {
|
||||
/// Default is plain. Synced per-platform via iCloud.
|
||||
var listStyle: VideoListStyle {
|
||||
get {
|
||||
#if os(tvOS)
|
||||
return .plain
|
||||
#else
|
||||
if let cached = _listStyle { return cached }
|
||||
guard let rawValue = string(for: .listStyle),
|
||||
let style = VideoListStyle(rawValue: rawValue) else {
|
||||
@@ -307,7 +215,6 @@ extension SettingsManager {
|
||||
}
|
||||
_listStyle = style
|
||||
return style
|
||||
#endif
|
||||
}
|
||||
set {
|
||||
_listStyle = newValue
|
||||
|
||||
@@ -335,11 +335,6 @@ extension SettingsManager {
|
||||
|
||||
// Copy all local settings to iCloud
|
||||
for key in SettingsKey.allCases {
|
||||
// Skip local-only keys (device-specific settings that shouldn't sync)
|
||||
if key.isLocalOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
let pKey = platformKey(key)
|
||||
if let value = localDefaults.object(forKey: pKey) {
|
||||
ubiquitousStore.set(value, forKey: pKey)
|
||||
@@ -367,11 +362,6 @@ extension SettingsManager {
|
||||
|
||||
// Copy all iCloud settings to local defaults
|
||||
for key in SettingsKey.allCases {
|
||||
// Skip local-only keys (device-specific settings that shouldn't sync)
|
||||
if key.isLocalOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip protected keys that should preserve local values
|
||||
if keysToPreserve.contains(key) {
|
||||
continue
|
||||
@@ -399,10 +389,6 @@ extension SettingsManager {
|
||||
|
||||
clearCache()
|
||||
updateLastSyncTime()
|
||||
|
||||
#if !os(tvOS)
|
||||
Self.applyTheme(theme)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Refreshes settings from iCloud by copying iCloud values to local storage.
|
||||
@@ -459,11 +445,5 @@ extension SettingsManager {
|
||||
|
||||
// Clear caches to force re-read from local storage
|
||||
clearCache()
|
||||
|
||||
// Re-apply the theme in case it was among the synced changes —
|
||||
// it is enforced at the window level, not via SwiftUI state.
|
||||
#if !os(tvOS)
|
||||
Self.applyTheme(theme)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,21 +87,4 @@ extension SettingsManager {
|
||||
set(newValue, for: .deArrowThumbnailAPIURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Short Link Resolution
|
||||
|
||||
/// When enabled, taps on known URL shorteners (bit.ly, tinyurl, t.co, …) in
|
||||
/// descriptions and comments follow the redirect and, if the destination is a
|
||||
/// supported YouTube/PeerTube URL, open it in-app. Off by default because it
|
||||
/// performs a network request to the shortener host on tap.
|
||||
var resolveShortLinksEnabled: Bool {
|
||||
get {
|
||||
if let cached = _resolveShortLinksEnabled { return cached }
|
||||
return bool(for: .resolveShortLinksEnabled, default: false)
|
||||
}
|
||||
set {
|
||||
_resolveShortLinksEnabled = newValue
|
||||
set(newValue, for: .resolveShortLinksEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
extension SettingsManager {
|
||||
@@ -24,28 +21,9 @@ extension SettingsManager {
|
||||
set {
|
||||
_theme = newValue
|
||||
set(newValue.rawValue, for: .theme)
|
||||
Self.applyTheme(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces the theme onto the platform windows directly. SwiftUI's
|
||||
/// `.preferredColorScheme` fails to revert from dark back to light/system
|
||||
/// while a sheet is presented, and on macOS it never covered secondary
|
||||
/// window scenes (Settings), so the override is applied at the
|
||||
/// UIKit/AppKit level instead.
|
||||
static func applyTheme(_ theme: AppTheme) {
|
||||
#if os(iOS)
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for window in windowScene.windows {
|
||||
window.overrideUserInterfaceStyle = theme.userInterfaceStyle
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
NSApp.appearance = theme.appearance
|
||||
#endif
|
||||
}
|
||||
|
||||
var accentColor: AccentColor {
|
||||
get {
|
||||
if let cached = _accentColor { return cached }
|
||||
@@ -57,92 +35,9 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
var customAccentColor: Color {
|
||||
get {
|
||||
let hex = _customAccentColor ?? string(for: .customAccentColor) ?? ""
|
||||
return Color(hex: hex) ?? AccentColor.default.color
|
||||
}
|
||||
set {
|
||||
let hex = newValue.toHexString()
|
||||
_customAccentColor = hex
|
||||
set(hex, for: .customAccentColor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user picked a separate accent color for dark mode.
|
||||
var useSeparateDarkAccentColor: Bool {
|
||||
get {
|
||||
if let cached = _useSeparateDarkAccentColor { return cached }
|
||||
return bool(for: .useSeparateDarkAccentColor, default: false)
|
||||
}
|
||||
set {
|
||||
_useSeparateDarkAccentColor = newValue
|
||||
set(newValue, for: .useSeparateDarkAccentColor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent color choice for dark mode. Falls back to the light selection until explicitly set.
|
||||
var accentColorDark: AccentColor {
|
||||
get {
|
||||
if let cached = _accentColorDark { return cached }
|
||||
if let raw = string(for: .accentColorDark), let value = AccentColor(rawValue: raw) {
|
||||
return value
|
||||
}
|
||||
return accentColor
|
||||
}
|
||||
set {
|
||||
_accentColorDark = newValue
|
||||
set(newValue.rawValue, for: .accentColorDark)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom accent color for dark mode. Falls back to the light custom color until explicitly set.
|
||||
var customAccentColorDark: Color {
|
||||
get {
|
||||
if let hex = _customAccentColorDark ?? string(for: .customAccentColorDark),
|
||||
let color = Color(hex: hex) {
|
||||
return color
|
||||
}
|
||||
return customAccentColor
|
||||
}
|
||||
set {
|
||||
let hex = newValue.toHexString()
|
||||
_customAccentColorDark = hex
|
||||
set(hex, for: .customAccentColorDark)
|
||||
}
|
||||
}
|
||||
|
||||
/// Light (or shared) accent color, resolving `.custom` to the user-picked value.
|
||||
private var resolvedLightAccentColor: Color {
|
||||
accentColor == .custom ? customAccentColor : accentColor.color
|
||||
}
|
||||
|
||||
/// Dark accent color, resolving `.custom` to the user-picked value.
|
||||
private var resolvedDarkAccentColor: Color {
|
||||
accentColorDark == .custom ? customAccentColorDark : accentColorDark.color
|
||||
}
|
||||
|
||||
/// The effective accent color. When a separate dark color is enabled this is a
|
||||
/// dynamic platform color that resolves per trait environment, so all consumers
|
||||
/// (root tint and direct readers) adapt to light/dark automatically.
|
||||
var resolvedAccentColor: Color {
|
||||
let light = resolvedLightAccentColor
|
||||
guard useSeparateDarkAccentColor else { return light }
|
||||
let dark = resolvedDarkAccentColor
|
||||
#if os(macOS)
|
||||
return Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
|
||||
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||
return NSColor(isDark ? dark : light)
|
||||
}))
|
||||
#else
|
||||
return Color(uiColor: UIColor { traits in
|
||||
UIColor(traits.userInterfaceStyle == .dark ? dark : light)
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - App Icon Settings
|
||||
// MARK: - App Icon Settings (iOS only)
|
||||
|
||||
#if os(iOS)
|
||||
var appIcon: AppIcon {
|
||||
get {
|
||||
if let cached = _appIcon { return cached }
|
||||
@@ -158,53 +53,14 @@ extension SettingsManager {
|
||||
|
||||
// Apply the icon change
|
||||
Task { @MainActor in
|
||||
#if os(iOS)
|
||||
do {
|
||||
try await UIApplication.shared.setAlternateIconName(newValue.alternateIconName)
|
||||
} catch {
|
||||
LoggingService.shared.error("Failed to set alternate icon: \(error)", category: .general)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
SettingsManager.applyMacAppIcon(newValue)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
static func applyMacAppIcon(_ icon: AppIcon) {
|
||||
if icon == .default {
|
||||
NSApp.applicationIconImage = nil
|
||||
} else if let source = NSImage(named: icon.previewImageName) {
|
||||
NSApp.applicationIconImage = makeMacIconImage(from: source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the source image onto a 1024×1024 canvas with the standard macOS
|
||||
/// squircle mask and transparent padding so the Dock renders it like a
|
||||
/// native app icon.
|
||||
private static func makeMacIconImage(from source: NSImage) -> NSImage {
|
||||
let canvasSize: CGFloat = 1024
|
||||
// macOS icon grid: artwork occupies ~824×824 centered in a 1024 canvas,
|
||||
// with a ~185pt corner radius on the masked rect.
|
||||
let artworkSize: CGFloat = 824
|
||||
let cornerRadius: CGFloat = 185
|
||||
let inset = (canvasSize - artworkSize) / 2
|
||||
let artworkRect = NSRect(x: inset, y: inset, width: artworkSize, height: artworkSize)
|
||||
|
||||
let image = NSImage(size: NSSize(width: canvasSize, height: canvasSize))
|
||||
image.lockFocus()
|
||||
let path = NSBezierPath(roundedRect: artworkRect, xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
path.addClip()
|
||||
source.draw(in: artworkRect,
|
||||
from: .zero,
|
||||
operation: .sourceOver,
|
||||
fraction: 1.0,
|
||||
respectFlipped: true,
|
||||
hints: [.interpolation: NSImageInterpolation.high.rawValue])
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Whether to show a checkmark badge on fully watched video thumbnails.
|
||||
@@ -650,26 +506,6 @@ extension SettingsManager {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Video Tap Action (tvOS only)
|
||||
|
||||
#if os(tvOS)
|
||||
/// Action to perform when clicking a video cell on tvOS. Default is openInfo.
|
||||
var tvOSVideoTapAction: VideoTapAction {
|
||||
get {
|
||||
if let cached = _tvOSVideoTapAction { return cached }
|
||||
guard let rawValue = localDefaults.string(forKey: "tvOSVideoTapAction"),
|
||||
let action = VideoTapAction(rawValue: rawValue) else {
|
||||
return .openInfo
|
||||
}
|
||||
return action
|
||||
}
|
||||
set {
|
||||
_tvOSVideoTapAction = newValue
|
||||
localDefaults.set(newValue.rawValue, forKey: "tvOSVideoTapAction")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Onboarding
|
||||
|
||||
/// Whether onboarding has been completed on this device.
|
||||
|
||||
@@ -95,86 +95,6 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout for home shortcut cards (compact or regular). Default is regular.
|
||||
var homeShortcutCardStyle: HomeShortcutCardStyle {
|
||||
get {
|
||||
if let cached = _homeShortcutCardStyle { return cached }
|
||||
guard let rawValue = string(for: .homeShortcutCardStyle) else {
|
||||
return .regular
|
||||
}
|
||||
return HomeShortcutCardStyle(rawValue: rawValue) ?? .regular
|
||||
}
|
||||
set {
|
||||
_homeShortcutCardStyle = newValue
|
||||
set(newValue.rawValue, for: .homeShortcutCardStyle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Color emphasis for home shortcut cards (soft or vibrant), independent of
|
||||
/// the layout style. Default is soft.
|
||||
var homeShortcutCardColor: HomeShortcutCardColor {
|
||||
get {
|
||||
if let cached = _homeShortcutCardColor { return cached }
|
||||
guard let rawValue = string(for: .homeShortcutCardColor) else {
|
||||
return .soft
|
||||
}
|
||||
return HomeShortcutCardColor(rawValue: rawValue) ?? .soft
|
||||
}
|
||||
set {
|
||||
_homeShortcutCardColor = newValue
|
||||
set(newValue.rawValue, for: .homeShortcutCardColor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette used by the "regular" card style. Colors are applied by grid
|
||||
/// position. Default is Accent (the first palette option).
|
||||
var homeShortcutColorfulPalette: HomeShortcutColorfulPalette {
|
||||
get {
|
||||
if let cached = _homeShortcutColorfulPalette { return cached }
|
||||
guard let rawValue = string(for: .homeShortcutColorfulPalette) else {
|
||||
return .accent
|
||||
}
|
||||
return HomeShortcutColorfulPalette(rawValue: rawValue) ?? .accent
|
||||
}
|
||||
set {
|
||||
_homeShortcutColorfulPalette = newValue
|
||||
set(newValue.rawValue, for: .homeShortcutColorfulPalette)
|
||||
}
|
||||
}
|
||||
|
||||
/// User-supplied hex colors for the custom "colorful" palette. Stored as a
|
||||
/// single comma-separated string. Defaults to a starter set of colors.
|
||||
var homeShortcutCustomPaletteColors: [String] {
|
||||
get {
|
||||
if let cached = _homeShortcutCustomPaletteColors { return cached }
|
||||
guard let raw = string(for: .homeShortcutCustomPaletteColors) else {
|
||||
return HomeShortcutColorfulPalette.customStarterColors
|
||||
}
|
||||
let colors = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
return colors
|
||||
}
|
||||
set {
|
||||
_homeShortcutCustomPaletteColors = newValue
|
||||
let joined = newValue.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: ",")
|
||||
set(joined, for: .homeShortcutCustomPaletteColors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout mode for home sections (list or grid). Default is list on iOS/macOS, grid on tvOS.
|
||||
var homeSectionLayout: HomeSectionLayout {
|
||||
get {
|
||||
if let cached = _homeSectionLayout { return cached }
|
||||
guard let rawValue = string(for: .homeSectionLayout) else {
|
||||
return HomeSectionLayout.platformDefault
|
||||
}
|
||||
return HomeSectionLayout(rawValue: rawValue) ?? HomeSectionLayout.platformDefault
|
||||
}
|
||||
set {
|
||||
_homeSectionLayout = newValue
|
||||
set(newValue.rawValue, for: .homeSectionLayout)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Section Settings
|
||||
|
||||
/// Ordered list of home sections. Default order is bookmarks, history, downloads.
|
||||
@@ -586,6 +506,13 @@ extension SettingsManager {
|
||||
return existingCards.contains(card.id) ? [] : [card]
|
||||
}
|
||||
|
||||
/// Returns all available section items for a media source that are NOT already added.
|
||||
func availableSections(for source: MediaSource) -> [HomeSectionItem] {
|
||||
let section = HomeSectionItem.mediaSource(sourceID: source.id)
|
||||
let existingSections = Set(homeSectionOrder.map { $0.id })
|
||||
return existingSections.contains(section.id) ? [] : [section]
|
||||
}
|
||||
|
||||
/// Returns all available cards across all media sources, grouped by source.
|
||||
func allAvailableMediaSourceShortcuts(sources: [MediaSource]) -> [(source: MediaSource, cards: [HomeShortcutItem])] {
|
||||
sources.compactMap { source in
|
||||
@@ -594,6 +521,14 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all available sections across all media sources, grouped by source.
|
||||
func allAvailableMediaSourceSections(sources: [MediaSource]) -> [(source: MediaSource, sections: [HomeSectionItem])] {
|
||||
sources.compactMap { source in
|
||||
let sections = availableSections(for: source)
|
||||
return sections.isEmpty ? nil : (source, sections)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all Home items for media sources that no longer exist.
|
||||
func cleanupOrphanedHomeMediaSourceItems(validSourceIDs: Set<UUID>) {
|
||||
var hadOrphans = false
|
||||
@@ -667,45 +602,6 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes ALL media-source Home *sections* (regardless of whether the source
|
||||
/// still exists). Media sources are shortcuts-only now — a media-source
|
||||
/// "section" was just a browse link and the feature was removed. Idempotent:
|
||||
/// only writes when something changed, so it's safe to call on every load and
|
||||
/// it also cleans up media-source sections that sync in from an older device.
|
||||
/// Leaves media-source *shortcuts* (`homeShortcutOrder`/`homeShortcutVisibility`) untouched.
|
||||
func removeAllHomeMediaSourceSections() {
|
||||
var didChange = false
|
||||
|
||||
var sectionOrder = homeSectionOrder
|
||||
let originalCount = sectionOrder.count
|
||||
sectionOrder.removeAll { item in
|
||||
if case .mediaSource = item { return true }
|
||||
return false
|
||||
}
|
||||
if sectionOrder.count != originalCount {
|
||||
LoggingService.shared.logCloudKit("removeAllHomeMediaSourceSections: removed \(originalCount - sectionOrder.count) media-source sections")
|
||||
homeSectionOrder = sectionOrder
|
||||
didChange = true
|
||||
}
|
||||
|
||||
var sectionVis = homeSectionVisibility
|
||||
let orphanedKeys = sectionVis.keys.filter { item in
|
||||
if case .mediaSource = item { return true }
|
||||
return false
|
||||
}
|
||||
if !orphanedKeys.isEmpty {
|
||||
for key in orphanedKeys {
|
||||
sectionVis.removeValue(forKey: key)
|
||||
}
|
||||
homeSectionVisibility = sectionVis
|
||||
didChange = true
|
||||
}
|
||||
|
||||
if didChange {
|
||||
LoggingService.shared.logCloudKit("removeAllHomeMediaSourceSections: cleaned up media-source sections")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar Settings (Compact Size Class)
|
||||
|
||||
/// Ordered list of tab bar items. Default order is subscriptions first, then others.
|
||||
|
||||
@@ -16,7 +16,6 @@ extension SettingsManager {
|
||||
/// NOT synced to iCloud - local-only storage.
|
||||
var customMPVOptions: [String: String] {
|
||||
get {
|
||||
if let cached = _customMPVOptions { return cached }
|
||||
guard let data = localDefaults.data(forKey: "customMPVOptions"),
|
||||
let options = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return [:]
|
||||
@@ -24,7 +23,6 @@ extension SettingsManager {
|
||||
return options
|
||||
}
|
||||
set {
|
||||
_customMPVOptions = newValue
|
||||
if let data = try? JSONEncoder().encode(newValue) {
|
||||
localDefaults.set(data, forKey: "customMPVOptions")
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// SettingsManager+Migration.swift
|
||||
// Yattee
|
||||
//
|
||||
// One-shot migrations that move legacy unprefixed values under
|
||||
// platform-specific keys when `SettingsKey.isPlatformSpecific` flips to true.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SettingsManager {
|
||||
private static let migrationFlagKey = "didMigratePlatformSpecificLayoutKeys_v1"
|
||||
|
||||
// Mirrors `SettingsManager+CloudSync.protectedVisibilityKeys`. Kept in sync manually
|
||||
// because that collection is fileprivate; the set is small and rarely changes.
|
||||
private static let protectedKeysForMigration: Set<SettingsKey> = [
|
||||
.homeShortcutVisibility,
|
||||
.homeSectionVisibility,
|
||||
.homeShortcutOrder,
|
||||
.homeSectionOrder
|
||||
]
|
||||
|
||||
/// Copies any legacy unprefixed values for keys that became platform-specific into their
|
||||
/// new `iOS.` / `macOS.` / `tvOS.` slots, both locally and (if iCloud sync is on) in iCloud.
|
||||
/// Leaves the legacy unprefixed keys in place so older builds on other devices still work.
|
||||
func migrateLayoutKeysToPlatformPrefixed() {
|
||||
guard !localDefaults.bool(forKey: Self.migrationFlagKey) else { return }
|
||||
|
||||
let keysNeedingMigration = SettingsKey.allCases.filter { $0.isPlatformSpecific }
|
||||
let pushToCloud = iCloudSyncEnabled && syncSettings
|
||||
|
||||
for key in keysNeedingMigration {
|
||||
let pKey = platformKey(key)
|
||||
let legacyKey = key.rawValue
|
||||
|
||||
// Skip if the prefixed form already exists or if the legacy key is the same as the prefixed
|
||||
// key (e.g. on a platform where `platformKey` didn't rewrite it, though that shouldn't happen
|
||||
// for isPlatformSpecific keys).
|
||||
guard pKey != legacyKey,
|
||||
localDefaults.object(forKey: pKey) == nil,
|
||||
let legacyValue = localDefaults.object(forKey: legacyKey)
|
||||
else { continue }
|
||||
|
||||
localDefaults.set(legacyValue, forKey: pKey)
|
||||
|
||||
if Self.protectedKeysForMigration.contains(key) {
|
||||
let legacyTimestampKey = "\(legacyKey)_modifiedAt"
|
||||
let newTimestampKey = modifiedAtKey(for: key)
|
||||
let legacyTimestamp = localDefaults.double(forKey: legacyTimestampKey)
|
||||
if legacyTimestamp > 0, localDefaults.double(forKey: newTimestampKey) == 0 {
|
||||
localDefaults.set(legacyTimestamp, forKey: newTimestampKey)
|
||||
}
|
||||
}
|
||||
|
||||
if pushToCloud {
|
||||
ubiquitousStore.set(legacyValue, forKey: pKey)
|
||||
if Self.protectedKeysForMigration.contains(key) {
|
||||
let newTimestampKey = modifiedAtKey(for: key)
|
||||
let timestamp = localDefaults.double(forKey: newTimestampKey)
|
||||
if timestamp > 0 {
|
||||
ubiquitousStore.set(timestamp, forKey: newTimestampKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pushToCloud {
|
||||
ubiquitousStore.synchronize()
|
||||
}
|
||||
|
||||
localDefaults.set(true, forKey: Self.migrationFlagKey)
|
||||
LoggingService.shared.logCloudKit("Migrated legacy layout keys to platform-prefixed storage")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ extension SettingsManager {
|
||||
var backgroundPlaybackEnabled: Bool {
|
||||
get {
|
||||
if let cached = _backgroundPlaybackEnabled { return cached }
|
||||
return bool(for: .backgroundPlayback, default: backgroundPlaybackDefault)
|
||||
return bool(for: .backgroundPlayback, default: true)
|
||||
}
|
||||
set {
|
||||
_backgroundPlaybackEnabled = newValue
|
||||
@@ -48,49 +48,16 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundPlaybackDefault: Bool {
|
||||
#if os(tvOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// tvOS only: when enabled, the Siri remote Menu button closes the video
|
||||
/// (clears queue, stops playback) instead of only collapsing the player.
|
||||
/// When enabled, the explicit top-bar close button is hidden.
|
||||
var tvOSMenuButtonClosesVideo: Bool {
|
||||
/// Whether DASH streams are enabled (MPV only).
|
||||
/// Disabled by default as DASH can be unreliable with some Invidious instances.
|
||||
var dashEnabled: Bool {
|
||||
get {
|
||||
if let cached = _tvOSMenuButtonClosesVideo { return cached }
|
||||
return bool(for: .tvOSMenuButtonClosesVideo, default: false)
|
||||
if let cached = _dashEnabled { return cached }
|
||||
return bool(for: .dashEnabled, default: false)
|
||||
}
|
||||
set {
|
||||
_tvOSMenuButtonClosesVideo = newValue
|
||||
set(newValue, for: .tvOSMenuButtonClosesVideo)
|
||||
}
|
||||
}
|
||||
|
||||
var allowSoftwareDecodedFormats: Bool {
|
||||
get {
|
||||
if let cached = _allowSoftwareDecodedFormats { return cached }
|
||||
return bool(for: .allowSoftwareDecodedFormats, default: false)
|
||||
}
|
||||
set {
|
||||
_allowSoftwareDecodedFormats = newValue
|
||||
set(newValue, for: .allowSoftwareDecodedFormats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio-only ("music") mode: when enabled, only the audio track is loaded
|
||||
/// for every video until turned off. Persisted per platform.
|
||||
var audioOnlyModeEnabled: Bool {
|
||||
get {
|
||||
if let cached = _audioOnlyModeEnabled { return cached }
|
||||
return bool(for: .audioOnlyMode, default: false)
|
||||
}
|
||||
set {
|
||||
_audioOnlyModeEnabled = newValue
|
||||
set(newValue, for: .audioOnlyMode)
|
||||
_dashEnabled = newValue
|
||||
set(newValue, for: .dashEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,58 +68,14 @@ extension SettingsManager {
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
/// Whether the expanded player opens in a separate window (vs. an inline sheet).
|
||||
/// Default is `true`.
|
||||
var macPlayerSeparateWindow: Bool {
|
||||
var macPlayerMode: MacPlayerMode {
|
||||
get {
|
||||
if let cached = _macPlayerSeparateWindow { return cached }
|
||||
return bool(for: .macPlayerSeparateWindow, default: true)
|
||||
if let cached = _macPlayerMode { return cached }
|
||||
return MacPlayerMode(rawValue: string(for: .macPlayerMode) ?? "") ?? .window
|
||||
}
|
||||
set {
|
||||
_macPlayerSeparateWindow = newValue
|
||||
set(newValue, for: .macPlayerSeparateWindow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the separate player window floats above other windows (always on top).
|
||||
/// Toggled live from the player's top-bar pin button and remembered across sessions.
|
||||
/// Default is `false`.
|
||||
var macPlayerFloating: Bool {
|
||||
get {
|
||||
if let cached = _macPlayerFloating { return cached }
|
||||
return bool(for: .macPlayerFloating, default: false)
|
||||
}
|
||||
set {
|
||||
_macPlayerFloating = newValue
|
||||
set(newValue, for: .macPlayerFloating)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized X offset of the macOS floating control bar from its default
|
||||
/// bottom-center position, stored as a fraction of the player container width.
|
||||
/// 0 = default docked position.
|
||||
var macControlsBarOffsetX: Double {
|
||||
get {
|
||||
if let cached = _macControlsBarOffsetX { return cached }
|
||||
return double(for: .macControlsBarOffsetX)
|
||||
}
|
||||
set {
|
||||
_macControlsBarOffsetX = newValue
|
||||
set(newValue, for: .macControlsBarOffsetX)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized Y offset of the macOS floating control bar from its default
|
||||
/// bottom-center position, stored as a fraction of the player container height.
|
||||
/// 0 = default docked position.
|
||||
var macControlsBarOffsetY: Double {
|
||||
get {
|
||||
if let cached = _macControlsBarOffsetY { return cached }
|
||||
return double(for: .macControlsBarOffsetY)
|
||||
}
|
||||
set {
|
||||
_macControlsBarOffsetY = newValue
|
||||
set(newValue, for: .macControlsBarOffsetY)
|
||||
_macPlayerMode = newValue
|
||||
set(newValue.rawValue, for: .macPlayerMode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//
|
||||
// SettingsManager+TopShelf.swift
|
||||
// Yattee
|
||||
//
|
||||
// tvOS Top Shelf settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SettingsManager {
|
||||
/// Ordered list of sections visible in the tvOS Top Shelf.
|
||||
/// Inclusion means the section is shown; absence hides it.
|
||||
var topShelfSections: [TopShelfSection] {
|
||||
get {
|
||||
if let cached = _topShelfSections { return cached }
|
||||
guard let data = data(for: .topShelfSections),
|
||||
let saved = try? JSONDecoder().decode([TopShelfSection].self, from: data) else {
|
||||
return TopShelfSection.defaultOrder
|
||||
}
|
||||
return saved
|
||||
}
|
||||
set {
|
||||
_topShelfSections = newValue
|
||||
if let data = try? JSONEncoder().encode(newValue) {
|
||||
set(data, for: .topShelfSections)
|
||||
}
|
||||
let tsKey = modifiedAtKey(for: .topShelfSections)
|
||||
let now = Date().timeIntervalSince1970
|
||||
localDefaults.set(now, forKey: tsKey)
|
||||
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
|
||||
ubiquitousStore.set(now, forKey: tsKey)
|
||||
}
|
||||
mirrorEnabledSectionsToAppGroup(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the enabled-sections list to the App Group UserDefaults suite
|
||||
/// so the tvOS Top Shelf extension can read the user's selection.
|
||||
func mirrorEnabledSectionsToAppGroup(_ sections: [TopShelfSection]) {
|
||||
let rawValues = sections.map(\.rawValue)
|
||||
AppGroup.defaults.set(rawValues, forKey: AppGroup.enabledSectionsKey)
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,6 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// MARK: - Theme & Appearance
|
||||
|
||||
@@ -27,24 +22,6 @@ enum AppTheme: String, CaseIterable, Codable {
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .system: return .unspecified
|
||||
case .light: return .light
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
#elseif canImport(AppKit)
|
||||
var appearance: NSAppearance? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return NSAppearance(named: .aqua)
|
||||
case .dark: return NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum AccentColor: String, CaseIterable, Codable {
|
||||
@@ -58,20 +35,10 @@ enum AccentColor: String, CaseIterable, Codable {
|
||||
case blue
|
||||
case purple
|
||||
case indigo
|
||||
case custom
|
||||
|
||||
/// Fixed swatches shown in the settings grid; `.custom` is rendered
|
||||
/// separately as a color picker, and `.indigo` is retired from the grid
|
||||
/// but still resolves for users who selected it before it was removed.
|
||||
static var presets: [AccentColor] {
|
||||
allCases.filter { $0 != .custom && $0 != .indigo }
|
||||
}
|
||||
|
||||
/// The color for `.custom` lives in settings storage — resolve through
|
||||
/// `SettingsManager.resolvedAccentColor` instead of this property.
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .default, .custom: return .blue // System default accent color
|
||||
case .default: return .blue // System default accent color
|
||||
case .red: return .red
|
||||
case .pink: return .pink
|
||||
case .orange: return .orange
|
||||
@@ -85,6 +52,7 @@ enum AccentColor: String, CaseIterable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
enum AppIcon: String, CaseIterable, Codable {
|
||||
case `default`
|
||||
case classic
|
||||
@@ -121,6 +89,7 @@ enum AppIcon: String, CaseIterable, Codable {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Video Quality
|
||||
|
||||
@@ -234,6 +203,37 @@ enum DownloadQuality: String, CaseIterable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - macOS Player Mode
|
||||
|
||||
#if os(macOS)
|
||||
enum MacPlayerMode: String, CaseIterable, Codable {
|
||||
case window
|
||||
case floatingWindow
|
||||
case inline
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .window: return String(localized: "settings.playback.macOS.playerMode.window")
|
||||
case .floatingWindow: return String(localized: "settings.playback.macOS.playerMode.floatingWindow")
|
||||
case .inline: return String(localized: "settings.playback.macOS.playerMode.inline")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this mode uses a separate window (vs sheet/inline)
|
||||
var usesWindow: Bool {
|
||||
switch self {
|
||||
case .window, .floatingWindow: return true
|
||||
case .inline: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the window should float above other windows
|
||||
var isFloating: Bool {
|
||||
self == .floatingWindow
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Haptic Feedback
|
||||
|
||||
/// Intensity levels for haptic feedback.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Sections that can appear in the tvOS Top Shelf.
|
||||
/// Stored ordered in `SettingsKey.topShelfSections` — inclusion = visible.
|
||||
enum TopShelfSection: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case continueWatching
|
||||
case recentFeed
|
||||
case recentBookmarks
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .continueWatching: return String(localized: "home.section.continueWatching")
|
||||
case .recentFeed: return String(localized: "home.section.feed")
|
||||
case .recentBookmarks: return String(localized: "home.section.bookmarks")
|
||||
}
|
||||
}
|
||||
|
||||
/// UserDefaults key (under the app-group suite) holding the JSON snapshot for this section.
|
||||
var snapshotKey: String {
|
||||
switch self {
|
||||
case .continueWatching: return "topShelf.continueWatching"
|
||||
case .recentFeed: return "topShelf.recentFeed"
|
||||
case .recentBookmarks: return "topShelf.recentBookmarks"
|
||||
}
|
||||
}
|
||||
|
||||
static let defaultOrder: [TopShelfSection] = [.continueWatching, .recentFeed, .recentBookmarks]
|
||||
}
|
||||
@@ -28,23 +28,17 @@ final class SettingsManager {
|
||||
// Theme
|
||||
var _theme: AppTheme?
|
||||
var _accentColor: AccentColor?
|
||||
var _customAccentColor: String?
|
||||
var _accentColorDark: AccentColor?
|
||||
var _customAccentColorDark: String?
|
||||
var _useSeparateDarkAccentColor: Bool?
|
||||
var _showWatchedCheckmark: Bool?
|
||||
|
||||
// Playback
|
||||
var _preferredQuality: VideoQuality?
|
||||
var _cellularQuality: VideoQuality?
|
||||
var _backgroundPlaybackEnabled: Bool?
|
||||
var _dashEnabled: Bool?
|
||||
var _preferredAudioLanguage: String?
|
||||
var _preferredSubtitlesLanguage: String?
|
||||
var _playerVolume: Float?
|
||||
var _resumeAction: ResumeAction?
|
||||
var _tvOSMenuButtonClosesVideo: Bool?
|
||||
var _allowSoftwareDecodedFormats: Bool?
|
||||
var _audioOnlyModeEnabled: Bool?
|
||||
|
||||
// SponsorBlock
|
||||
var _sponsorBlockEnabled: Bool?
|
||||
@@ -59,9 +53,6 @@ final class SettingsManager {
|
||||
var _deArrowAPIURL: String?
|
||||
var _deArrowThumbnailAPIURL: String?
|
||||
|
||||
// Short link resolution
|
||||
var _resolveShortLinksEnabled: Bool?
|
||||
|
||||
// User Agent
|
||||
var _customUserAgent: String?
|
||||
var _randomizeUserAgentPerRequest: Bool?
|
||||
@@ -77,10 +68,7 @@ final class SettingsManager {
|
||||
var _preferPortraitBrowsing: Bool?
|
||||
#endif
|
||||
#if os(macOS)
|
||||
var _macPlayerSeparateWindow: Bool?
|
||||
var _macPlayerFloating: Bool?
|
||||
var _macControlsBarOffsetX: Double?
|
||||
var _macControlsBarOffsetY: Double?
|
||||
var _macPlayerMode: MacPlayerMode?
|
||||
var _playerSheetAutoResize: Bool?
|
||||
#endif
|
||||
|
||||
@@ -116,20 +104,9 @@ final class SettingsManager {
|
||||
var _homeShortcutOrder: [HomeShortcutItem]?
|
||||
var _homeShortcutVisibility: [HomeShortcutItem: Bool]?
|
||||
var _homeShortcutLayout: HomeShortcutLayout?
|
||||
var _homeShortcutCardStyle: HomeShortcutCardStyle?
|
||||
var _homeShortcutCardColor: HomeShortcutCardColor?
|
||||
var _homeShortcutColorfulPalette: HomeShortcutColorfulPalette?
|
||||
var _homeShortcutCustomPaletteColors: [String]?
|
||||
var _homeSectionOrder: [HomeSectionItem]?
|
||||
var _homeSectionVisibility: [HomeSectionItem: Bool]?
|
||||
var _homeSectionItemsLimit: Int?
|
||||
var _homeSectionLayout: HomeSectionLayout?
|
||||
|
||||
// Top Shelf (tvOS)
|
||||
var _topShelfSections: [TopShelfSection]?
|
||||
|
||||
// Custom MPV options (local-only)
|
||||
var _customMPVOptions: [String: String]?
|
||||
|
||||
// Tab bar settings (compact size class only - iOS)
|
||||
var _tabBarItemOrder: [TabBarItem]?
|
||||
@@ -163,16 +140,11 @@ final class SettingsManager {
|
||||
// Advanced settings
|
||||
var _showAdvancedStreamDetails: Bool?
|
||||
var _showPlayerAreaDebug: Bool?
|
||||
var _showTVDebugButton: Bool?
|
||||
var _verboseMPVLogging: Bool?
|
||||
var _verboseRemoteControlLogging: Bool?
|
||||
var _mpvBufferSeconds: Double?
|
||||
var _mpvUseEDLStreams: Bool?
|
||||
var _zoomTransitionsEnabled: Bool?
|
||||
var _tvMatchDisplayFrameRate: Bool?
|
||||
var _tvMatchDisplayDynamicRange: Bool?
|
||||
var _tvAudioDelayMs: Double?
|
||||
var _tvVideoSyncMode: TVVideoSyncMode?
|
||||
|
||||
// Details panel settings
|
||||
var _floatingDetailsPanelSide: FloatingPanelSide?
|
||||
@@ -216,16 +188,13 @@ final class SettingsManager {
|
||||
var _textAreaTapAction: VideoTapAction?
|
||||
#endif
|
||||
|
||||
// Video tap action (tvOS only)
|
||||
#if os(tvOS)
|
||||
var _tvOSVideoTapAction: VideoTapAction?
|
||||
#endif
|
||||
|
||||
// Player Controls settings (controlsButtonSize moved to preset)
|
||||
|
||||
// Appearance settings
|
||||
var _listStyle: VideoListStyle?
|
||||
#if os(iOS)
|
||||
var _appIcon: AppIcon?
|
||||
#endif
|
||||
|
||||
// Video Swipe Actions
|
||||
#if !os(tvOS)
|
||||
@@ -283,12 +252,6 @@ final class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot migration: move legacy unprefixed values for keys that became
|
||||
// platform-specific into their new iOS./macOS./tvOS. slots. Must run before
|
||||
// the initial iCloud refresh so it can seed the prefixed iCloud slot from the
|
||||
// current local value before any remote data is read.
|
||||
migrateLayoutKeysToPlatformPrefixed()
|
||||
|
||||
// Initial sync from iCloud to local storage (async to avoid blocking app launch)
|
||||
// This ensures local defaults have the latest iCloud values before any reads.
|
||||
// While sync is pending, suppress iCloud writes from set() to prevent stale
|
||||
@@ -422,21 +385,15 @@ final class SettingsManager {
|
||||
func clearCache() {
|
||||
_theme = nil
|
||||
_accentColor = nil
|
||||
_customAccentColor = nil
|
||||
_accentColorDark = nil
|
||||
_customAccentColorDark = nil
|
||||
_useSeparateDarkAccentColor = nil
|
||||
_showWatchedCheckmark = nil
|
||||
_preferredQuality = nil
|
||||
_cellularQuality = nil
|
||||
_backgroundPlaybackEnabled = nil
|
||||
_dashEnabled = nil
|
||||
_preferredAudioLanguage = nil
|
||||
_preferredSubtitlesLanguage = nil
|
||||
_playerVolume = nil
|
||||
_resumeAction = nil
|
||||
_tvOSMenuButtonClosesVideo = nil
|
||||
_allowSoftwareDecodedFormats = nil
|
||||
_audioOnlyModeEnabled = nil
|
||||
_sponsorBlockEnabled = nil
|
||||
_sponsorBlockCategories = nil
|
||||
_sponsorBlockAPIURL = nil
|
||||
@@ -446,7 +403,6 @@ final class SettingsManager {
|
||||
_deArrowReplaceThumbnails = nil
|
||||
_deArrowAPIURL = nil
|
||||
_deArrowThumbnailAPIURL = nil
|
||||
_resolveShortLinksEnabled = nil
|
||||
_customUserAgent = nil
|
||||
_randomizeUserAgentPerRequest = nil
|
||||
_feedCacheValidityMinutes = nil
|
||||
@@ -470,10 +426,7 @@ final class SettingsManager {
|
||||
_syncSearchHistory = nil
|
||||
_searchHistoryLimit = nil
|
||||
#if os(macOS)
|
||||
_macPlayerSeparateWindow = nil
|
||||
_macPlayerFloating = nil
|
||||
_macControlsBarOffsetX = nil
|
||||
_macControlsBarOffsetY = nil
|
||||
_macPlayerMode = nil
|
||||
_playerSheetAutoResize = nil
|
||||
#endif
|
||||
// miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset
|
||||
@@ -483,15 +436,9 @@ final class SettingsManager {
|
||||
_homeShortcutOrder = nil
|
||||
_homeShortcutVisibility = nil
|
||||
_homeShortcutLayout = nil
|
||||
_homeShortcutCardStyle = nil
|
||||
_homeShortcutCardColor = nil
|
||||
_homeShortcutColorfulPalette = nil
|
||||
_homeShortcutCustomPaletteColors = nil
|
||||
_homeSectionOrder = nil
|
||||
_homeSectionVisibility = nil
|
||||
_homeSectionItemsLimit = nil
|
||||
_homeSectionLayout = nil
|
||||
_topShelfSections = nil
|
||||
_tabBarItemOrder = nil
|
||||
_tabBarItemVisibility = nil
|
||||
_sidebarMainItemOrder = nil
|
||||
@@ -512,16 +459,11 @@ final class SettingsManager {
|
||||
_sidebarPlaylistsLimitEnabled = nil
|
||||
_showAdvancedStreamDetails = nil
|
||||
_showPlayerAreaDebug = nil
|
||||
_showTVDebugButton = nil
|
||||
_verboseMPVLogging = nil
|
||||
_verboseRemoteControlLogging = nil
|
||||
_mpvBufferSeconds = nil
|
||||
_mpvUseEDLStreams = nil
|
||||
_zoomTransitionsEnabled = nil
|
||||
_tvMatchDisplayFrameRate = nil
|
||||
_tvMatchDisplayDynamicRange = nil
|
||||
_tvAudioDelayMs = nil
|
||||
_tvVideoSyncMode = nil
|
||||
_floatingDetailsPanelSide = nil
|
||||
_floatingDetailsPanelWidth = nil
|
||||
_landscapeDetailsPanelVisible = nil
|
||||
@@ -548,9 +490,6 @@ final class SettingsManager {
|
||||
_thumbnailTapAction = nil
|
||||
_textAreaTapAction = nil
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
_tvOSVideoTapAction = nil
|
||||
#endif
|
||||
_listStyle = nil
|
||||
#if os(iOS)
|
||||
_appIcon = nil
|
||||
|
||||
@@ -217,7 +217,7 @@ final class Bookmark {
|
||||
publishedText: publishedText,
|
||||
viewCount: viewCount,
|
||||
likeCount: nil,
|
||||
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
|
||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, width: nil, height: nil)] } ?? [],
|
||||
isLive: isLive,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
|
||||
@@ -120,56 +120,26 @@ extension DataManager {
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Gets all bookmarks matching a video ID. The same ID can exist
|
||||
/// under multiple source scopes; callers that care about a specific
|
||||
/// source must pick the matching entity themselves.
|
||||
func bookmarks(forVideoID videoID: String) -> [Bookmark] {
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a single bookmark without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteBookmark(_ bookmark: Bookmark) {
|
||||
let videoID = bookmark.videoID
|
||||
modelContext.delete(bookmark)
|
||||
save()
|
||||
|
||||
// Keep the fast-lookup cache accurate; another scope may still
|
||||
// have a bookmark with this video ID
|
||||
if self.bookmark(for: videoID) == nil {
|
||||
cachedBookmarkedVideoIDs.remove(videoID)
|
||||
}
|
||||
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||
}
|
||||
|
||||
/// Inserts a bookmark into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote bookmarks.
|
||||
func insertBookmark(_ bookmark: Bookmark) {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// video ID can legitimately exist under different sources
|
||||
// Check for duplicates
|
||||
let videoID = bookmark.videoID
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
let scopeSuffix = bookmark.sourceScopeSuffix
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
cachedBookmarkedVideoIDs.insert(videoID)
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
cachedBookmarkedVideoIDs.insert(videoID)
|
||||
}
|
||||
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||
}
|
||||
|
||||
/// Updates bookmark tags and note for a video.
|
||||
@@ -209,17 +179,3 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension Bookmark {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: globalProvider,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: externalExtractor
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,22 +115,6 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all notification settings matching a channel ID. The same ID can
|
||||
/// exist under multiple source scopes.
|
||||
func allChannelNotificationSettings(forChannelID channelID: String) -> [ChannelNotificationSettings] {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a single notification settings record without queueing a
|
||||
/// CloudKit deletion. Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
|
||||
modelContext.delete(settings)
|
||||
save()
|
||||
}
|
||||
|
||||
/// Deletes notification settings for a channel.
|
||||
/// - Parameter channelID: The channel ID to delete settings for.
|
||||
func deleteNotificationSettings(for channelID: String) {
|
||||
|
||||
@@ -304,30 +304,10 @@ extension DataManager {
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Gets all recent channels matching a channel ID. The same ID can exist
|
||||
/// under multiple source scopes.
|
||||
func recentChannelEntries(forChannelID channelID: String) -> [RecentChannel] {
|
||||
let descriptor = FetchDescriptor<RecentChannel>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a recent channel without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteRecentChannelEntry(_ channel: RecentChannel) {
|
||||
modelContext.delete(channel)
|
||||
save()
|
||||
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Inserts a recent channel (for CloudKit sync).
|
||||
func insertRecentChannel(_ recentChannel: RecentChannel) {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// channel ID can legitimately exist under different sources
|
||||
let scopeSuffix = recentChannel.sourceScopeSuffix
|
||||
let existing = recentChannelEntries(forChannelID: recentChannel.channelID)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
// Check for duplicates by channelID
|
||||
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
|
||||
modelContext.insert(recentChannel)
|
||||
save()
|
||||
}
|
||||
@@ -358,7 +338,7 @@ extension DataManager {
|
||||
existing.title = playlist.title
|
||||
existing.authorName = playlist.authorName
|
||||
existing.videoCount = playlist.videoCount
|
||||
existing.thumbnailURLString = RecentPlaylist.reliableThumbnailURLString(playlist.thumbnailURL)
|
||||
existing.thumbnailURLString = playlist.thumbnailURL?.absoluteString
|
||||
savedEntry = existing
|
||||
} else {
|
||||
// Create new entry
|
||||
@@ -491,58 +471,12 @@ extension DataManager {
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Gets all recent playlists matching a playlist ID. The same ID can
|
||||
/// exist under multiple source scopes.
|
||||
func recentPlaylistEntries(forPlaylistID playlistID: String) -> [RecentPlaylist] {
|
||||
let descriptor = FetchDescriptor<RecentPlaylist>(
|
||||
predicate: #Predicate { $0.playlistID == playlistID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a recent playlist without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteRecentPlaylistEntry(_ playlist: RecentPlaylist) {
|
||||
modelContext.delete(playlist)
|
||||
save()
|
||||
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Inserts a recent playlist (for CloudKit sync).
|
||||
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// playlist ID can legitimately exist under different sources
|
||||
let scopeSuffix = recentPlaylist.sourceScopeSuffix
|
||||
let existing = recentPlaylistEntries(forPlaylistID: recentPlaylist.playlistID)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
// Check for duplicates by playlistID
|
||||
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
|
||||
modelContext.insert(recentPlaylist)
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension RecentChannel {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: nil,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: nil
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
private extension RecentPlaylist {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: nil,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: nil
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,16 +200,6 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all subscriptions matching a channel ID. The same ID can exist
|
||||
/// under multiple source scopes; callers that care about a specific
|
||||
/// source must pick the matching entity themselves.
|
||||
func subscriptions(forChannelID channelID: String) -> [Subscription] {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Gets all subscriptions.
|
||||
func subscriptions() -> [Subscription] {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
@@ -229,17 +219,15 @@ extension DataManager {
|
||||
/// Inserts a subscription into the database.
|
||||
/// Used by SubscriptionService for caching server subscriptions locally.
|
||||
func insertSubscription(_ subscription: Subscription) {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// channel ID can legitimately exist under different sources
|
||||
// Check for duplicates
|
||||
let channelID = subscription.channelID
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
let scopeSuffix = subscription.sourceScopeSuffix
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(subscription)
|
||||
save()
|
||||
}
|
||||
@@ -291,45 +279,6 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes all locally stored subscriptions, including their iCloud copies.
|
||||
/// Server-account subscriptions (Invidious/Piped) are unaffected.
|
||||
func deleteAllSubscriptions() {
|
||||
let allSubscriptions = subscriptions()
|
||||
guard !allSubscriptions.isEmpty else { return }
|
||||
|
||||
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
|
||||
for subscription in allSubscriptions {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: subscription.sourceRawValue,
|
||||
globalProvider: subscription.providerName,
|
||||
instanceURLString: subscription.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
deleteInfo.append((subscription.channelID, scope))
|
||||
modelContext.delete(subscription)
|
||||
}
|
||||
|
||||
save()
|
||||
|
||||
for info in deleteInfo {
|
||||
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
|
||||
}
|
||||
|
||||
SubscriptionFeedCache.shared.invalidate()
|
||||
|
||||
let change = SubscriptionChange(
|
||||
addedSubscriptions: [],
|
||||
removedChannelIDs: deleteInfo.map(\.channelID)
|
||||
)
|
||||
NotificationCenter.default.post(
|
||||
name: .subscriptionsDidChange,
|
||||
object: nil,
|
||||
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||
)
|
||||
|
||||
LoggingService.shared.info("Deleted all \(deleteInfo.count) local subscriptions", category: .general)
|
||||
}
|
||||
|
||||
/// Returns the total count of subscriptions.
|
||||
var subscriptionCount: Int {
|
||||
let descriptor = FetchDescriptor<Subscription>()
|
||||
@@ -445,17 +394,3 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension Subscription {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: providerName,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: nil
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,8 @@ extension DataManager {
|
||||
|
||||
/// Records or updates watch progress locally without triggering iCloud sync.
|
||||
/// Use this for frequent updates during playback to avoid unnecessary sync overhead.
|
||||
///
|
||||
/// - Parameter isLive: Set by the player when the active stream is live but the
|
||||
/// `Video` metadata does not say so (some sources hardcode `isLive: false`).
|
||||
/// Live views are still recorded in history, but without a resume position.
|
||||
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
|
||||
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
|
||||
let videoID = video.id.videoID
|
||||
let isLiveContent = video.isLive || isLive
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
@@ -27,30 +22,16 @@ extension DataManager {
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if let existingEntry = existing.first {
|
||||
if isLiveContent {
|
||||
existingEntry.recordLiveWatch()
|
||||
} else {
|
||||
// Clear the flag first - updateProgress's auto-finish check reads
|
||||
// `progress`, which is hard-0 while isLive is still set
|
||||
existingEntry.isLive = false
|
||||
existingEntry.updateProgress(seconds: seconds, duration: duration)
|
||||
}
|
||||
save()
|
||||
} else {
|
||||
let newEntry = WatchEntry.from(video: video)
|
||||
if !isLiveContent {
|
||||
newEntry.watchedSeconds = seconds
|
||||
if let duration, duration > 0, newEntry.duration == 0 {
|
||||
newEntry.duration = duration
|
||||
}
|
||||
} else {
|
||||
newEntry.isLive = true
|
||||
}
|
||||
modelContext.insert(newEntry)
|
||||
save()
|
||||
// Notify HomeView when a new entry is inserted (not on every progress update)
|
||||
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
||||
}
|
||||
save()
|
||||
// Note: No CloudKit queueing - use updateWatchProgress() when sync is needed
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update watch progress locally", error: error)
|
||||
@@ -59,10 +40,9 @@ extension DataManager {
|
||||
|
||||
/// Records or updates watch progress for a video and queues for iCloud sync.
|
||||
/// Use this when video closes or switches to sync the final progress.
|
||||
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
|
||||
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
|
||||
// Find existing entry or create new one
|
||||
let videoID = video.id.videoID
|
||||
let isLiveContent = video.isLive || isLive
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
@@ -71,25 +51,14 @@ extension DataManager {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
let entry: WatchEntry
|
||||
if let existingEntry = existing.first {
|
||||
if isLiveContent {
|
||||
existingEntry.recordLiveWatch()
|
||||
} else {
|
||||
// Clear the flag first - updateProgress's auto-finish check reads
|
||||
// `progress`, which is hard-0 while isLive is still set
|
||||
existingEntry.isLive = false
|
||||
existingEntry.updateProgress(seconds: seconds, duration: duration)
|
||||
}
|
||||
entry = existingEntry
|
||||
} else {
|
||||
let newEntry = WatchEntry.from(video: video)
|
||||
if !isLiveContent {
|
||||
newEntry.watchedSeconds = seconds
|
||||
if let duration, duration > 0, newEntry.duration == 0 {
|
||||
newEntry.duration = duration
|
||||
}
|
||||
} else {
|
||||
newEntry.isLive = true
|
||||
}
|
||||
modelContext.insert(newEntry)
|
||||
entry = newEntry
|
||||
}
|
||||
@@ -97,7 +66,6 @@ extension DataManager {
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueWatchEntrySave(entry)
|
||||
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update watch progress", error: error)
|
||||
}
|
||||
@@ -157,38 +125,18 @@ extension DataManager {
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Gets all watch entries matching a video ID. The same ID can exist
|
||||
/// under multiple source scopes; callers that care about a specific
|
||||
/// source must pick the matching entity themselves.
|
||||
func watchEntries(forVideoID videoID: String) -> [WatchEntry] {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Deletes a single watch entry without queueing a CloudKit deletion.
|
||||
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||
func deleteWatchEntry(_ entry: WatchEntry) {
|
||||
modelContext.delete(entry)
|
||||
save()
|
||||
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
|
||||
}
|
||||
|
||||
/// Inserts a watch entry into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote watch history.
|
||||
func insertWatchEntry(_ watchEntry: WatchEntry) {
|
||||
// Check for duplicates within the same source scope - the same
|
||||
// video ID can legitimately exist under different sources
|
||||
// Check for duplicates
|
||||
let videoID = watchEntry.videoID
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
let scopeSuffix = watchEntry.sourceScopeSuffix
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(watchEntry)
|
||||
save()
|
||||
}
|
||||
@@ -197,7 +145,6 @@ extension DataManager {
|
||||
modelContext.insert(watchEntry)
|
||||
save()
|
||||
}
|
||||
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
|
||||
}
|
||||
|
||||
/// Clears all watch history.
|
||||
@@ -424,17 +371,3 @@ extension DataManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Scope
|
||||
|
||||
private extension WatchEntry {
|
||||
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||
var sourceScopeSuffix: String {
|
||||
SourceScope.from(
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: globalProvider,
|
||||
instanceURLString: instanceURLString,
|
||||
externalExtractor: externalExtractor
|
||||
).recordNameSuffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,10 @@ final class LocalPlaylist {
|
||||
}
|
||||
|
||||
/// The first video's thumbnail URL for display.
|
||||
///
|
||||
/// Rewritten to the always-available `hqdefault` variant because the stored
|
||||
/// URL is the best advertised quality (often `maxresdefault`), which 404s
|
||||
/// for many older videos and would leave the cover blank.
|
||||
var thumbnailURL: URL? {
|
||||
Thumbnail.reliableURL(
|
||||
for: (items ?? []).sorted { $0.sortOrder < $1.sortOrder }
|
||||
(items ?? []).sorted { $0.sortOrder < $1.sortOrder }
|
||||
.first?
|
||||
.thumbnailURL
|
||||
)
|
||||
}
|
||||
|
||||
/// Sorted items by order.
|
||||
@@ -255,7 +249,7 @@ extension LocalPlaylistItem {
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
|
||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
||||
isLive: isLive,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
|
||||
@@ -57,18 +57,10 @@ final class RecentPlaylist {
|
||||
title: playlist.title,
|
||||
authorName: playlist.authorName,
|
||||
videoCount: playlist.videoCount,
|
||||
thumbnailURLString: reliableThumbnailURLString(playlist.thumbnailURL)
|
||||
thumbnailURLString: playlist.thumbnailURL?.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
/// Rewrites YouTube `/vi/ID/...` thumbnails to the always-available `hqdefault.jpg`
|
||||
/// variant: recent playlist cards render a single URL without fallback, and
|
||||
/// higher-quality variants (`maxresdefault`/`sddefault`) 404 for many older videos.
|
||||
static func reliableThumbnailURLString(_ url: URL?) -> String? {
|
||||
guard let url else { return nil }
|
||||
return (Thumbnail.reliableURL(for: url) ?? url).absoluteString
|
||||
}
|
||||
|
||||
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
|
||||
switch source {
|
||||
case .global:
|
||||
|
||||
@@ -51,10 +51,6 @@ final class WatchEntry {
|
||||
/// Thumbnail URL string.
|
||||
var thumbnailURLString: String?
|
||||
|
||||
/// Whether this entry was recorded for a live stream.
|
||||
/// Live entries never carry a resume position - see `recordLiveWatch()`.
|
||||
var isLive: Bool = false
|
||||
|
||||
// MARK: - Watch Progress
|
||||
|
||||
/// Last watched position in seconds.
|
||||
@@ -90,8 +86,7 @@ final class WatchEntry {
|
||||
duration: TimeInterval,
|
||||
thumbnailURLString: String? = nil,
|
||||
watchedSeconds: TimeInterval = 0,
|
||||
isFinished: Bool = false,
|
||||
isLive: Bool = false
|
||||
isFinished: Bool = false
|
||||
) {
|
||||
self.videoID = videoID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
@@ -107,7 +102,6 @@ final class WatchEntry {
|
||||
self.thumbnailURLString = thumbnailURLString
|
||||
self.watchedSeconds = watchedSeconds
|
||||
self.isFinished = isFinished
|
||||
self.isLive = isLive
|
||||
self.createdAt = Date()
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
@@ -142,10 +136,8 @@ final class WatchEntry {
|
||||
}
|
||||
|
||||
/// Watch progress as a percentage (0.0 to 1.0).
|
||||
/// Always 0 for live streams - their playback position is relative to the
|
||||
/// live edge, so it does not describe progress through a fixed timeline.
|
||||
var progress: Double {
|
||||
guard !isLive, duration > 0 else { return 0 }
|
||||
guard duration > 0 else { return 0 }
|
||||
return min(watchedSeconds / duration, 1.0)
|
||||
}
|
||||
|
||||
@@ -196,20 +188,6 @@ final class WatchEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a live-stream view without storing a resume position.
|
||||
/// Live playback positions are relative to the live edge, so keeping them
|
||||
/// would make the app offer to "continue" from a meaningless timestamp.
|
||||
/// Also clears state a previous non-live save may have left behind, which
|
||||
/// heals entries wrongly marked finished by a drifting HLS duration.
|
||||
func recordLiveWatch() {
|
||||
isLive = true
|
||||
watchedSeconds = 0
|
||||
duration = 0
|
||||
isFinished = false
|
||||
finishedAt = nil
|
||||
updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Marks the video as finished.
|
||||
func markAsFinished() {
|
||||
isFinished = true
|
||||
@@ -241,8 +219,8 @@ extension WatchEntry {
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
|
||||
isLive: isLive,
|
||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
||||
isLive: false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
@@ -282,11 +260,8 @@ extension WatchEntry {
|
||||
title: video.title,
|
||||
authorName: video.author.name,
|
||||
authorID: video.author.id,
|
||||
// Live videos report no usable duration - Invidious sends 0 and Piped
|
||||
// uses -1 as its live sentinel, which must never reach storage.
|
||||
duration: video.isLive ? 0 : max(0, video.duration),
|
||||
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
|
||||
isLive: video.isLive
|
||||
duration: video.duration,
|
||||
thumbnailURLString: video.bestThumbnail?.url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
//
|
||||
// Color+Hex.swift
|
||||
// Yattee
|
||||
//
|
||||
// Hex-string parsing/formatting for SwiftUI Color, plus the environment value
|
||||
// used to drive position-based colorful shortcut cards.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
/// Creates a color from a `#RRGGBB` / `RRGGBB` hex string (also accepts
|
||||
/// `#RGB` shorthand and an optional `AA`/`AARRGGBB`/`RRGGBBAA` alpha).
|
||||
/// Returns `nil` for anything it can't parse so bad input is simply skipped.
|
||||
init?(hex: String) {
|
||||
var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.hasPrefix("#") {
|
||||
cleaned.removeFirst()
|
||||
}
|
||||
guard !cleaned.isEmpty, cleaned.allSatisfy({ $0.isHexDigit }) else { return nil }
|
||||
|
||||
// Expand #RGB shorthand to #RRGGBB.
|
||||
if cleaned.count == 3 {
|
||||
cleaned = cleaned.map { "\($0)\($0)" }.joined()
|
||||
}
|
||||
|
||||
guard let value = UInt64(cleaned, radix: 16) else { return nil }
|
||||
|
||||
let r, g, b, a: Double
|
||||
switch cleaned.count {
|
||||
case 6: // RRGGBB
|
||||
r = Double((value & 0xFF0000) >> 16) / 255
|
||||
g = Double((value & 0x00FF00) >> 8) / 255
|
||||
b = Double(value & 0x0000FF) / 255
|
||||
a = 1
|
||||
case 8: // RRGGBBAA
|
||||
r = Double((value & 0xFF00_0000) >> 24) / 255
|
||||
g = Double((value & 0x00FF_0000) >> 16) / 255
|
||||
b = Double((value & 0x0000_FF00) >> 8) / 255
|
||||
a = Double(value & 0x0000_00FF) / 255
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
self.init(red: r, green: g, blue: b, opacity: a)
|
||||
}
|
||||
|
||||
/// Formats the resolved color as an uppercase `#RRGGBB` string.
|
||||
func toHexString() -> String {
|
||||
let resolved = resolve(in: EnvironmentValues())
|
||||
let r = Int((max(0, min(1, resolved.red)) * 255).rounded())
|
||||
let g = Int((max(0, min(1, resolved.green)) * 255).rounded())
|
||||
let b = Int((max(0, min(1, resolved.blue)) * 255).rounded())
|
||||
return String(format: "#%02X%02X%02X", r, g, b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment: position-based colorful color
|
||||
|
||||
private struct HomeShortcutColorfulColorKey: EnvironmentKey {
|
||||
static let defaultValue: Color? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The colorful-style fill color resolved from the shortcut's grid position.
|
||||
/// When set, it overrides a card's fixed `colorfulColor`.
|
||||
var homeShortcutColorfulColor: Color? {
|
||||
get { self[HomeShortcutColorfulColorKey.self] }
|
||||
set { self[HomeShortcutColorfulColorKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
/// Formats as "M:SS" or "H:MM:SS" when hours > 0.
|
||||
var formattedAsTimestamp: String {
|
||||
let totalSeconds = Int(max(0, self))
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let seconds = totalSeconds % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// ToolbarContent+Close.swift
|
||||
// Yattee
|
||||
//
|
||||
// Shared sheet-dismiss toolbar item.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Standard sheet-dismiss toolbar item.
|
||||
///
|
||||
/// On macOS this renders a native text button (e.g. "Close"), which fits the desktop
|
||||
/// convention. On iOS/tvOS it renders the compact icon-only xmark used by the rest of
|
||||
/// the app's sheets.
|
||||
///
|
||||
/// Placement defaults to `.confirmationAction`; only the *label* changes per platform,
|
||||
/// so it can be dropped into existing `.toolbar { }` blocks without changing layout.
|
||||
@ToolbarContentBuilder
|
||||
func sheetCloseToolbarItem(
|
||||
placement: ToolbarItemPlacement = .confirmationAction,
|
||||
titleKey: LocalizedStringResource = "common.close",
|
||||
identifier: String? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some ToolbarContent {
|
||||
ToolbarItem(placement: placement) {
|
||||
sheetCloseButton(titleKey: titleKey, identifier: identifier, action: action)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sheetCloseButton(
|
||||
titleKey: LocalizedStringResource,
|
||||
identifier: String?,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
let button = Button(role: .cancel, action: action) {
|
||||
#if os(macOS)
|
||||
Text(String(localized: titleKey))
|
||||
#else
|
||||
Label(String(localized: titleKey), systemImage: "xmark")
|
||||
.labelStyle(.iconOnly)
|
||||
#endif
|
||||
}
|
||||
|
||||
if let identifier {
|
||||
button.accessibilityIdentifier(identifier)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// View+SoftScrollEdgeEffect.swift
|
||||
// Yattee
|
||||
//
|
||||
// iOS 27 changed the default scroll edge effect style from soft to hard,
|
||||
// which draws a sharp dividing line under the toolbar. Views that extend
|
||||
// a banner or gradient beneath the toolbar (channel header, video info)
|
||||
// need the soft style so the artwork stays cleanly visible.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Forces the soft (blur/fade) scroll edge effect on the top edge
|
||||
/// for scroll views in this hierarchy. No-op before iOS 26/macOS 26.
|
||||
@ViewBuilder
|
||||
func softTopScrollEdgeEffect() -> some View {
|
||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
||||
scrollEdgeEffectStyle(.soft, for: .top)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,6 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
/// Whether the iOS binary is running on a Mac (Catalyst or "Designed for iPad").
|
||||
/// Zoom transitions trap inside UIKit's _UIZoomTransitionController when a sheet
|
||||
/// is dismissed on the Mac presentation path, so they must stay off there.
|
||||
private let zoomTransitionsRunningOnMac: Bool =
|
||||
ProcessInfo.processInfo.isMacCatalystApp || ProcessInfo.processInfo.isiOSAppOnMac
|
||||
#endif
|
||||
|
||||
// MARK: - Environment Keys
|
||||
|
||||
/// Environment key to pass the navigation transition namespace through the view hierarchy.
|
||||
@@ -57,7 +49,7 @@ struct ZoomTransitionSourceModifier<ID: Hashable>: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
|
||||
if zoomTransitionsEnabled, let namespace {
|
||||
content
|
||||
.matchedTransitionSource(id: id, in: namespace)
|
||||
} else {
|
||||
@@ -83,7 +75,7 @@ struct ZoomTransitionDestinationModifier<ID: Hashable>: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
|
||||
if zoomTransitionsEnabled, let namespace {
|
||||
content
|
||||
.navigationTransition(.zoom(sourceID: id, in: namespace))
|
||||
} else {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// ObjCExceptionHandler.h
|
||||
// Yattee
|
||||
//
|
||||
// Catches ObjC NSExceptions that Swift cannot handle natively.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// Executes a block and catches any NSException thrown.
|
||||
/// Returns YES if the block executed without throwing, NO if an exception was caught.
|
||||
/// If an exception is caught, it is returned via the outException parameter.
|
||||
BOOL tryCatchObjCException(void (NS_NOESCAPE ^block)(void), NSException *_Nullable *_Nullable outException);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,20 +0,0 @@
|
||||
//
|
||||
// ObjCExceptionHandler.m
|
||||
// Yattee
|
||||
//
|
||||
// Catches ObjC NSExceptions that Swift cannot handle natively.
|
||||
//
|
||||
|
||||
#import "ObjCExceptionHandler.h"
|
||||
|
||||
BOOL tryCatchObjCException(void (NS_NOESCAPE ^block)(void), NSException *_Nullable *_Nullable outException) {
|
||||
@try {
|
||||
block();
|
||||
return YES;
|
||||
} @catch (NSException *exception) {
|
||||
if (outException) {
|
||||
*outException = exception;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>stream.yattee.app.feedRefresh</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>stream.yattee.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>yattee</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_yattee._tcp</string>
|
||||
<string>_webdav._tcp</string>
|
||||
<string>_webdavs._tcp</string>
|
||||
<string>_smb._tcp</string>
|
||||
</array>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>stream.yattee.app.activity</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>remote-notification</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -4,15 +4,6 @@
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<!-- Sparkle auto-updates (Developer ID / Homebrew cask build only; ignored on App Store + iOS + tvOS) -->
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://dl.yattee.stream/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4=</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>stream.yattee.app.feedRefresh</string>
|
||||
|
||||
@@ -14,7 +14,6 @@ struct CachedChannelData: Codable {
|
||||
let thumbnailURL: URL?
|
||||
let bannerURL: URL?
|
||||
let subscriberCount: Int?
|
||||
let description: String?
|
||||
|
||||
/// In-memory cache of author data from video detail API responses.
|
||||
@MainActor
|
||||
@@ -33,25 +32,21 @@ struct CachedChannelData: Codable {
|
||||
.appendingPathComponent("authors.json")
|
||||
}
|
||||
|
||||
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?, description: String? = nil) {
|
||||
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?) {
|
||||
self.name = name
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.bannerURL = bannerURL
|
||||
self.subscriberCount = subscriberCount
|
||||
self.description = description
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func cacheAuthor(_ author: Author) {
|
||||
guard !author.id.isEmpty, !author.name.isEmpty else { return }
|
||||
loadFromDiskIfNeeded()
|
||||
let existing = authorCache[author.id]
|
||||
guard author.thumbnailURL != nil || author.subscriberCount != nil else { return }
|
||||
authorCache[author.id] = CachedChannelData(
|
||||
name: author.name,
|
||||
thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
|
||||
bannerURL: existing?.bannerURL,
|
||||
subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
|
||||
description: existing?.description
|
||||
thumbnailURL: author.thumbnailURL,
|
||||
bannerURL: nil,
|
||||
subscriberCount: author.subscriberCount
|
||||
)
|
||||
|
||||
// Evict oldest entries if over limit
|
||||
@@ -106,7 +101,6 @@ struct CachedChannelData: Codable {
|
||||
thumbnailURL = subscription.avatarURL
|
||||
bannerURL = subscription.bannerURL
|
||||
subscriberCount = subscription.subscriberCount
|
||||
description = subscription.channelDescription
|
||||
}
|
||||
|
||||
init(from recentChannel: RecentChannel) {
|
||||
@@ -114,7 +108,6 @@ struct CachedChannelData: Codable {
|
||||
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
|
||||
bannerURL = nil // RecentChannel doesn't store banner
|
||||
subscriberCount = recentChannel.subscriberCount
|
||||
description = nil
|
||||
}
|
||||
|
||||
/// Load cached data for a channel ID from Subscription or RecentChannel.
|
||||
|
||||
@@ -18,11 +18,6 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
|
||||
/// The URL to fetch the caption content
|
||||
let url: URL
|
||||
|
||||
/// Original filename of a user-picked external subtitle file.
|
||||
/// When set, it is used as the display name so two picked files with the
|
||||
/// same language remain distinguishable from API captions and each other.
|
||||
var pickedFileName: String? = nil
|
||||
|
||||
/// Whether this is an auto-generated caption
|
||||
var isAutoGenerated: Bool {
|
||||
label.contains("auto-generated")
|
||||
@@ -42,9 +37,6 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
|
||||
|
||||
/// Formatted display name for the caption
|
||||
var displayName: String {
|
||||
if let pickedFileName {
|
||||
return pickedFileName
|
||||
}
|
||||
// Try to get localized language name (AUTO badge shown separately in UI)
|
||||
if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
|
||||
return localizedName
|
||||
|
||||
@@ -73,19 +73,6 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Whether to allow invalid/self-signed SSL certificates.
|
||||
var allowInvalidCertificates: Bool
|
||||
|
||||
/// Whether to route video streams through this instance instead of connecting directly to YouTube CDN.
|
||||
var proxiesVideos: Bool
|
||||
|
||||
/// Whether this instance sits behind an HTTP Basic Auth reverse proxy.
|
||||
/// Set when basic auth credentials are stored, so a missing Keychain entry
|
||||
/// (e.g. after reinstalling and importing sources from iCloud) can be detected.
|
||||
/// Yattee Server always requires basic auth regardless of this flag.
|
||||
var usesBasicAuth: Bool
|
||||
|
||||
/// Whether the user has logged into an account on this instance (Invidious/Piped).
|
||||
/// Set on login, so a missing Keychain credential can be detected after reinstall.
|
||||
var usesAccountLogin: Bool
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
@@ -96,10 +83,7 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
|
||||
isEnabled: Bool = true,
|
||||
dateAdded: Date = Date(),
|
||||
apiKey: String? = nil,
|
||||
allowInvalidCertificates: Bool = false,
|
||||
proxiesVideos: Bool = false,
|
||||
usesBasicAuth: Bool = false,
|
||||
usesAccountLogin: Bool = false
|
||||
allowInvalidCertificates: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
@@ -109,24 +93,6 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
|
||||
self.dateAdded = dateAdded
|
||||
self.apiKey = apiKey
|
||||
self.allowInvalidCertificates = allowInvalidCertificates
|
||||
self.proxiesVideos = proxiesVideos
|
||||
self.usesBasicAuth = usesBasicAuth
|
||||
self.usesAccountLogin = usesAccountLogin
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
type = try container.decode(InstanceType.self, forKey: .type)
|
||||
url = try container.decode(URL.self, forKey: .url)
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
|
||||
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
|
||||
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
|
||||
allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates)
|
||||
proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false
|
||||
usesBasicAuth = try container.decodeIfPresent(Bool.self, forKey: .usesBasicAuth) ?? false
|
||||
usesAccountLogin = try container.decodeIfPresent(Bool.self, forKey: .usesAccountLogin) ?? false
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
@@ -135,17 +101,6 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
|
||||
name ?? url.host ?? url.absoluteString
|
||||
}
|
||||
|
||||
/// Returns the URL string with embedded credentials stripped for safe display in the UI.
|
||||
var displayURL: String {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.user != nil else {
|
||||
return url.absoluteString
|
||||
}
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
return components.url?.absoluteString ?? url.absoluteString
|
||||
}
|
||||
|
||||
var contentSource: ContentSource {
|
||||
type.contentSource(for: url)
|
||||
}
|
||||
@@ -193,27 +148,6 @@ extension Instance {
|
||||
var supportsPopular: Bool {
|
||||
type == .invidious || type == .yatteeServer
|
||||
}
|
||||
|
||||
/// Whether this instance supports proxying video streams through itself.
|
||||
var supportsVideoProxying: Bool {
|
||||
type == .invidious || type == .piped || type == .yatteeServer
|
||||
}
|
||||
|
||||
/// Whether this instance can sit behind an HTTP Basic Auth reverse proxy.
|
||||
/// Piped is excluded: its session token is sent in the same `Authorization`
|
||||
/// header that the proxy would consume, so logged-in features can't coexist
|
||||
/// with proxy credentials.
|
||||
var supportsHTTPBasicAuthProxy: Bool {
|
||||
type.supportsHTTPBasicAuthProxy
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceType {
|
||||
/// See `Instance.supportsHTTPBasicAuthProxy`. Type-only variant for flows
|
||||
/// (e.g., AddRemoteServer detection) that don't yet have an `Instance`.
|
||||
var supportsHTTPBasicAuthProxy: Bool {
|
||||
self != .piped
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instance Validation
|
||||
@@ -311,6 +245,10 @@ extension Instance {
|
||||
components.path = String(components.path.dropLast())
|
||||
}
|
||||
|
||||
// Strip embedded credentials (security best practice)
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
//
|
||||
// MPVTrack.swift
|
||||
// Yattee
|
||||
//
|
||||
// Model representing one entry of mpv's `track-list` property.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A single track reported by mpv's `track-list` property (embedded or external).
|
||||
struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
|
||||
enum TrackType: String, Decodable, Sendable {
|
||||
case video
|
||||
case audio
|
||||
case sub
|
||||
}
|
||||
|
||||
/// mpv track id — unique only within a track type.
|
||||
let trackID: Int
|
||||
let type: TrackType
|
||||
let title: String?
|
||||
let lang: String?
|
||||
let isDefault: Bool
|
||||
let isForced: Bool
|
||||
/// True for tracks loaded via `sub-add`/`audio-add` (external files).
|
||||
let isExternal: Bool
|
||||
/// Whether mpv currently plays this track.
|
||||
let isSelected: Bool
|
||||
/// Cover-art pseudo video tracks.
|
||||
let isAlbumArt: Bool
|
||||
let codec: String?
|
||||
let channelCount: Int?
|
||||
let sampleRate: Int?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
let fps: Double?
|
||||
|
||||
/// Identifiable across types — mpv ids collide between audio/sub/video.
|
||||
var id: String { "\(type.rawValue):\(trackID)" }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case trackID = "id"
|
||||
case type
|
||||
case title
|
||||
case lang
|
||||
case isDefault = "default"
|
||||
case isForced = "forced"
|
||||
case isExternal = "external"
|
||||
case isSelected = "selected"
|
||||
case isAlbumArt = "albumart"
|
||||
case codec
|
||||
case channelCount = "demux-channel-count"
|
||||
case sampleRate = "demux-samplerate"
|
||||
case width = "demux-w"
|
||||
case height = "demux-h"
|
||||
case fps = "demux-fps"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
trackID = try container.decode(Int.self, forKey: .trackID)
|
||||
type = try container.decode(TrackType.self, forKey: .type)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
lang = try container.decodeIfPresent(String.self, forKey: .lang)
|
||||
isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
|
||||
isForced = try container.decodeIfPresent(Bool.self, forKey: .isForced) ?? false
|
||||
isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
|
||||
isSelected = try container.decodeIfPresent(Bool.self, forKey: .isSelected) ?? false
|
||||
isAlbumArt = try container.decodeIfPresent(Bool.self, forKey: .isAlbumArt) ?? false
|
||||
codec = try container.decodeIfPresent(String.self, forKey: .codec)
|
||||
channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount)
|
||||
sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate)
|
||||
width = try container.decodeIfPresent(Int.self, forKey: .width)
|
||||
height = try container.decodeIfPresent(Int.self, forKey: .height)
|
||||
fps = try container.decodeIfPresent(Double.self, forKey: .fps)
|
||||
}
|
||||
|
||||
init(
|
||||
trackID: Int,
|
||||
type: TrackType,
|
||||
title: String? = nil,
|
||||
lang: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
isForced: Bool = false,
|
||||
isExternal: Bool = false,
|
||||
isSelected: Bool = false,
|
||||
isAlbumArt: Bool = false,
|
||||
codec: String? = nil,
|
||||
channelCount: Int? = nil,
|
||||
sampleRate: Int? = nil,
|
||||
width: Int? = nil,
|
||||
height: Int? = nil,
|
||||
fps: Double? = nil
|
||||
) {
|
||||
self.trackID = trackID
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.lang = lang
|
||||
self.isDefault = isDefault
|
||||
self.isForced = isForced
|
||||
self.isExternal = isExternal
|
||||
self.isSelected = isSelected
|
||||
self.isAlbumArt = isAlbumArt
|
||||
self.codec = codec
|
||||
self.channelCount = channelCount
|
||||
self.sampleRate = sampleRate
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.fps = fps
|
||||
}
|
||||
|
||||
/// Base language code normalized to the 2-letter form when possible, so
|
||||
/// Matroska 3-letter codes ("eng") match preference values ("en").
|
||||
var baseLanguageCode: String? {
|
||||
guard let lang, !lang.isEmpty, lang != "und" else { return nil }
|
||||
var code = lang.lowercased()
|
||||
if let hyphenIndex = code.firstIndex(of: "-") {
|
||||
code = String(code[..<hyphenIndex])
|
||||
}
|
||||
guard code.count == 3 else { return code }
|
||||
// ISO 639-2 -> 639-1 where a 2-letter code exists ("eng" -> "en").
|
||||
return Locale.LanguageCode(code).identifier(.alpha2) ?? code
|
||||
}
|
||||
|
||||
/// "Director's Commentary", "English", or a numbered fallback.
|
||||
var displayName: String {
|
||||
if let title, !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
if let baseLanguageCode,
|
||||
let localized = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
|
||||
return localized
|
||||
}
|
||||
return String(localized: "player.track.number \(trackID)")
|
||||
}
|
||||
|
||||
/// Whether this track's language matches a user preference code like "en".
|
||||
func matchesLanguage(_ preferredCode: String?) -> Bool {
|
||||
guard let preferredCode, !preferredCode.isEmpty,
|
||||
let baseLanguageCode else { return false }
|
||||
var preferred = preferredCode.lowercased()
|
||||
if let hyphenIndex = preferred.firstIndex(of: "-") {
|
||||
preferred = String(preferred[..<hyphenIndex])
|
||||
}
|
||||
if preferred.count == 3 {
|
||||
preferred = Locale.LanguageCode(preferred).identifier(.alpha2) ?? preferred
|
||||
}
|
||||
return preferred == baseLanguageCode
|
||||
}
|
||||
|
||||
/// Secondary detail line for advanced mode, e.g. "eac3 · 6ch · 48 kHz".
|
||||
var detailText: String? {
|
||||
var parts: [String] = []
|
||||
if let codec, !codec.isEmpty {
|
||||
parts.append(codec)
|
||||
}
|
||||
if let channelCount {
|
||||
parts.append("\(channelCount)ch")
|
||||
}
|
||||
if let sampleRate {
|
||||
parts.append("\(sampleRate / 1000) kHz")
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -67,14 +67,7 @@ struct MediaFile: Identifiable, Hashable, Sendable {
|
||||
|
||||
/// Full URL to this file.
|
||||
var url: URL {
|
||||
// For local folders, the persisted `source.url` may point at a stale app
|
||||
// container path after iOS reinstall/restore. Prefer the freshly resolved
|
||||
// bookmark URL stored in `LocalFolderURLResolver` when available.
|
||||
if source.type == .localFolder,
|
||||
let resolved = LocalFolderURLResolver.resolvedURL(for: source.id) {
|
||||
return resolved.appendingPathComponent(path)
|
||||
}
|
||||
return source.url.appendingPathComponent(path)
|
||||
source.url.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
/// File extension (lowercase).
|
||||
|
||||
@@ -15,8 +15,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
case sources
|
||||
case settings
|
||||
case nowPlaying
|
||||
case openURL
|
||||
case remoteControl
|
||||
|
||||
// MARK: - Dynamic Channel Items
|
||||
case channel(channelID: String, name: String, source: ContentSource)
|
||||
@@ -36,8 +34,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
case downloads
|
||||
case subscriptionsFeed
|
||||
case manageChannels
|
||||
case playlistsList
|
||||
case continueWatching
|
||||
|
||||
// MARK: - Identifiable
|
||||
|
||||
@@ -53,10 +49,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return "settings"
|
||||
case .nowPlaying:
|
||||
return "now-playing"
|
||||
case .openURL:
|
||||
return "open-url"
|
||||
case .remoteControl:
|
||||
return "remote-control"
|
||||
case .channel(let channelID, _, let source):
|
||||
return "channel-\(source.provider)-\(channelID)"
|
||||
case .playlist(let id, _):
|
||||
@@ -75,10 +67,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return "subscriptions-feed"
|
||||
case .manageChannels:
|
||||
return "manage-channels"
|
||||
case .playlistsList:
|
||||
return "playlists-list"
|
||||
case .continueWatching:
|
||||
return "continue-watching"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +84,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return String(localized: "tabs.settings")
|
||||
case .nowPlaying:
|
||||
return String(localized: "sidebar.nowPlaying")
|
||||
case .openURL:
|
||||
return String(localized: "sidebar.mainItem.openURL")
|
||||
case .remoteControl:
|
||||
return String(localized: "sidebar.mainItem.remoteControl")
|
||||
case .channel(_, let name, _):
|
||||
return name
|
||||
case .playlist(_, let title):
|
||||
@@ -118,10 +102,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return String(localized: "home.subscriptions.title")
|
||||
case .manageChannels:
|
||||
return String(localized: "sidebar.manageChannels")
|
||||
case .playlistsList:
|
||||
return String(localized: "home.playlists.title")
|
||||
case .continueWatching:
|
||||
return String(localized: "home.continueWatching.title")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,10 +117,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return "gear"
|
||||
case .nowPlaying:
|
||||
return "play.circle.fill"
|
||||
case .openURL:
|
||||
return "link"
|
||||
case .remoteControl:
|
||||
return "antenna.radiowaves.left.and.right"
|
||||
case .channel:
|
||||
return "person.circle"
|
||||
case .playlist:
|
||||
@@ -159,10 +135,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return "play.square.stack.fill"
|
||||
case .manageChannels:
|
||||
return "person.2"
|
||||
case .playlistsList:
|
||||
return "list.bullet.rectangle"
|
||||
case .continueWatching:
|
||||
return "play.circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +144,7 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
/// Returns nil for items that are root views (home, search) which don't push.
|
||||
func navigationDestination() -> NavigationDestination? {
|
||||
switch self {
|
||||
case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl:
|
||||
case .home, .search, .sources, .settings, .nowPlaying:
|
||||
// These are root tabs, not push destinations
|
||||
return nil
|
||||
case .channel(let channelID, _, let source):
|
||||
@@ -194,10 +166,6 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
return .subscriptionsFeed
|
||||
case .manageChannels:
|
||||
return .manageChannels
|
||||
case .playlistsList:
|
||||
return .playlists
|
||||
case .continueWatching:
|
||||
return .continueWatching
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +174,7 @@ enum SidebarItem: Hashable, Identifiable {
|
||||
/// Whether this is a fixed navigation item (always visible).
|
||||
var isFixedNavigation: Bool {
|
||||
switch self {
|
||||
case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl:
|
||||
case .home, .search, .sources, .settings, .nowPlaying:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -16,23 +16,18 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case history
|
||||
case downloads
|
||||
case channels
|
||||
case playlists
|
||||
case sources
|
||||
case settings
|
||||
case openURL
|
||||
case remoteControl
|
||||
case continueWatching
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Default order for sidebar main items.
|
||||
static var defaultOrder: [SidebarMainItem] {
|
||||
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .playlists, .sources, .openURL, .remoteControl, .downloads, .continueWatching, .settings]
|
||||
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .sources, .downloads, .settings]
|
||||
}
|
||||
|
||||
/// Default visibility (all visible except subscriptions and channels).
|
||||
static var defaultVisibility: [SidebarMainItem: Bool] {
|
||||
#if os(tvOS)
|
||||
[
|
||||
.search: true,
|
||||
.home: true,
|
||||
@@ -41,30 +36,9 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
.history: false,
|
||||
.downloads: true,
|
||||
.channels: false,
|
||||
.playlists: false,
|
||||
.sources: true,
|
||||
.settings: true,
|
||||
.openURL: false,
|
||||
.remoteControl: true,
|
||||
.continueWatching: false
|
||||
.settings: true
|
||||
]
|
||||
#else
|
||||
[
|
||||
.search: true,
|
||||
.home: true,
|
||||
.subscriptions: false,
|
||||
.bookmarks: false,
|
||||
.history: false,
|
||||
.downloads: true,
|
||||
.channels: false,
|
||||
.playlists: false,
|
||||
.sources: true,
|
||||
.settings: true,
|
||||
.openURL: false,
|
||||
.remoteControl: false,
|
||||
.continueWatching: false
|
||||
]
|
||||
#endif
|
||||
}
|
||||
|
||||
/// SF Symbol icon name.
|
||||
@@ -77,12 +51,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .history: "clock"
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .channels: "person.2"
|
||||
case .playlists: "list.bullet.rectangle"
|
||||
case .sources: "server.rack"
|
||||
case .settings: "gear"
|
||||
case .openURL: "link"
|
||||
case .remoteControl: "antenna.radiowaves.left.and.right"
|
||||
case .continueWatching: "play.circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +66,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .history: String(localized: "sidebar.mainItem.history")
|
||||
case .downloads: String(localized: "sidebar.mainItem.downloads")
|
||||
case .channels: String(localized: "sidebar.mainItem.channels")
|
||||
case .playlists: String(localized: "sidebar.mainItem.playlists")
|
||||
case .sources: String(localized: "sidebar.mainItem.sources")
|
||||
case .settings: String(localized: "sidebar.mainItem.settings")
|
||||
case .openURL: String(localized: "sidebar.mainItem.openURL")
|
||||
case .remoteControl: String(localized: "sidebar.mainItem.remoteControl")
|
||||
case .continueWatching: String(localized: "sidebar.mainItem.continueWatching")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +90,6 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
case .settings:
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -148,12 +108,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .history: return TabBarItem.history.rawValue
|
||||
case .downloads: return TabBarItem.downloads.rawValue
|
||||
case .channels: return TabBarItem.channels.rawValue
|
||||
case .playlists: return TabBarItem.playlists.rawValue
|
||||
case .sources: return TabBarItem.sources.rawValue
|
||||
case .settings: return TabBarItem.settings.rawValue
|
||||
case .openURL: return "open-url"
|
||||
case .remoteControl: return "remote-control"
|
||||
case .continueWatching: return "continue-watching"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,12 +123,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .history: return .history
|
||||
case .downloads: return .downloads
|
||||
case .channels: return .manageChannels
|
||||
case .playlists: return .playlistsList
|
||||
case .sources: return .sources
|
||||
case .settings: return .settings
|
||||
case .openURL: return .openURL
|
||||
case .remoteControl: return .remoteControl
|
||||
case .continueWatching: return .continueWatching
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,12 +134,11 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .subscriptions: self = .subscriptions
|
||||
case .channels: self = .channels
|
||||
case .bookmarks: self = .bookmarks
|
||||
case .playlists: self = .playlists
|
||||
case .playlists: return nil // No direct mapping - playlists isn't a SidebarMainItem
|
||||
case .history: self = .history
|
||||
case .downloads: self = .downloads
|
||||
case .sources: self = .sources
|
||||
case .settings: self = .settings
|
||||
case .continueWatching: self = .continueWatching
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,17 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case downloads
|
||||
case sources
|
||||
case settings
|
||||
case continueWatching
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Default order for tab bar items.
|
||||
static var defaultOrder: [TabBarItem] {
|
||||
[.subscriptions, .channels, .bookmarks, .playlists, .history, .continueWatching, .sources, .downloads, .settings]
|
||||
[.subscriptions, .channels, .bookmarks, .playlists, .history, .sources, .downloads, .settings]
|
||||
}
|
||||
|
||||
/// Default visibility (only subscriptions visible by default).
|
||||
static var defaultVisibility: [TabBarItem: Bool] {
|
||||
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false, .continueWatching: false]
|
||||
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false]
|
||||
}
|
||||
|
||||
/// SF Symbol icon name.
|
||||
@@ -42,7 +41,6 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .sources: "server.rack"
|
||||
case .settings: "gear"
|
||||
case .continueWatching: "play.circle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +55,6 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case .downloads: String(localized: "tabBar.item.downloads")
|
||||
case .sources: String(localized: "tabBar.item.sources")
|
||||
case .settings: String(localized: "tabBar.item.settings")
|
||||
case .continueWatching: String(localized: "tabBar.item.continueWatching")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,6 @@ import Foundation
|
||||
/// Closure type for loading more videos via continuation
|
||||
typealias LoadMoreVideosCallback = @Sendable () async throws -> ([Video], String?)
|
||||
|
||||
/// Extra payload required to play files from a media source (WebDAV/SMB/local folder)
|
||||
/// via `QueueManager.playFromMediaBrowser`. Without this, playback falls back to
|
||||
/// `openVideo` / `playFromList`, which do not set up on-demand stream and caption
|
||||
/// resolution for media-browser files.
|
||||
struct MediaBrowserPlaybackInfo: Equatable, Hashable {
|
||||
let source: MediaSource
|
||||
let allFilesInFolder: [MediaFile]
|
||||
}
|
||||
|
||||
/// Context information for playing a video with queue support.
|
||||
/// Used when navigating from list views (subscriptions, search, etc.) to video info pages.
|
||||
struct VideoQueueContext {
|
||||
@@ -44,10 +35,6 @@ struct VideoQueueContext {
|
||||
/// Returns new videos and updated continuation token
|
||||
let loadMoreVideos: LoadMoreVideosCallback?
|
||||
|
||||
/// When set, playback from VideoInfoView must route through
|
||||
/// `QueueManager.playFromMediaBrowser` using this payload.
|
||||
var mediaBrowserPlayback: MediaBrowserPlaybackInfo? = nil
|
||||
|
||||
/// Whether this context has valid queue information
|
||||
var hasQueueInfo: Bool {
|
||||
videoList != nil && videoIndex != nil
|
||||
@@ -90,8 +77,7 @@ extension VideoQueueContext: Equatable {
|
||||
lhs.sourceLabel == rhs.sourceLabel &&
|
||||
lhs.videoList == rhs.videoList &&
|
||||
lhs.videoIndex == rhs.videoIndex &&
|
||||
lhs.startTime == rhs.startTime &&
|
||||
lhs.mediaBrowserPlayback == rhs.mediaBrowserPlayback
|
||||
lhs.startTime == rhs.startTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +89,5 @@ extension VideoQueueContext: Hashable {
|
||||
hasher.combine(videoList)
|
||||
hasher.combine(videoIndex)
|
||||
hasher.combine(startTime)
|
||||
hasher.combine(mediaBrowserPlayback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ extension LayoutPreset {
|
||||
/// Bump this version whenever any built-in preset definition changes.
|
||||
/// On launch, the app compares this against the last-applied version
|
||||
/// and replaces stale built-in presets with fresh copies from code.
|
||||
static let builtInPresetsVersion = 8
|
||||
static let builtInPresetsVersion = 5
|
||||
|
||||
// MARK: - Built-in Preset IDs
|
||||
|
||||
@@ -25,19 +25,6 @@ extension LayoutPreset {
|
||||
|
||||
/// Default preset with balanced controls for general use.
|
||||
static func defaultPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
|
||||
LayoutPreset(
|
||||
id: BuiltInID.defaultPreset,
|
||||
name: String(localized: "controls.preset.default"),
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
isBuiltIn: true,
|
||||
deviceClass: deviceClass,
|
||||
layout: deviceClass == .macOS ? macOSDefaultLayout() : standardDefaultLayout()
|
||||
)
|
||||
}
|
||||
|
||||
/// Default layout for iOS/tvOS (touch-oriented overlay controls).
|
||||
private static func standardDefaultLayout() -> PlayerControlsLayout {
|
||||
// Top buttons: titleAuthor (wideOnly), spacer, orientationLock, close
|
||||
let topButtons: [ControlButtonConfiguration] = [
|
||||
ControlButtonConfiguration(
|
||||
@@ -123,7 +110,7 @@ extension LayoutPreset {
|
||||
// Mini player: show video, tap for PiP
|
||||
let miniPlayerSettings = MiniPlayerSettings()
|
||||
|
||||
return PlayerControlsLayout(
|
||||
let layout = PlayerControlsLayout(
|
||||
topSection: LayoutSection(buttons: topButtons),
|
||||
centerSettings: centerSettings,
|
||||
bottomSection: LayoutSection(buttons: bottomButtons),
|
||||
@@ -133,84 +120,20 @@ extension LayoutPreset {
|
||||
playerPillSettings: playerPillSettings,
|
||||
miniPlayerSettings: miniPlayerSettings
|
||||
)
|
||||
}
|
||||
|
||||
/// Default layout for macOS, mirroring the QuickTime-style control bar:
|
||||
/// top bar with title/author, keep-on-top pin and close; capsule row with
|
||||
/// volume slider, transport and trailing actions around flexible spacers.
|
||||
private static func macOSDefaultLayout() -> PlayerControlsLayout {
|
||||
let topButtons: [ControlButtonConfiguration] = [
|
||||
.defaultConfiguration(for: .titleAuthor),
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .keepOnTop),
|
||||
.defaultConfiguration(for: .close)
|
||||
]
|
||||
|
||||
let bottomButtons: [ControlButtonConfiguration] = [
|
||||
ControlButtonConfiguration(
|
||||
buttonType: .volume,
|
||||
settings: .slider(SliderSettings(sliderBehavior: .alwaysVisible))
|
||||
),
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .queue),
|
||||
.defaultConfiguration(for: .playPrevious),
|
||||
.defaultConfiguration(for: .playPause),
|
||||
.defaultConfiguration(for: .playNext),
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .contextMenu),
|
||||
.defaultConfiguration(for: .settings),
|
||||
.defaultConfiguration(for: .pictureInPicture),
|
||||
.defaultConfiguration(for: .fullscreen)
|
||||
]
|
||||
|
||||
// On macOS center settings only drive seek amounts (keyboard arrows
|
||||
// and default seek buttons).
|
||||
let centerSettings = CenterSectionSettings(
|
||||
showPlayPause: true,
|
||||
showSeekBackward: true,
|
||||
showSeekForward: true,
|
||||
seekBackwardSeconds: 10,
|
||||
seekForwardSeconds: 10,
|
||||
leftSlider: .disabled,
|
||||
rightSlider: .disabled
|
||||
)
|
||||
|
||||
let globalSettings = GlobalLayoutSettings(
|
||||
style: .plain,
|
||||
buttonSize: .medium,
|
||||
fontStyle: .system,
|
||||
systemControlsMode: .seek,
|
||||
systemControlsSeekDuration: .tenSeconds,
|
||||
volumeMode: .mpv
|
||||
)
|
||||
|
||||
return PlayerControlsLayout(
|
||||
topSection: LayoutSection(buttons: topButtons),
|
||||
centerSettings: centerSettings,
|
||||
bottomSection: LayoutSection(buttons: bottomButtons),
|
||||
globalSettings: globalSettings,
|
||||
progressBarSettings: ProgressBarSettings(),
|
||||
gesturesSettings: nil,
|
||||
playerPillSettings: nil,
|
||||
miniPlayerSettings: MiniPlayerSettings()
|
||||
return LayoutPreset(
|
||||
id: BuiltInID.defaultPreset,
|
||||
name: String(localized: "controls.preset.default"),
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
isBuiltIn: true,
|
||||
deviceClass: deviceClass,
|
||||
layout: layout
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal preset with stripped-down controls for distraction-free playback.
|
||||
static func minimalPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
|
||||
LayoutPreset(
|
||||
id: BuiltInID.minimalPreset,
|
||||
name: String(localized: "controls.preset.minimal"),
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
isBuiltIn: true,
|
||||
deviceClass: deviceClass,
|
||||
layout: deviceClass == .macOS ? macOSMinimalLayout() : standardMinimalLayout()
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal layout for iOS/tvOS.
|
||||
private static func standardMinimalLayout() -> PlayerControlsLayout {
|
||||
let topButtons: [ControlButtonConfiguration] = [
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .close)
|
||||
@@ -277,7 +200,7 @@ extension LayoutPreset {
|
||||
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
|
||||
)
|
||||
|
||||
return PlayerControlsLayout(
|
||||
let layout = PlayerControlsLayout(
|
||||
topSection: LayoutSection(buttons: topButtons),
|
||||
centerSettings: centerSettings,
|
||||
bottomSection: LayoutSection(buttons: bottomButtons),
|
||||
@@ -287,56 +210,15 @@ extension LayoutPreset {
|
||||
playerPillSettings: playerPillSettings,
|
||||
miniPlayerSettings: miniPlayerSettings
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal layout for macOS: bare play/pause and settings in the capsule,
|
||||
/// close-only top bar, no chapters or SponsorBlock markers.
|
||||
private static func macOSMinimalLayout() -> PlayerControlsLayout {
|
||||
let topButtons: [ControlButtonConfiguration] = [
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .close)
|
||||
]
|
||||
|
||||
let bottomButtons: [ControlButtonConfiguration] = [
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .playPause),
|
||||
.flexibleSpacer(),
|
||||
.defaultConfiguration(for: .settings)
|
||||
]
|
||||
|
||||
let centerSettings = CenterSectionSettings(
|
||||
showPlayPause: true,
|
||||
showSeekBackward: true,
|
||||
showSeekForward: true,
|
||||
seekBackwardSeconds: 10,
|
||||
seekForwardSeconds: 10,
|
||||
leftSlider: .disabled,
|
||||
rightSlider: .disabled
|
||||
)
|
||||
|
||||
let globalSettings = GlobalLayoutSettings(
|
||||
style: .plain,
|
||||
buttonSize: .medium,
|
||||
fontStyle: .system,
|
||||
systemControlsMode: .seek,
|
||||
systemControlsSeekDuration: .tenSeconds,
|
||||
volumeMode: .mpv
|
||||
)
|
||||
|
||||
let progressBarSettings = ProgressBarSettings(
|
||||
showChapters: false,
|
||||
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
|
||||
)
|
||||
|
||||
return PlayerControlsLayout(
|
||||
topSection: LayoutSection(buttons: topButtons),
|
||||
centerSettings: centerSettings,
|
||||
bottomSection: LayoutSection(buttons: bottomButtons),
|
||||
globalSettings: globalSettings,
|
||||
progressBarSettings: progressBarSettings,
|
||||
gesturesSettings: nil,
|
||||
playerPillSettings: nil,
|
||||
miniPlayerSettings: MiniPlayerSettings()
|
||||
return LayoutPreset(
|
||||
id: BuiltInID.minimalPreset,
|
||||
name: String(localized: "controls.preset.minimal"),
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
isBuiltIn: true,
|
||||
deviceClass: deviceClass,
|
||||
layout: layout
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
|
||||
/// Number of seconds for the seek forward button.
|
||||
var seekForwardSeconds: Int
|
||||
|
||||
/// Number of seconds for the secondary seek backward shortcut (⇧ + seek).
|
||||
var secondarySeekBackwardSeconds: Int
|
||||
|
||||
/// Number of seconds for the secondary seek forward shortcut (⇧ + seek).
|
||||
var secondarySeekForwardSeconds: Int
|
||||
|
||||
/// Type of slider to show on the left edge of the player (iOS only).
|
||||
var leftSlider: SideSliderType
|
||||
|
||||
@@ -46,8 +40,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
|
||||
/// - showSeekForward: Show seek forward button. Defaults to true.
|
||||
/// - seekBackwardSeconds: Seconds to seek backward. Defaults to 10.
|
||||
/// - seekForwardSeconds: Seconds to seek forward. Defaults to 10.
|
||||
/// - secondarySeekBackwardSeconds: Seconds for the secondary (⇧) backward seek. Defaults to 30.
|
||||
/// - secondarySeekForwardSeconds: Seconds for the secondary (⇧) forward seek. Defaults to 30.
|
||||
/// - leftSlider: Type of slider on left edge. Defaults to disabled.
|
||||
/// - rightSlider: Type of slider on right edge. Defaults to disabled.
|
||||
init(
|
||||
@@ -56,8 +48,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
|
||||
showSeekForward: Bool = true,
|
||||
seekBackwardSeconds: Int = 10,
|
||||
seekForwardSeconds: Int = 10,
|
||||
secondarySeekBackwardSeconds: Int = 30,
|
||||
secondarySeekForwardSeconds: Int = 30,
|
||||
leftSlider: SideSliderType = .disabled,
|
||||
rightSlider: SideSliderType = .disabled
|
||||
) {
|
||||
@@ -66,8 +56,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
|
||||
self.showSeekForward = showSeekForward
|
||||
self.seekBackwardSeconds = max(1, seekBackwardSeconds)
|
||||
self.seekForwardSeconds = max(1, seekForwardSeconds)
|
||||
self.secondarySeekBackwardSeconds = max(1, secondarySeekBackwardSeconds)
|
||||
self.secondarySeekForwardSeconds = max(1, secondarySeekForwardSeconds)
|
||||
self.leftSlider = leftSlider
|
||||
self.rightSlider = rightSlider
|
||||
}
|
||||
@@ -80,8 +68,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
|
||||
case showSeekForward
|
||||
case seekBackwardSeconds
|
||||
case seekForwardSeconds
|
||||
case secondarySeekBackwardSeconds
|
||||
case secondarySeekForwardSeconds
|
||||
case leftSlider
|
||||
case rightSlider
|
||||
}
|
||||
@@ -94,8 +80,6 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
|
||||
seekBackwardSeconds = try container.decode(Int.self, forKey: .seekBackwardSeconds)
|
||||
seekForwardSeconds = try container.decode(Int.self, forKey: .seekForwardSeconds)
|
||||
// New properties with defaults for backward compatibility
|
||||
secondarySeekBackwardSeconds = try container.decodeIfPresent(Int.self, forKey: .secondarySeekBackwardSeconds) ?? 30
|
||||
secondarySeekForwardSeconds = try container.decodeIfPresent(Int.self, forKey: .secondarySeekForwardSeconds) ?? 30
|
||||
leftSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .leftSlider) ?? .disabled
|
||||
rightSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .rightSlider) ?? .disabled
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case titleAuthor
|
||||
case panscan
|
||||
case autoPlayNext
|
||||
case audioMode
|
||||
case seek
|
||||
case keepOnTop
|
||||
|
||||
// MARK: - Version Tracking
|
||||
|
||||
@@ -117,12 +115,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
return String(localized: "controls.button.panscan")
|
||||
case .autoPlayNext:
|
||||
return String(localized: "controls.button.autoPlayNext")
|
||||
case .audioMode:
|
||||
return String(localized: "controls.button.audioMode")
|
||||
case .seek:
|
||||
return String(localized: "controls.button.seek")
|
||||
case .keepOnTop:
|
||||
return String(localized: "controls.button.keepOnTop")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,12 +183,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
return "arrow.left.and.right.square"
|
||||
case .autoPlayNext:
|
||||
return "play.square.stack.fill"
|
||||
case .audioMode:
|
||||
return "music.note"
|
||||
case .seek:
|
||||
return "goforward.10" // Default icon, actual icon is determined by settings
|
||||
case .keepOnTop:
|
||||
return "pin"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,31 +224,6 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
|
||||
/// Button types available for top/bottom sections.
|
||||
static var availableForHorizontalSections: [ControlButtonType] {
|
||||
#if os(macOS)
|
||||
[
|
||||
.spacer,
|
||||
.timeDisplay,
|
||||
.titleAuthor,
|
||||
.playPrevious,
|
||||
.playPause,
|
||||
.playNext,
|
||||
.seek,
|
||||
.queue,
|
||||
.close,
|
||||
.keepOnTop,
|
||||
.volume,
|
||||
.pictureInPicture,
|
||||
.fullscreen,
|
||||
.controlsLock,
|
||||
.settings,
|
||||
.playbackSpeed,
|
||||
.share,
|
||||
.contextMenu,
|
||||
.autoPlayNext,
|
||||
.audioMode,
|
||||
.mpvDebug
|
||||
]
|
||||
#else
|
||||
[
|
||||
.spacer,
|
||||
.timeDisplay,
|
||||
@@ -287,11 +252,9 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
.panelToggle,
|
||||
.panscan,
|
||||
.autoPlayNext,
|
||||
.audioMode,
|
||||
.airplay,
|
||||
.mpvDebug
|
||||
]
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Button types for center section (play/pause, seek).
|
||||
@@ -324,25 +287,6 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
|
||||
/// Button types available for the mini player (curated subset for compact UI).
|
||||
static var availableForMiniPlayer: [ControlButtonType] {
|
||||
#if os(macOS)
|
||||
[
|
||||
// Transport
|
||||
.playPause,
|
||||
.playPrevious,
|
||||
.playNext,
|
||||
.seek,
|
||||
// Queue & Actions
|
||||
.queue,
|
||||
.close,
|
||||
// Player Actions
|
||||
.share,
|
||||
.addToPlaylist,
|
||||
.pictureInPicture,
|
||||
// Utility
|
||||
.playbackSpeed,
|
||||
.audioMode
|
||||
]
|
||||
#else
|
||||
[
|
||||
// Transport
|
||||
.playPause,
|
||||
@@ -358,9 +302,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
.airplay,
|
||||
.pictureInPicture,
|
||||
// Utility
|
||||
.playbackSpeed,
|
||||
.audioMode
|
||||
.playbackSpeed
|
||||
]
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,9 +411,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
|
||||
/// How volume is controlled during playback.
|
||||
var volumeMode: VolumeMode
|
||||
|
||||
/// Forced appearance for the macOS control bar's glass background.
|
||||
var controlBarTheme: ControlsTheme
|
||||
|
||||
/// Theme derived from style (for backwards compatibility).
|
||||
var theme: ControlsTheme { style.theme }
|
||||
|
||||
@@ -436,7 +433,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
|
||||
case systemControlsMode
|
||||
case systemControlsSeekDuration
|
||||
case volumeMode
|
||||
case controlBarTheme
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -449,7 +445,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
|
||||
systemControlsMode = try container.decodeIfPresent(SystemControlsMode.self, forKey: .systemControlsMode) ?? .seek
|
||||
systemControlsSeekDuration = try container.decodeIfPresent(SystemControlsSeekDuration.self, forKey: .systemControlsSeekDuration) ?? .tenSeconds
|
||||
volumeMode = try container.decodeIfPresent(VolumeMode.self, forKey: .volumeMode) ?? .mpv
|
||||
controlBarTheme = try container.decodeIfPresent(ControlsTheme.self, forKey: .controlBarTheme) ?? .system
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -462,7 +457,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
|
||||
try container.encode(systemControlsMode, forKey: .systemControlsMode)
|
||||
try container.encode(systemControlsSeekDuration, forKey: .systemControlsSeekDuration)
|
||||
try container.encode(volumeMode, forKey: .volumeMode)
|
||||
try container.encode(controlBarTheme, forKey: .controlBarTheme)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
@@ -475,8 +469,7 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
|
||||
controlsFadeOpacity: Double = 0.5,
|
||||
systemControlsMode: SystemControlsMode = .seek,
|
||||
systemControlsSeekDuration: SystemControlsSeekDuration = .tenSeconds,
|
||||
volumeMode: VolumeMode = .mpv,
|
||||
controlBarTheme: ControlsTheme = .system
|
||||
volumeMode: VolumeMode = .mpv
|
||||
) {
|
||||
self.style = style
|
||||
self.buttonSize = buttonSize
|
||||
@@ -485,7 +478,6 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
|
||||
self.systemControlsMode = systemControlsMode
|
||||
self.systemControlsSeekDuration = systemControlsSeekDuration
|
||||
self.volumeMode = volumeMode
|
||||
self.controlBarTheme = controlBarTheme
|
||||
}
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
@@ -99,12 +99,7 @@ struct Storyboard: Hashable, Sendable, Codable {
|
||||
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
|
||||
func directSheetURL(for index: Int) -> URL? {
|
||||
guard index >= 0, index < storyboardCount else { return nil }
|
||||
// YouTube storyboard filenames are `M{N}.jpg` and the templateUrl encodes the
|
||||
// slot as `M$M`. The leading `M` is literal, so replace `M$M` with `M{index}`
|
||||
// (not bare `\(index)`) — otherwise the file becomes `0.jpg` instead of `M0.jpg`
|
||||
// and YouTube returns 404. Matching the full `M$M` token also avoids accidentally
|
||||
// rewriting any `$M` that appears later in query params such as `sigh=rs$...`.
|
||||
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "M\(index)")
|
||||
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "\(index)")
|
||||
return URL(string: urlString)
|
||||
}
|
||||
|
||||
|
||||
@@ -189,64 +189,6 @@ struct StreamResolution: Codable, Hashable, Sendable, Comparable, CustomStringCo
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio-Only Variant
|
||||
|
||||
extension Stream {
|
||||
/// Whether the backend must disable the video track when loading this stream:
|
||||
/// a muxed file being played as audio-only (see `audioOnlyVariant()`).
|
||||
var requiresVideoTrackDisabled: Bool {
|
||||
isAudioOnly && videoCodec != nil
|
||||
}
|
||||
|
||||
/// Creates an audio-only copy of this stream keeping the same URL.
|
||||
/// Used for muxed local files in audio mode: `videoCodec` stays set so
|
||||
/// `requiresVideoTrackDisabled` tells the backend to skip the video track.
|
||||
func audioOnlyVariant() -> Stream {
|
||||
Stream(
|
||||
url: url,
|
||||
resolution: nil,
|
||||
format: format,
|
||||
videoCodec: videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
bitrate: bitrate,
|
||||
fileSize: fileSize,
|
||||
isAudioOnly: true,
|
||||
isLive: isLive,
|
||||
mimeType: mimeType,
|
||||
audioLanguage: audioLanguage,
|
||||
audioTrackName: audioTrackName,
|
||||
isOriginalAudio: isOriginalAudio,
|
||||
httpHeaders: httpHeaders
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Rewriting
|
||||
|
||||
extension Stream {
|
||||
/// Creates a copy of this stream with a different URL.
|
||||
/// Used for proxying streams through an instance.
|
||||
func withURL(_ newURL: URL) -> Stream {
|
||||
Stream(
|
||||
url: newURL,
|
||||
resolution: resolution,
|
||||
format: format,
|
||||
videoCodec: videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
bitrate: bitrate,
|
||||
fileSize: fileSize,
|
||||
isAudioOnly: isAudioOnly,
|
||||
isLive: isLive,
|
||||
mimeType: mimeType,
|
||||
audioLanguage: audioLanguage,
|
||||
audioTrackName: audioTrackName,
|
||||
isOriginalAudio: isOriginalAudio,
|
||||
httpHeaders: httpHeaders,
|
||||
fps: fps
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Data
|
||||
|
||||
extension Stream {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
//
|
||||
// TVVideoSyncMode.swift
|
||||
// Yattee
|
||||
//
|
||||
// MPV `video-sync` mode override exposed on tvOS for A/V sync debugging.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum TVVideoSyncMode: String, CaseIterable, Codable {
|
||||
/// Lock video to display refresh, drop/repeat frames to match. Audio plays at PTS.
|
||||
/// Current shipping default.
|
||||
case displayVdrop = "display-vdrop"
|
||||
/// Lock video to display refresh; resample audio to match. Eliminates A/V drift
|
||||
/// when the display mode doesn't match content fps, at the cost of tiny audio
|
||||
/// pitch correction.
|
||||
case displayResample = "display-resample"
|
||||
/// libmpv default: video adjusts to audio. Most forgiving when display-mode
|
||||
/// matching is uncertain.
|
||||
case audio
|
||||
|
||||
var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .displayVdrop: "settings.playback.tvVideoSyncMode.displayVdrop"
|
||||
case .displayResample: "settings.playback.tvVideoSyncMode.displayResample"
|
||||
case .audio: "settings.playback.tvVideoSyncMode.audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,25 +91,6 @@ struct Video: Identifiable, Codable, Sendable {
|
||||
thumbnails.sorted { $0.quality > $1.quality }.first
|
||||
}
|
||||
|
||||
/// Thumbnail URLs ordered best-quality-first.
|
||||
///
|
||||
/// Used as a fallback chain: backends (and YouTube's CDN) often advertise
|
||||
/// `maxres`/`sddefault` variants that don't actually exist for a given video
|
||||
/// and 404. Consumers try these in order and drop to the next when one fails.
|
||||
var thumbnailURLsByQuality: [URL] {
|
||||
thumbnails.sorted { $0.quality > $1.quality }.map(\.url)
|
||||
}
|
||||
|
||||
/// Best thumbnail URL rewritten to the always-available `hqdefault` variant.
|
||||
///
|
||||
/// For consumers that render or fetch a single URL with no way to fall back
|
||||
/// through the quality chain (Now Playing artwork, Top Shelf, remote control,
|
||||
/// navigation covers): the best advertised variant is often `maxresdefault`,
|
||||
/// which 404s for many older videos.
|
||||
var reliableThumbnailURL: URL? {
|
||||
Thumbnail.reliableURL(for: bestThumbnail?.url)
|
||||
}
|
||||
|
||||
var formattedDuration: String {
|
||||
guard !isLive else { return "LIVE" }
|
||||
guard duration > 0 else { return "" }
|
||||
@@ -321,55 +302,6 @@ struct Thumbnail: Codable, Hashable, Sendable {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
/// Expands a single stored YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`)
|
||||
/// into a best-first fallback chain. Persistence models (playlists, watch history,
|
||||
/// bookmarks) store only the best advertised URL — usually `maxresdefault.jpg`,
|
||||
/// which doesn't exist for many older videos and 404s. Reconstructing the
|
||||
/// lower-quality variants lets thumbnail views fall back the same way they do
|
||||
/// for live API results. Non-matching URLs get a single-entry chain.
|
||||
/// Rewrites a YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`) to the
|
||||
/// `hqdefault.jpg` variant, which exists for effectively every video (unlike
|
||||
/// `maxresdefault`/`sddefault`, which 404 for many older uploads). Use where a
|
||||
/// single URL is displayed without fallback, e.g. playlist covers. Non-matching
|
||||
/// URLs are returned unchanged.
|
||||
static func reliableURL(for url: URL?) -> URL? {
|
||||
guard let url else { return nil }
|
||||
let variants = ["maxresdefault.jpg", "sddefault.jpg", "hqdefault.jpg", "mqdefault.jpg", "default.jpg"]
|
||||
let path = url.path
|
||||
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
|
||||
let match = variants.first(where: { path.hasSuffix($0) }),
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url
|
||||
}
|
||||
components.path = String(path.dropLast(match.count)) + "hqdefault.jpg"
|
||||
return components.url ?? url
|
||||
}
|
||||
|
||||
static func fallbackChain(for url: URL?) -> [Thumbnail] {
|
||||
guard let url else { return [] }
|
||||
let variants: [(suffix: String, quality: Quality)] = [
|
||||
("maxresdefault.jpg", .maxres),
|
||||
("sddefault.jpg", .standard),
|
||||
("hqdefault.jpg", .high),
|
||||
("mqdefault.jpg", .medium),
|
||||
("default.jpg", .default),
|
||||
]
|
||||
let path = url.path
|
||||
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
|
||||
let current = variants.first(where: { path.hasSuffix($0.suffix) }),
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [Thumbnail(url: url, quality: .medium)]
|
||||
}
|
||||
let basePath = String(path.dropLast(current.suffix.count))
|
||||
return variants.compactMap { variant in
|
||||
guard variant.quality <= current.quality else { return nil }
|
||||
var variantComponents = components
|
||||
variantComponents.path = basePath + variant.suffix
|
||||
guard let variantURL = variantComponents.url else { return nil }
|
||||
return Thumbnail(url: variantURL, quality: variant.quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Support
|
||||
|
||||
@@ -43,7 +43,7 @@ enum GridConstants {
|
||||
/// Spacing between grid items.
|
||||
static let spacing: CGFloat = {
|
||||
#if os(tvOS)
|
||||
48
|
||||
32
|
||||
#else
|
||||
12
|
||||
#endif
|
||||
@@ -55,15 +55,6 @@ enum GridConstants {
|
||||
/// Maximum allowed columns (to prevent excessive density).
|
||||
static let maxAllowedColumns = 6
|
||||
|
||||
/// Minimum allowed columns. tvOS doesn't make sense with a single column.
|
||||
static let minAllowedColumns: Int = {
|
||||
#if os(tvOS)
|
||||
2
|
||||
#else
|
||||
1
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// Threshold for compact card styling (columns >= this use compact mode).
|
||||
static let compactThreshold = 3
|
||||
}
|
||||
@@ -83,14 +74,14 @@ func maxGridColumns(
|
||||
// Formula: availableWidth = (columns * minCardWidth) + ((columns - 1) * spacing)
|
||||
// Solving for columns: columns = (availableWidth + spacing) / (minCardWidth + spacing)
|
||||
let maxColumns = Int((availableWidth + spacing) / (minCardWidth + spacing))
|
||||
return max(GridConstants.minAllowedColumns, min(maxColumns, GridConstants.maxAllowedColumns))
|
||||
return max(1, min(maxColumns, GridConstants.maxAllowedColumns))
|
||||
}
|
||||
|
||||
/// Creates grid columns for a LazyVGrid with the specified count.
|
||||
/// - Parameter count: Number of columns
|
||||
/// - Returns: Array of flexible GridItems with top alignment
|
||||
func makeGridColumns(count: Int) -> [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(GridConstants.minAllowedColumns, count))
|
||||
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(1, count))
|
||||
}
|
||||
|
||||
// MARK: - Grid Layout Configuration
|
||||
@@ -127,7 +118,7 @@ struct GridLayoutConfiguration {
|
||||
|
||||
/// Effective column count, clamped to valid range.
|
||||
var effectiveColumns: Int {
|
||||
min(max(GridConstants.minAllowedColumns, gridColumns), max(GridConstants.minAllowedColumns, maxColumns))
|
||||
min(max(1, gridColumns), max(1, maxColumns))
|
||||
}
|
||||
|
||||
/// Whether cards should use compact styling (3+ columns).
|
||||
|
||||
@@ -41,10 +41,10 @@ actor ContentService: ContentServiceProtocol {
|
||||
private let defaultPeerTubeAPI: PeerTubeAPI
|
||||
private let defaultYatteeServerAPI: YatteeServerAPI
|
||||
|
||||
/// Credentials manager for fetching basic auth headers on demand.
|
||||
private let basicAuthCredentialsManager: BasicAuthCredentialsManager?
|
||||
/// Credentials manager for fetching Yattee Server auth headers on demand.
|
||||
private let yatteeServerCredentialsManager: YatteeServerCredentialsManager?
|
||||
|
||||
init(httpClient: HTTPClient, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) {
|
||||
init(httpClient: HTTPClient, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) {
|
||||
// Legacy init - create factory internally
|
||||
self.httpClientFactory = HTTPClientFactory()
|
||||
self.defaultHTTPClient = httpClient
|
||||
@@ -52,10 +52,10 @@ actor ContentService: ContentServiceProtocol {
|
||||
self.defaultPipedAPI = PipedAPI(httpClient: httpClient)
|
||||
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: httpClient)
|
||||
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
||||
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
||||
self.yatteeServerCredentialsManager = yatteeServerCredentialsManager
|
||||
}
|
||||
|
||||
init(httpClientFactory: HTTPClientFactory, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) {
|
||||
init(httpClientFactory: HTTPClientFactory, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) {
|
||||
self.httpClientFactory = httpClientFactory
|
||||
// Create default client for instances that don't need insecure SSL
|
||||
self.defaultHTTPClient = httpClientFactory.createClient(allowInvalidCertificates: false)
|
||||
@@ -63,54 +63,65 @@ actor ContentService: ContentServiceProtocol {
|
||||
self.defaultPipedAPI = PipedAPI(httpClient: defaultHTTPClient)
|
||||
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: defaultHTTPClient)
|
||||
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: defaultHTTPClient)
|
||||
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
||||
self.yatteeServerCredentialsManager = yatteeServerCredentialsManager
|
||||
}
|
||||
|
||||
// MARK: - Routing
|
||||
|
||||
/// Builds a per-instance HTTPClient with the basic-auth `Authorization` header baked in,
|
||||
/// or returns nil if no basic-auth credentials are configured for the instance.
|
||||
/// Used to inject reverse-proxy basic auth uniformly across all backends.
|
||||
private func httpClientWithBasicAuth(for instance: Instance) async -> HTTPClient? {
|
||||
guard let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) else {
|
||||
return nil
|
||||
}
|
||||
let client = httpClientFactory.createClient(for: instance)
|
||||
await client.setDefaultHeaders(["Authorization": authHeader])
|
||||
return client
|
||||
}
|
||||
|
||||
/// Returns an API client configured for the instance's SSL and auth requirements.
|
||||
private func api(for instance: Instance) async -> any InstanceAPI {
|
||||
// For Yattee Server, use the dedicated method that handles auth
|
||||
if instance.type == .yatteeServer {
|
||||
return await yatteeServerAPI(for: instance)
|
||||
}
|
||||
|
||||
// For instances with standard SSL, use cached default API clients
|
||||
if !instance.allowInvalidCertificates {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return await invidiousAPI(for: instance)
|
||||
return defaultInvidiousAPI
|
||||
case .piped:
|
||||
return await pipedAPI(for: instance)
|
||||
return defaultPipedAPI
|
||||
case .peertube:
|
||||
return await peerTubeAPI(for: instance)
|
||||
return defaultPeerTubeAPI
|
||||
case .yatteeServer:
|
||||
return await yatteeServerAPI(for: instance)
|
||||
fatalError("Should be handled above")
|
||||
}
|
||||
}
|
||||
|
||||
// For instances with allowInvalidCertificates, create API with insecure HTTPClient
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return InvidiousAPI(httpClient: insecureClient)
|
||||
case .piped:
|
||||
return PipedAPI(httpClient: insecureClient)
|
||||
case .peertube:
|
||||
return PeerTubeAPI(httpClient: insecureClient)
|
||||
case .yatteeServer:
|
||||
fatalError("Should be handled above")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
|
||||
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
|
||||
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||
return YatteeServerAPI(httpClient: authClient)
|
||||
}
|
||||
let api: YatteeServerAPI
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultYatteeServerAPI
|
||||
}
|
||||
api = defaultYatteeServerAPI
|
||||
} else {
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
return YatteeServerAPI(httpClient: insecureClient)
|
||||
api = YatteeServerAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
/// Returns an InvidiousAPI configured for the instance's SSL and auth requirements.
|
||||
private func invidiousAPI(for instance: Instance) async -> InvidiousAPI {
|
||||
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||
return InvidiousAPI(httpClient: authClient)
|
||||
// Fetch auth header directly from credentials manager (avoids race condition on app startup)
|
||||
let authHeader = await yatteeServerCredentialsManager?.basicAuthHeader(for: instance)
|
||||
await api.setAuthHeader(authHeader)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
/// Returns an InvidiousAPI configured for the instance's SSL requirements.
|
||||
private func invidiousAPI(for instance: Instance) -> InvidiousAPI {
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultInvidiousAPI
|
||||
}
|
||||
@@ -118,11 +129,8 @@ actor ContentService: ContentServiceProtocol {
|
||||
return InvidiousAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
/// Returns a PipedAPI configured for the instance's SSL and auth requirements.
|
||||
private func pipedAPI(for instance: Instance) async -> PipedAPI {
|
||||
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||
return PipedAPI(httpClient: authClient)
|
||||
}
|
||||
/// Returns a PipedAPI configured for the instance's SSL requirements.
|
||||
private func pipedAPI(for instance: Instance) -> PipedAPI {
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultPipedAPI
|
||||
}
|
||||
@@ -130,11 +138,8 @@ actor ContentService: ContentServiceProtocol {
|
||||
return PipedAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
/// Returns a PeerTubeAPI configured for the instance's SSL and auth requirements.
|
||||
private func peerTubeAPI(for instance: Instance) async -> PeerTubeAPI {
|
||||
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||
return PeerTubeAPI(httpClient: authClient)
|
||||
}
|
||||
/// Returns a PeerTubeAPI configured for the instance's SSL requirements.
|
||||
private func peerTubeAPI(for instance: Instance) -> PeerTubeAPI {
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultPeerTubeAPI
|
||||
}
|
||||
@@ -243,27 +248,22 @@ actor ContentService: ContentServiceProtocol {
|
||||
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
|
||||
}
|
||||
|
||||
/// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/
|
||||
/// so the server caches the file on disk; other backends apply Invidious URL rewriting).
|
||||
/// Fetches streams with proxy URLs for faster LAN downloads (Yattee Server only).
|
||||
/// For other backends, returns regular streams.
|
||||
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
if instance.type == .yatteeServer {
|
||||
return try await yatteeServerAPI(for: instance)
|
||||
.proxyStreams(videoID: videoID, instance: instance, mode: .download)
|
||||
return try await yatteeServerAPI(for: instance).proxyStreams(videoID: videoID, instance: instance)
|
||||
}
|
||||
let fetchedStreams = try await streams(videoID: videoID, instance: instance)
|
||||
return await InvidiousAPI.proxyStreamsIfNeeded(fetchedStreams, instance: instance)
|
||||
return try await streams(videoID: videoID, instance: instance)
|
||||
}
|
||||
|
||||
/// Fetches video details + streams for downloads. For Yattee Server this routes
|
||||
/// through `/proxy/fast/`, which caches the file on disk on the server.
|
||||
/// Fetches video details, proxy streams, captions, and storyboards (Yattee Server only).
|
||||
/// For other backends, falls back to regular streams.
|
||||
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
||||
if instance.type == .yatteeServer {
|
||||
return try await yatteeServerAPI(for: instance)
|
||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .download)
|
||||
return try await yatteeServerAPI(for: instance).videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
}
|
||||
var result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
result.streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
|
||||
return result
|
||||
return try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
}
|
||||
|
||||
/// Fetches video details, streams, and captions in a single API call (Invidious and Yattee Server).
|
||||
@@ -280,19 +280,10 @@ actor ContentService: ContentServiceProtocol {
|
||||
case .invidious:
|
||||
return try await invidiousAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
case .yatteeServer:
|
||||
// When the user has opted into proxying for this Yattee Server source,
|
||||
// fetch playback streams through the server's /proxy/relay byte-relay
|
||||
// (Range-friendly, fast TTFB). When off, leave routing to the server's
|
||||
// per-site proxy_streaming flag — the server can still proxy by default,
|
||||
// but the client doesn't force it on top.
|
||||
if instance.proxiesVideos {
|
||||
return try await yatteeServerAPI(for: instance)
|
||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .relay)
|
||||
}
|
||||
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
case .piped:
|
||||
// Piped fallback - make separate calls (no storyboard support)
|
||||
let pipedAPI = await pipedAPI(for: instance)
|
||||
let pipedAPI = pipedAPI(for: instance)
|
||||
async let videoTask = pipedAPI.video(id: id, instance: instance)
|
||||
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
|
||||
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
|
||||
@@ -304,7 +295,7 @@ actor ContentService: ContentServiceProtocol {
|
||||
|
||||
case .peertube:
|
||||
// PeerTube fallback - make separate calls (no storyboard support)
|
||||
let peerTubeAPI = await peerTubeAPI(for: instance)
|
||||
let peerTubeAPI = peerTubeAPI(for: instance)
|
||||
async let videoTask = peerTubeAPI.video(id: id, instance: instance)
|
||||
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
|
||||
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)
|
||||
|
||||