Compare commits

...

45 Commits

Author SHA1 Message Date
Arkadiusz Fal
20d0cfc0c7 Use macos-26 runner for release workflow
macos-latest (macos-15) doesn't have iOS 26 / tvOS 26 platform
SDKs pre-installed, causing build failures. The macos-26 runner
ships with the required SDKs.
2026-02-07 20:53:38 +01:00
Arkadiusz Fal
7fe99b09ef Update build number 2026-02-07 20:24:45 +01:00
Arkadiusz Fal
78f155a3b9 Merge pull request #904 from telyn/support-invidious-empty-domain
Support Invidious servers which have unspecified domains (... at least for thumbnail URLs 🙂)
2025-12-22 23:07:29 +01:00
telyn
6f696c9262 [invidious] default thumbnail hostname to configured URL's
For context, Invidious performs a type check of its configuration during
bootup, and allows the "domain" to be unspecified, in which case the API
and HTML it generates includes path-only URLs. This is valid: in these
cases we should assume that the Host for those URLs is the same as
the Host we used to access the endpoint called.

This commit adds support for servers configured in such a way, by
defaulting the host of thumbnail URLs, to that used for authentication.
2025-12-22 14:59:39 +00:00
Arkadiusz Fal
b38bd3f444 Update changelog for build 211 2025-11-23 17:17:48 +01:00
Arkadiusz Fal
d8e079ac90 Bump build number to 211 2025-11-23 17:11:12 +01:00
Arkadiusz Fal
75812906c1 Update dependencies 2025-11-23 17:08:39 +01:00
Arkadiusz Fal
82570b7f34 Fix SwiftFormat indentation in VideoContextMenuView
Applied SwiftFormat indentation rules to conditional overlay block in iOS-specific code.
2025-11-23 17:02:07 +01:00
Arkadiusz Fal
e43eddc8e7 Fix iOS menu text disappearing in ChannelVideosView
Applied ZStack overlay fix to the channel menu in ChannelVideosView
where the channel name, avatar, subscriber count, and view count
would disappear when tapping the menu.

Uses the same pattern:
- Visible static label with channel info stays on screen
- Invisible Menu overlay with .opacity(0) handles interactions
- Prevents text/avatar disappearing and resizing animations
2025-11-23 14:34:29 +01:00
Arkadiusz Fal
c5137a8af8 Prefer fast-loading formats when switching to AVPlayer
When switching from MPV to AVPlayer, prioritize HLS and stream formats over non-streamable MP4/AVC1 formats to avoid long loading times.

Changes:
- Added isFastLoadingFormat() helper to AVPlayerBackend
- Modified streamByQualityProfile to prefer fast-loading formats for AVPlayer
- Falls back to non-streamable formats only if no fast-loading option exists
- Ensures quick backend switching without waiting for metadata download
2025-11-23 14:20:28 +01:00
Arkadiusz Fal
9177abb0ec Fix iOS menu text disappearing in navigation headers
Extended the ZStack overlay fix to all iOS navigation header menus
where text labels would disappear when tapping the menu:

- HomeView: "Home" title menu
- PopularView: "Popular" title with icon menu
- TrendingView: Country/flag title menu
- PlaylistsView: Playlist title with thumbnail menu
- ChannelPlaylistView: Playlist title with thumbnail menu
- OpenVideosView: Playback mode picker menu

All menus now use the same pattern as PlaybackSettings:
- Visible static label layer in ZStack
- Invisible Menu overlay with .opacity(0)
- Prevents text disappearing and resizing animations
2025-11-23 14:16:21 +01:00
Arkadiusz Fal
65e86d30ec Fix iOS playback settings menu text disappearing and resizing issues
When tapping menus in playback settings (playback mode, quality profile,
stream, rate, captions, audio track), the selected value text would
disappear and cause unwanted resizing animations.

Implemented ZStack overlay technique for all iOS menu buttons:
- Visible static label remains on screen
- Invisible Menu overlay (.opacity(0)) handles tap interactions
- Prevents text from disappearing when menu opens
- Eliminates resizing animations on option selection
2025-11-23 14:09:14 +01:00
Arkadiusz Fal
0c4609bcf1 Update dependencies 2025-11-23 13:43:43 +01:00
Arkadiusz Fal
36190e62f5 Restrict orientation locking to iPhone only
- Add device checks in Orientation enum to prevent locking on iPad
- Hide "Lock portrait mode" setting on iPad in BrowsingSettings
- Use Constants.isIPhone for consistent device detection
2025-11-23 13:41:03 +01:00
Arkadiusz Fal
e6e69eb757 Add optional AVPlayer support for non-streamable MP4/AVC1 formats
AVPlayer has a fundamental limitation with MP4/AVC1 progressive downloads where the moov atom position affects playback start time. When moov is at the end of the file, AVPlayer must download the entire file before starting playback. MPV doesn't have this limitation.

This commit adds an advanced setting to optionally enable these formats in AVPlayer with appropriate warnings:

- Added new setting: "Enable non-streamable formats (MP4/AVC1)"
- Default: disabled (formats hidden, MPV handles them)
- When enabled: MP4/AVC1 formats up to 1080p appear in AVPlayer quality selector
- Resolution limit: 1080p maximum (higher resolutions can't be played properly)
- Clear warning about slow loading and 1080p limitation
- Automatic stream refresh when setting is toggled
- Full import/export support for the setting
2025-11-23 13:32:16 +01:00
Arkadiusz Fal
41a33634ee Fix YouTube share links including port from Invidious instance
Fixes #870 where YouTube share links incorrectly included the port number
from the user's Invidious instance URL (e.g., http://www.youtube.com:3000
instead of http://www.youtube.com).

Added defensive logic to explicitly clear the port when creating share URLs
with frontend URL strings containing "youtube.com". This ensures YouTube
share links always use the standard format without port numbers, regardless
of the user's instance configuration.
2025-11-23 12:00:02 +01:00
Arkadiusz Fal
aa703f6531 Fix Invidious search API parameters
Update search query parameters to match Invidious API:
- Change sort_by to sort
- Change upload_date to date
- Change view_count to views

Fixes #881
2025-11-23 11:51:25 +01:00
Arkadiusz Fal
db80b6adbb Restrict tvOS Cancel button to versions below 18
The Cancel button in VideoContextMenuView now only appears on tvOS 17 and earlier using #unavailable(tvOS 18.0) check.
2025-11-23 01:20:38 +01:00
Arkadiusz Fal
6591d503d4 Restrict context menu overlay to iOS only
The tap-blocking overlay is only needed on iOS to dismiss the context menu
on tap. Removed it from macOS and tvOS where it was either blocking normal
interactions or not functional due to platform limitations.
2025-11-23 01:18:55 +01:00
Arkadiusz Fal
1eba731283 Fix home view empty sections taking excessive vertical space
Removed fixed height constraint from empty state in FavoriteItemView.
Empty sections now collapse to natural height instead of reserving
full content height (290px iOS/600px tvOS), improving space efficiency.
2025-11-22 23:54:33 +01:00
Arkadiusz Fal
0913c6d73c Fix tvOS controls overlay button text legibility
Improved text contrast on overlay buttons by:
- Applying foreground color directly to button labels to ensure proper override
- Using semi-transparent gray background for unfocused buttons instead of Color.secondary
- Removing accent color overrides from caption text to respect button styling

This ensures readable text in both focused (black on white) and unfocused
(white on gray) states.
2025-11-22 23:40:23 +01:00
Arkadiusz Fal
997de6468d Improve tvOS controls overlay with single-press menus
Changed context menus from press-and-hold to single-press for better UX:
- Quality profile selection
- Stream quality selection
- Captions selection
- Audio track selection

Updated ControlsOverlayButton to support tap actions via new onSelect parameter.
Replaced .contextMenu modifiers with .alert for instant menu access on tvOS.
Removed hint text and unnecessary 80px padding as single-press is self-evident.
2025-11-22 23:39:55 +01:00
Arkadiusz Fal
1397a2fee6 Fix tvOS captions list always showing as unavailable in MPV
The captions context menu on tvOS was always empty because it relied on
an .onAppear callback that was never triggered. The captionsPicker view
with .onAppear was only rendered on iOS/macOS, not tvOS.

Changes:
- Access captions directly from player.currentVideo?.captions instead of
  using a state variable populated via .onAppear
- Fix label logic to show "Disabled" when captions are available but not
  selected, and "Not available" only when video has no captions
- Remove unused state variables and helper functions
2025-11-22 23:06:56 +01:00
Arkadiusz Fal
660891f2a5 Add hide videos without duration setting to macOS instance settings
Added the "Experimental: Hide videos without duration" toggle to the
macOS instance settings view to match the iOS/tvOS implementation.
2025-11-22 22:54:45 +01:00
Arkadiusz Fal
2e27dcd2cf Fix Invidious captions URL when companion is enabled
Prefix caption URLs with /companion when invidiousCompanion is enabled in instance settings. This ensures captions are routed through the companion service, matching the behavior of video streams.
2025-11-22 22:52:46 +01:00
Arkadiusz Fal
5f53e53c7a Fix iOS fullscreen gesture collision with notification center
In fullscreen playback, swipe-down and timeline seek gestures now respect a 60pt safe zone at the top of the screen, allowing the system notification center gesture to work without triggering app gestures.
2025-11-22 20:11:14 +01:00
Arkadiusz Fal
73295e726a Fix iOS comments scrolling issue in video details view
Comments at the bottom of the comments view were not accessible on iOS
without entering fullscreen mode. The issue was caused by the VideoDetails
view being offset by the player height when not in fullscreen, but the
ScrollView padding didn't account for this offset.

Changes:
- Add SafeAreaModel observer for iOS platform
- Update bottom padding to dynamically account for player height offset
  and safe area insets based on fullscreen state
- When not in fullscreen: padding = player height + safe area bottom + 20
- When in fullscreen: padding = max(60, safe area bottom + 20)

This ensures all comments and video details content are fully scrollable
and visible regardless of fullscreen state.
2025-11-22 19:54:36 +01:00
Arkadiusz Fal
b0dfd2f9d2 Add experimental setting to hide videos without duration in Invidious
This adds a new instance setting for Invidious that filters out videos
without duration information from feeds, popular, trending, search results,
and channel pages. This can be used to hide YouTube Shorts.

The setting is labeled as "Experimental: Hide videos without duration" and
includes an explanation that it can be used to hide shorts.

Key changes:
- Added hideVideosWithoutDuration property to Instance model
- Updated InstancesBridge to serialize/deserialize the new setting
- Added UI toggle in InstanceSettings with explanatory footer text
- Implemented filtering in InvidiousAPI for:
  * Popular videos
  * Trending videos
  * Search results
  * Subscription feed
  * Channel content
- Videos accessed directly by URL are not filtered
2025-11-22 19:42:18 +01:00
Arkadiusz Fal
735e7d62b6 Update changelog for build 210 2025-11-20 18:00:31 +01:00
Arkadiusz Fal
320c16fcc7 Bump build number to 210 2025-11-20 17:56:24 +01:00
Arkadiusz Fal
8c5c503df2 Fix iPad iOS 18 keyboard dismissal issue in search
Removed auto-focus logic that was causing keyboard show/hide loop
on iPad with docked keyboard. The keyboard was repeatedly dismissing
immediately after appearing due to interaction between keyboard
notifications, focus state changes, and view updates.

Changes:
- Removed focused state and keyboard observer from SearchModel
- Removed iOS textField reference (kept macOS only)
- Removed auto-focus logic from FocusableSearchTextField on iOS
- Cleaned up unused focus-related code

The search field now works reliably when tapped manually on iPad.
Auto-focus still works on macOS where it doesn't cause issues.
2025-11-20 17:49:10 +01:00
Arkadiusz Fal
36738572da Fix SwiftFormat and SwiftLint issues
- Fix indentation in AppSidebarNavigation, VideoCell
- Replace && with comma in PlayerModel condition
- Add SwiftLint suppression for necessary tvOS 17.0 availability check
- Update SwiftLint config to use renamed rules and disable false positives
2025-11-20 17:05:22 +01:00
Arkadiusz Fal
9a8ccc366c Clean up trending settings when feature flag is disabled
Add startup cleanup to remove trending-related settings when the feature flag is disabled:
- Remove trending from visible sections
- Reset startup section to home if it was set to trending
- Remove all trending favorite items

This ensures users don't have invalid/broken settings referencing the disabled trending feature.
2025-11-20 13:21:56 +01:00
Arkadiusz Fal
e9ca36f1db Fix Trending menu command to hide instead of disable
Change the Trending menu command to be completely hidden when the feature flag is disabled, rather than just being disabled and still visible in the UI.
2025-11-20 13:18:03 +01:00
Arkadiusz Fal
5b607687d9 Add feature flag to disable Trending functionality
Introduces a feature flag to disable the Trending section across the app. When disabled, all trending-related UI elements, navigation links, and settings are hidden.

Changes:
- Add trendingEnabled feature flag to FeatureFlags.swift (currently disabled)
- Hide Trending tab in AppTabNavigation, Sidebar, and TVNavigationView
- Remove Trending option from visible sections settings
- Remove Trending option from startup section picker
- Disable Trending menu command and keyboard shortcut
- Prevent Trending URL navigation in OpenURLHandler
- Hide Trending in FavoriteItemView navigation
2025-11-20 13:14:31 +01:00
Arkadiusz Fal
e723bb9147 Change Trending icon to arrow.up.right.circle.fill
Replace chart.bar.fill icon with arrow.up.right.circle.fill for the Trending section across tab bar and sidebar navigation.
2025-11-20 13:08:55 +01:00
Arkadiusz Fal
a3747a0975 Change Popular icon to chart.bar.fill
Replace arrow.up.right.circle.fill icon with chart.bar.fill for the Popular section across all navigation contexts (tab bar, sidebar, and view header).
2025-11-20 13:07:32 +01:00
Arkadiusz Fal
bb2bd86c07 Add feature flag to disable hide shorts functionality
The hide shorts feature is no longer working due to API changes that prevent reliable detection of short videos. This commit introduces a feature flag system to disable the functionality while preserving the ability to easily restore it if the API issue is resolved.

Changes:
- Add FeatureFlags.swift with hideShortsEnabled flag (currently disabled)
- Hide all HideShortsButtons UI elements when flag is disabled
- Disable shorts filtering logic in ContentItemView, FavoriteItemView, and FeedModel
- Preserve hideShorts user preference for future restoration
2025-11-20 13:05:12 +01:00
Arkadiusz Fal
680ac9a8a0 Fix keyboard shortcut conflict for Show Player command
Changed Show Player shortcut from Cmd+O to Cmd+Shift+P to avoid
conflict with system Open command.
2025-11-20 00:23:27 +01:00
Arkadiusz Fal
c1b23d20f2 Fix tab selection timing to wait for account sign-in
Tab selection was being set immediately during app configuration, before
the user account had completed sign-in. This caused tabs that require
authentication (like Subscriptions and Playlists) to not be properly
selected on startup since they weren't visible yet.

Changes:
- Add notification system for account configuration completion
- Post notification after all account types finish configuration:
  * Accounts with existing tokens
  * Accounts requiring sign-in (after network request completes)
  * Anonymous/public accounts
  * Error cases (missing credentials, network failures)
- Set up observer before account configuration to ensure notification
  is received
- Set tab selection only when account is fully configured
2025-11-19 23:24:21 +01:00
Arkadiusz Fal
b8f6dabbc9 Update SwiftUI-Introspect to support iOS 26
Upgrade SwiftUI-Introspect dependency from 1.3.0 to 26.0.0 and add iOS 26 support to the introspect modifier in AppSidebarNavigation.
2025-11-19 23:09:18 +01:00
Arkadiusz Fal
1c168bd982 Fix thumbnail aspect ratio to prevent stretching and layout jumps
Fixed issues with thumbnails being stretched vertically and layout jumping during image loading:
- Simplified VideoCellThumbnail to always use 16:9 aspect ratio with .fill mode
- Added matching 16:9 aspect ratio to placeholder with .fill mode to prevent layout shifts
- Removed quality-based aspect ratio selection (4:3 vs 16:9) in favor of consistent 16:9
- Ensures thumbnails maintain proper proportions on both iOS and macOS

This provides consistent sizing across platforms and eliminates the jump when images finish loading.
2025-11-19 23:01:52 +01:00
Arkadiusz Fal
42d53c30db Fix thumbnail aspect ratio in video grid cells
Thumbnails were being stretched vertically due to incorrect aspect ratio handling. Fixed by:
- Using .scaledToFill() on thumbnails to fill the container width
- Constraining container to 16:9 aspect ratio with .fit mode
- Adding matching aspect ratio to placeholder to prevent layout shift during loading

This ensures thumbnails maintain proper proportions while filling the full cell width.
2025-11-19 22:37:05 +01:00
Arkadiusz Fal
a55adb2e65 Fix thumbnail loading for video details
Explicitly specify thumbnail quality order instead of using Thumbnail.Quality.allCases to ensure proper thumbnail URL generation and loading priority.
2025-11-19 22:05:55 +01:00
Arkadiusz Fal
cea296c4b7 Fix audio session interrupting other apps on launch
Previously, the audio session was initialized immediately when the app launched, causing audio from other apps (like Music) to stop even when no video was playing in Yattee.

Changes:
- Remove audio session initialization from AppDelegate launch
- Remove audio session setup from MPVClient initialization
- Update setAudioSessionActive() to configure audio session category before activation

The audio session is now lazily initialized only when playback actually starts:
- For MPV backend: triggered by FILE_LOADED, PLAYBACK_RESTART, AUDIO_RECONFIG events
- For AVPlayer backend: triggered when play() is called

This allows music from other apps to continue playing until a video is actually played in Yattee.
2025-11-19 21:45:56 +01:00
65 changed files with 937 additions and 433 deletions

View File

@@ -27,7 +27,7 @@ jobs:
# lane: ['mac beta', 'ios beta', 'tvos beta']
lane: ['ios beta', 'tvos beta']
name: Releasing ${{ matrix.lane }} version to TestFlight
runs-on: macos-latest
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
@@ -54,7 +54,7 @@ jobs:
if-no-files-found: ignore
mac_notarized:
name: Build and notarize macOS app
runs-on: macos-latest
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1

View File

@@ -8,6 +8,15 @@ disabled_rules:
- multiline_arguments
- implicit_return
- closure_end_indentation
- discarded_notification_center_observer # Observer intentionally lives for app lifetime
# Disable deprecated rules in favor of their renamed versions
- operator_whitespace # renamed to function_name_whitespace
- redundant_optional_initialization # renamed to implicit_optional_initialization
opt_in_rules:
- function_name_whitespace
- implicit_optional_initialization
excluded:
- Vendor
- Tests Apple TV

View File

@@ -1,7 +1,49 @@
## Build 209
## Build 241
## What's Changed
### iOS Fixes
* Fix menu text disappearing in navigation headers and playback settings
* Fix fullscreen gesture collision with notification center by adding 60pt safe zone at top
* Fix comments scrolling issue - comments at bottom of video details view are now fully accessible
* Restrict orientation locking to iPhone only (hide on iPad)
### tvOS Fixes
* Improve controls overlay with single-press menus for quality, stream, captions, and audio track selection
* Fix controls overlay button text legibility
* Fix captions list always showing as unavailable in MPV
### API & Backend Fixes
* Fix Invidious search API parameters (sort_by→sort, upload_date→date, view_count→views)
* Fix Invidious captions URL when companion is enabled
* Fix YouTube share links incorrectly including port from Invidious instance
### UI & Layout
* Fix home view empty sections taking excessive vertical space
### Advanced Settings
* Add experimental setting to hide videos without duration in Invidious instance settings (can be used to filter shorts)
* Add optional AVPlayer support for non-streamable MP4/AVC1 formats in advanced settings with warnings about slow loading
### Dependencies
* Update dependencies
## Previous builds
## Build 210
## What's Changed
* Trending and Hide Shorts was disabled due to changes in the video apps API
* Fix iPad iOS 18 keyboard dismissal issue in search
* Fix audio session interrupting other apps on launch
* Fix thumbnail loading for video details
* Fix thumbnail aspect ratio to prevent stretching and layout jumps
* Fix keyboard shortcut conflict for Show Player command
## Previous builds
**Build 209:**
* Fix Now Playing controls for both MPV and AVPlayer backends
* Fix thumbnail sizing and aspect ratio issues in video cells (#896)
* Adjust tvOS video cell dimensions for better layout
@@ -10,8 +52,6 @@
* Simplify fullscreen handling for iOS
* Add macOS-specific entitlements for MPV backend
## Previous builds
**Build 208:**
* Enable resizable windows on iPad
* Improve iPad UI behavior and settings layout

View File

@@ -0,0 +1,5 @@
import Foundation
extension Notification.Name {
static let accountConfigurationComplete = Notification.Name("accountConfigurationComplete")
}

View File

@@ -2,13 +2,14 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1182.0)
aws-sdk-core (3.237.0)
aws-partitions (1.1187.0)
aws-sdk-core (3.239.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -16,10 +17,10 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.117.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.203.1)
aws-sdk-s3 (1.205.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -33,6 +34,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -69,8 +71,9 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
fastlane (2.229.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
@@ -78,6 +81,7 @@ GEM
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -97,6 +101,7 @@ GEM
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)

View File

@@ -11,8 +11,9 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
var frontendURL: String?
var proxiesVideos: Bool
var invidiousCompanion: Bool
var hideVideosWithoutDuration: Bool
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false, hideVideosWithoutDuration: Bool = false) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name ?? app.rawValue
@@ -20,6 +21,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
self.frontendURL = frontendURL
self.proxiesVideos = proxiesVideos
self.invidiousCompanion = invidiousCompanion
self.hideVideosWithoutDuration = hideVideosWithoutDuration
}
var apiURL: URL! {

View File

@@ -17,7 +17,8 @@ struct InstancesBridge: Defaults.Bridge {
"apiURL": value.apiURLString,
"frontendURL": value.frontendURL ?? "",
"proxiesVideos": value.proxiesVideos ? "true" : "false",
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
"invidiousCompanion": value.invidiousCompanion ? "true" : "false",
"hideVideosWithoutDuration": value.hideVideosWithoutDuration ? "true" : "false"
]
}
@@ -35,7 +36,8 @@ struct InstancesBridge: Defaults.Bridge {
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
let proxiesVideos = object["proxiesVideos"] == "true"
let invidiousCompanion = object["invidiousCompanion"] == "true"
let hideVideosWithoutDuration = object["hideVideosWithoutDuration"] == "true"
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion, hideVideosWithoutDuration: hideVideosWithoutDuration)
}
}

View File

@@ -90,6 +90,17 @@ final class InstancesModel: ObservableObject {
Defaults[.instances][index] = instance
}
func setHideVideosWithoutDuration(_ instance: Instance, _ hideVideosWithoutDuration: Bool) {
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
return
}
var instance = Defaults[.instances][index]
instance.hideVideosWithoutDuration = hideVideosWithoutDuration
Defaults[.instances][index] = instance
}
func remove(_ instance: Instance) {
let accounts = accounts(instance.id)
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {

View File

@@ -52,11 +52,13 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(self.extractVideo)
let videos = content.json.arrayValue.map(self.extractVideo)
return self.account.instance.hideVideosWithoutDuration ? videos.filter { $0.length > 0 } : videos
}
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.map(self.extractVideo)
let videos = content.json.arrayValue.map(self.extractVideo)
return self.account.instance.hideVideosWithoutDuration ? videos.filter { $0.length > 0 } : videos
}
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
@@ -70,7 +72,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
}
if type == "video" {
return ContentItem(video: self.extractVideo(from: json))
let video = self.extractVideo(from: json)
if self.account.instance.hideVideosWithoutDuration, video.length == 0 {
return nil
}
return ContentItem(video: video)
}
return nil
@@ -101,7 +107,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
if let feedVideos = content.json.dictionaryValue["videos"] {
return feedVideos.arrayValue.map(self.extractVideo)
let videos = feedVideos.arrayValue.map(self.extractVideo)
return self.account.instance.hideVideosWithoutDuration ? videos.filter { $0.length > 0 } : videos
}
return []
@@ -152,6 +159,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
FeedModel.shared.onAccountChange()
SubscribedChannelsModel.shared.onAccountChange()
PlaylistsModel.shared.onAccountChange()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
}
@@ -160,6 +171,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
guard !account.anonymous,
(account.token?.isEmpty ?? true) || force
else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
@@ -172,6 +186,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
title: "Account Error",
message: "Remove and add your account again in Settings."
)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
@@ -212,6 +229,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
self.configure()
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
@@ -390,7 +409,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
func search(_ query: SearchQuery, page: String?) -> Resource {
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
.withParam("q", searchQuery(query.query))
.withParam("sort_by", query.sortBy.parameter)
.withParam("sort", query.sortBy.parameter)
.withParam("type", "all")
if let date = query.date, date != .any {
@@ -593,6 +612,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
// Some instances are not configured properly and return thumbnail links
// with an incorrect scheme or a missing port.
components.scheme = accountUrlComponents.scheme
if (components.host ?? "") == "" {
components.host = accountUrlComponents.host
}
components.port = accountUrlComponents.port
// If basic HTTP authentication is used,
@@ -839,7 +861,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
private func extractCaptions(from content: JSON) -> [Captions] {
content["captions"].arrayValue.compactMap { details in
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
var urlString = details["url"].stringValue
// Prefix with /companion if enabled
if account.instance.invidiousCompanion {
urlString = "/companion" + urlString
}
guard let url = URL(string: urlString, relativeTo: account.url) else { return nil }
return Captions(
label: details["label"].stringValue,
@@ -863,7 +892,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return ContentItem(playlist: extractChannelPlaylist(from: json))
}
if type == "video" {
return ContentItem(video: extractVideo(from: json))
let video = extractVideo(from: json)
if account.instance.hideVideosWithoutDuration, video.length == 0 {
return nil
}
return ContentItem(video: video)
}
return nil

View File

@@ -174,6 +174,9 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
guard !account.anonymous,
(account.token?.isEmpty ?? true) || force
else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
@@ -186,6 +189,9 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
title: "Account Error",
message: "Remove and add your account again in Settings."
)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
@@ -194,6 +200,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
title: "Account Error",
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
)
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
AF
@@ -226,6 +233,8 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
}
self.configure()
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}

View File

@@ -135,6 +135,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
FeedModel.shared.onAccountChange()
SubscribedChannelsModel.shared.onAccountChange()
PlaylistsModel.shared.onAccountChange()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
}
@@ -149,6 +153,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let username,
let password
else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
return
}
@@ -184,11 +191,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
self.configure()
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
case let .failure(error):
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error.localizedDescription
)
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
}
}
}
@@ -532,7 +542,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
let qualities = [
Thumbnail.Quality.maxresdefault, .high, .medium, .default, .start, .middle, .end
]
let thumbnails: [Thumbnail] = qualities.compactMap {
if let url = buildThumbnailURL(from: content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}

View File

@@ -114,6 +114,10 @@ extension VideosAPI {
let frontendURL = URL(string: frontendURLString)
{
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
// Ensure port is not included when sharing to external frontends like YouTube
if frontendURLString.contains("youtube.com") {
urlComponents?.port = nil
}
} else if let instanceComponents = account?.instance?.urlComponents {
urlComponents = instanceComponents
}

View File

@@ -235,7 +235,7 @@ final class FeedModel: ObservableObject, CacheModel {
let watches = watchFetchRequestResult(videos, context: backgroundContext)
let watchesIDs = watches.map(\.videoID)
let unwatched = videos.filter { video in
if Defaults[.hideShorts], video.short {
if FeatureFlags.hideShortsEnabled, Defaults[.hideShorts], video.short {
return false
}

View File

@@ -6,6 +6,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
[
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
"avPlayerAllowsNonStreamableFormats": Defaults[.avPlayerAllowsNonStreamableFormats],
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
"mpvEnableLogging": Defaults[.mpvEnableLogging],
"mpvCacheSecs": Defaults[.mpvCacheSecs],

View File

@@ -13,6 +13,10 @@ struct AdvancedSettingsGroupImporter {
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
}
if let avPlayerAllowsNonStreamableFormats = json["avPlayerAllowsNonStreamableFormats"].bool {
Defaults[.avPlayerAllowsNonStreamableFormats] = avPlayerAllowsNonStreamableFormats
}
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
}

View File

@@ -318,7 +318,7 @@ final class NavigationModel: ObservableObject {
func multipleTapHandler() {
switch tabSelection {
case .search:
search.focused = true
break
default:
print("not implemented")
}

View File

@@ -11,6 +11,8 @@ import SwiftUI
final class AVPlayerBackend: PlayerBackend {
static let assetKeysToLoad = ["tracks", "playable", "duration"]
@Default(.avPlayerAllowsNonStreamableFormats) private var allowsNonStreamableFormats
private var logger = Logger(label: "avplayer-backend")
var model: PlayerModel { .shared }
@@ -150,7 +152,36 @@ final class AVPlayerBackend: PlayerBackend {
}
func canPlay(_ stream: Stream) -> Bool {
stream.kind == .hls || stream.kind == .stream
// AVPlayer has a fundamental limitation with MP4/AVC1 progressive downloads:
// If the moov atom is at the end of the file (common case), it must download
// the entire file before playback can start. MPV doesn't have this limitation.
// By default, reject non-HLS MP4/AVC1 streams unless user explicitly enables them.
// Check if this is a non-streamable format (MP4/AVC1) that isn't HLS
let isNonStreamableFormat = stream.kind != .hls && (stream.format == .mp4 || stream.format == .avc1)
if isNonStreamableFormat && !allowsNonStreamableFormats {
return false
}
// If non-streamable formats are enabled, allow MP4/AVC1 adaptive streams
// but limit to 1080p maximum (higher resolutions can't be played properly)
if isNonStreamableFormat && allowsNonStreamableFormats {
let maxHeight = 1080
if let resolution = stream.resolution, resolution.height > maxHeight {
return false
}
return true
}
// AVPlayer works well with HLS and stream formats
return stream.kind == .hls || stream.kind == .stream
}
func isFastLoadingFormat(_ stream: Stream) -> Bool {
// HLS and stream formats load quickly
// Non-streamable MP4/AVC1 formats may take a long time
return stream.kind == .hls || stream.kind == .stream
}
func playStream(
@@ -304,12 +335,12 @@ final class AVPlayerBackend: PlayerBackend {
preservingTime: Bool = false,
model: PlayerModel
) {
model.logger.info("loading \(type.rawValue) track")
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
guard let self else {
return
}
model.logger.info("loading \(type.rawValue) track")
let assetTracks = asset.tracks(withMediaType: type)
guard let compositionTrack = self.composition.addMutableTrack(

View File

@@ -135,12 +135,6 @@ final class MPVClient: ObservableObject {
checkError(mpv_initialize(mpv))
#if !os(macOS)
// Set up audio session for Now Playing support
backend?.model.setupAudioSessionForNowPlaying()
backend?.model.updateNowPlayingInfo()
#endif
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
var initParams = mpv_opengl_init_params(
get_proc_address: getProcAddress,

View File

@@ -650,7 +650,7 @@ final class PlayerModel: ObservableObject {
// When switching away from AVPlayer, clear its current item to release Now Playing control
#if !os(macOS)
if from == .appleAVPlayer && to == .mpv {
if from == .appleAVPlayer, to == .mpv {
avPlayerBackend.avPlayer.replaceCurrentItem(with: nil)
// Clear Now Playing info entirely before MPV takes over
@@ -1394,7 +1394,11 @@ final class PlayerModel: ObservableObject {
func setAudioSessionActive(_ setActive: Bool) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
do {
try AVAudioSession.sharedInstance().setActive(setActive)
let audioSession = AVAudioSession.sharedInstance()
if setActive {
try audioSession.setCategory(.playback, mode: .moviePlayback)
}
try audioSession.setActive(setActive)
} catch {
self.logger.error("Error setting audio session to \(setActive): \(error)")
}

View File

@@ -133,6 +133,26 @@ extension PlayerModel {
let profile = qualityProfile ?? .defaultProfile
// For AVPlayer, prefer fast-loading formats (HLS/stream) over non-streamable formats
// to avoid long loading times when switching backends
if activeBackend == .appleAVPlayer, let avBackend = backend as? AVPlayerBackend {
// Try to find a fast-loading stream first
let fastLoadingStreams = availableStreams.filter { backend.canPlay($0) && avBackend.isFastLoadingFormat($0) }
if let fastStream = backend.bestPlayable(
fastLoadingStreams.filter { profile.isPreferred($0) },
maxResolution: profile.resolution, formatOrder: profile.formats
) {
return fastStream
}
// Fallback to any fast-loading stream
if let fastStream = backend.bestPlayable(
fastLoadingStreams,
maxResolution: profile.resolution, formatOrder: profile.formats
) {
return fastStream
}
}
// First attempt: Filter by both `canPlay` and `isPreferred`
if let streamPreferredForProfile = backend.bestPlayable(
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },

View File

@@ -16,30 +16,18 @@ final class SearchModel: ObservableObject {
@Published var querySuggestions = [String]()
private var suggestionsDebouncer = Debouncer(.milliseconds(200))
@Published var focused = false
@Default(.showSearchSuggestions) private var showSearchSuggestions
#if os(iOS)
var textField: UITextField!
#elseif os(macOS)
#if os(macOS)
var textField: NSTextField!
#endif
var accounts: AccountsModel { .shared }
private var resource: Resource!
init() {
#if os(iOS)
addKeyboardDidHideNotificationObserver()
#endif
}
init() {}
deinit {
#if os(iOS)
removeKeyboardDidHideNotificationObserver()
#endif
}
deinit {}
var isLoading: Bool {
resource?.isLoading ?? false
@@ -158,18 +146,4 @@ final class SearchModel: ObservableObject {
}
}
}
#if os(iOS)
private func addKeyboardDidHideNotificationObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
}
@objc func onKeyboardDidHide() {
focused = false
}
private func removeKeyboardDidHideNotificationObserver() {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
}
#endif
}

View File

@@ -47,9 +47,9 @@ final class SearchQuery: ObservableObject {
var parameter: String {
switch self {
case .uploadDate:
return "upload_date"
return "date"
case .viewCount:
return "view_count"
return "views"
default:
return rawValue
}

View File

@@ -109,22 +109,7 @@ struct ChannelPlaylistView: View {
#if os(iOS)
private var playlistMenu: some View {
Menu {
playButtons
favoriteButton
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
ZStack {
HStack(spacing: 12) {
if let url = store.item?.thumbnailURL ?? playlist.thumbnailURL {
ThumbnailView(url: url)
@@ -141,8 +126,43 @@ struct ChannelPlaylistView: View {
.imageScale(.small)
}
.frame(maxWidth: 320)
.transaction { t in t.animation = nil }
Menu {
playButtons
favoriteButton
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
HStack(spacing: 12) {
if let url = store.item?.thumbnailURL ?? playlist.thumbnailURL {
ThumbnailView(url: url)
.frame(width: 60, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
Text(playlist.title)
.font(.headline)
.foregroundColor(.primary)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.small)
}
.frame(maxWidth: 320)
.opacity(0)
}
}
.transaction { t in t.animation = nil }
}
#endif

View File

@@ -259,26 +259,7 @@ struct ChannelVideosView: View {
#if !os(tvOS)
var channelMenu: some View {
Menu {
if let channel = presentedChannel {
contentTypePicker
Section {
subscriptionToggleButton
FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, channel.id, channel.name)))
}
if subscriptions.isSubscribing(channel.id) {
toggleWatchedButton
}
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
}
} label: {
ZStack {
HStack(spacing: 12) {
thumbnail
@@ -311,6 +292,61 @@ struct ChannelVideosView: View {
.imageScale(.small)
}
.frame(maxWidth: 320)
Menu {
if let channel = presentedChannel {
contentTypePicker
Section {
subscriptionToggleButton
FavoriteButton(item: FavoriteItem(section: .channel(accounts.app.appType.rawValue, channel.id, channel.name)))
}
if subscriptions.isSubscribing(channel.id) {
toggleWatchedButton
}
ListingStyleButtons(listingStyle: $channelPlaylistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
}
} label: {
HStack(spacing: 12) {
thumbnail
VStack(alignment: .leading) {
Text(presentedChannel?.name ?? "Channel")
.font(.headline)
.foregroundColor(.primary)
.layoutPriority(1)
.frame(minWidth: 160, alignment: .leading)
Group {
HStack(spacing: 12) {
subscriptionsLabel
if presentedChannel?.verified ?? false {
Image(systemName: "checkmark.seal.fill")
.imageScale(.small)
}
viewsLabel
}
.frame(minWidth: 160, alignment: .leading)
}
.font(.caption2.bold())
.foregroundColor(.secondary)
}
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.small)
}
.frame(maxWidth: 320)
.opacity(0)
}
}
}
#endif

View File

@@ -6,6 +6,7 @@ enum Constants {
static let overlayAnimation = Animation.linear(duration: 0.2)
static let aspectRatio16x9 = 16.0 / 9.0
static let aspectRatio4x3 = 4.0 / 3.0
static let notificationCenterZoneHeight: Double = 60
static var isAppleTV: Bool {
#if os(iOS)

View File

@@ -192,7 +192,8 @@ extension Defaults.Keys {
hd1080p60MPVProfile,
hd1080pMPVProfile,
hd720p60MPVProfile,
hd720pMPVProfile
hd720pMPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = hd720pMPVProfile.id
@@ -208,7 +209,8 @@ extension Defaults.Keys {
hd1080pMPVProfile,
hd720p60MPVProfile,
hd720pMPVProfile,
sd360pMPVProfile
sd360pMPVProfile,
hd720pAVPlayerProfile
]
static let batteryCellularProfileDefault = sd360pMPVProfile.id
@@ -361,6 +363,7 @@ extension Defaults.Keys {
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
static let videoLoadingRetryCount = Key<Int>("videoLoadingRetryCount", default: 10)
static let avPlayerAllowsNonStreamableFormats = Key<Bool>("avPlayerAllowsNonStreamableFormats", default: false)
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)

12
Shared/FeatureFlags.swift Normal file
View File

@@ -0,0 +1,12 @@
import Foundation
/// Feature flags for enabling/disabling functionality across the app
enum FeatureFlags {
/// Controls whether the "Hide Shorts" functionality is available
/// Set to false when the API changes prevent reliable detection of short videos
static let hideShortsEnabled = false
/// Controls whether the "Trending" section is available
/// Set to false to disable trending functionality across the app
static let trendingEnabled = false
}

View File

@@ -50,9 +50,11 @@ struct FavoriteItemView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.secondary)
if hideShorts || hideWatched {
if (FeatureFlags.hideShortsEnabled && hideShorts) || hideWatched {
AccentButton(text: "Disable filters", maxWidth: nil, verticalPadding: 0, minHeight: 30) {
hideShorts = false
if FeatureFlags.hideShortsEnabled {
hideShorts = false
}
hideWatched = false
reloadVisibleWatches()
}
@@ -64,7 +66,6 @@ struct FavoriteItemView: View {
#else
.padding(.horizontal, 15)
#endif
.frame(height: expectedContentHeight)
} else {
ZStack(alignment: .topLeading) {
// Reserve space immediately to prevent layout shift
@@ -107,7 +108,7 @@ struct FavoriteItemView: View {
resource?.removeObservers(ownedBy: store)
}
.onChange(of: player.currentVideo) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
.onChange(of: hideShorts) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
.onChange(of: hideShorts) { _ in if !player.presentingPlayer && FeatureFlags.hideShortsEnabled { reloadVisibleWatches() } }
.onChange(of: hideWatched) { _ in if !player.presentingPlayer { reloadVisibleWatches() } }
// Delay is necessary to update the list with the new items.
.onChange(of: favoritesChanged) { _ in if !player.presentingPlayer { Delay.by(1.0) { reloadVisibleWatches() } } }
@@ -135,9 +136,9 @@ struct FavoriteItemView: View {
var emptyItemsText: String {
var filterText = ""
if hideShorts && hideWatched {
if FeatureFlags.hideShortsEnabled && hideShorts && hideWatched {
filterText = "(watched and shorts hidden)"
} else if hideShorts {
} else if FeatureFlags.hideShortsEnabled && hideShorts {
filterText = "(shorts hidden)"
} else if hideWatched {
filterText = "(watched hidden)"
@@ -227,7 +228,7 @@ struct FavoriteItemView: View {
return false
}
guard hideShorts, item.contentType == .video, let video = item.video else {
guard FeatureFlags.hideShortsEnabled, hideShorts, item.contentType == .video, let video = item.video else {
return true
}
@@ -351,7 +352,7 @@ struct FavoriteItemView: View {
case .history:
return false
case .trending:
return visibleSections.contains(.trending)
return FeatureFlags.trendingEnabled && visibleSections.contains(.trending)
case .subscriptions:
return visibleSections.contains(.subscriptions) && accounts.signedIn
case .popular:

View File

@@ -211,19 +211,7 @@ struct HomeView: View {
#if os(iOS)
var homeMenu: some View {
Menu {
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
Button {
navigation.presentingHomeSettings = true
} label: {
Label("Home Settings", systemImage: "gear")
}
}
} label: {
ZStack {
HStack(spacing: 12) {
Text("Home")
.foregroundColor(.primary)
@@ -233,8 +221,33 @@ struct HomeView: View {
.foregroundColor(.accentColor)
.imageScale(.small)
}
.transaction { t in t.animation = nil }
Menu {
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
Button {
navigation.presentingHomeSettings = true
} label: {
Label("Home Settings", systemImage: "gear")
}
}
} label: {
HStack(spacing: 12) {
Text("Home")
.foregroundColor(.primary)
.font(.headline)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.small)
}
.opacity(0)
}
}
.transaction { t in t.animation = nil }
}
#endif
}

View File

@@ -36,10 +36,12 @@ struct MenuCommands: Commands {
.disabled(!AccountsModel.shared.app.supportsPopular)
.keyboardShortcut("3")
Button("Trending") {
setTabSelection(.trending)
if FeatureFlags.trendingEnabled {
Button("Trending") {
setTabSelection(.trending)
}
.keyboardShortcut("4")
}
.keyboardShortcut("4")
Button("Search") {
setTabSelection(.search)
@@ -76,7 +78,7 @@ struct MenuCommands: Commands {
Button(togglePlayerLabel) {
PlayerModel.shared.togglePlayer()
}
.keyboardShortcut("o")
.keyboardShortcut("p", modifiers: [.command, .shift])
}
}

View File

@@ -15,7 +15,7 @@ struct AppSidebarNavigation: View {
var body: some View {
#if os(iOS)
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17, .v18)) { viewController in
content.introspect(.viewController, on: .iOS(.v15, .v16, .v17, .v18, .v26)) { viewController in
// workaround for an empty supplementary view on launch
// the supplementary view is determined by the default selection inside the
// primary view, but the primary view is not loaded so its selection is not read

View File

@@ -37,7 +37,7 @@ struct AppTabNavigation: View {
popularNavigationView
}
if visibleSections.contains(.trending) {
if FeatureFlags.trendingEnabled && visibleSections.contains(.trending) {
trendingNavigationView
}
@@ -115,7 +115,7 @@ struct AppTabNavigation: View {
.toolbar { toolbarContent }
}
.tabItem {
Label("Popular", systemImage: "arrow.up.right.circle.fill")
Label("Popular", systemImage: "chart.bar.fill")
.accessibility(label: Text("Popular"))
}
.tag(TabSelection.popular)
@@ -126,7 +126,7 @@ struct AppTabNavigation: View {
LazyView(TrendingView())
}
.tabItem {
Label("Trending", systemImage: "chart.bar.fill")
Label("Trending", systemImage: "arrow.up.right.circle.fill")
.accessibility(label: Text("Trending"))
}
.tag(TabSelection.trending)

View File

@@ -95,15 +95,15 @@ struct Sidebar: View {
if visibleSections.contains(.popular), accounts.app.supportsPopular {
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
Label("Popular", systemImage: "arrow.up.right.circle")
Label("Popular", systemImage: "chart.bar.fill")
.accessibility(label: Text("Popular"))
}
.id("popular")
}
if visibleSections.contains(.trending) {
if FeatureFlags.trendingEnabled && visibleSections.contains(.trending) {
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
Label("Trending", systemImage: "chart.bar")
Label("Trending", systemImage: "arrow.up.right.circle.fill")
.accessibility(label: Text("Trending"))
}
.id("trending")

View File

@@ -74,6 +74,7 @@ struct OpenURLHandler {
focusMainWindow()
#endif
case .trending:
guard FeatureFlags.trendingEnabled else { return }
navigation.hideViewsAboveBrowser()
navigation.tabSelection = .trending
#if os(macOS)

View File

@@ -5,8 +5,6 @@ struct ControlsOverlay: View {
@ObservedObject private var player = PlayerModel.shared
private var model = PlayerControlsModel.shared
@State private var availableCaptions: [Captions] = []
@State private var isLoadingCaptions = true
@State private var contentSize: CGSize = .zero
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
@@ -23,7 +21,10 @@ struct ControlsOverlay: View {
}
@FocusState private var focusedField: Field?
@State private var presentingButtonHintAlert = false
@State private var presentingQualityProfileMenu = false
@State private var presentingStreamMenu = false
@State private var presentingCaptionsMenu = false
@State private var presentingAudioTrackMenu = false
#endif
var body: some View {
@@ -104,19 +105,9 @@ struct ControlsOverlay: View {
#if os(tvOS)
.padding(.horizontal, 40)
#endif
#if os(tvOS)
Text("Press and hold remote button to open captions and quality menus")
.frame(maxWidth: 400)
.font(.caption)
.foregroundColor(.secondary)
#endif
}
.frame(maxHeight: overlayHeight)
.frame(maxHeight: contentSize.height)
#if os(tvOS)
.alert(isPresented: $presentingButtonHintAlert) {
Alert(title: Text("Press and hold to open this menu"))
}
.onAppear {
focusedField = .qualityProfile
}
@@ -127,14 +118,6 @@ struct ControlsOverlay: View {
player.activeBackend == .mpv ? "Rate & Captions" : "Playback Rate"
}
private var overlayHeight: Double {
#if os(tvOS)
contentSize.height + 80.0
#else
contentSize.height
#endif
}
private func controlsHeader(_ text: String) -> some View {
Text(text)
.font(.system(.caption))
@@ -279,23 +262,25 @@ struct ControlsOverlay: View {
.modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3))
#else
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
ControlsOverlayButton(
focusedField: $focusedField,
field: .qualityProfile,
onSelect: { presentingQualityProfileMenu = true }
) {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.lineLimit(1)
.frame(maxWidth: 320)
}
.contextMenu {
.alert("Quality Profile", isPresented: $presentingQualityProfileMenu) {
Button("Automatic") { player.qualityProfileSelection = nil }
ForEach(qualityProfiles) { qualityProfile in
Button {
Button(qualityProfile.description) {
player.qualityProfileSelection = qualityProfile
} label: {
Text(qualityProfile.description)
}
Button("Cancel", role: .cancel) {}
}
Button("Cancel", role: .cancel) {}
}
#endif
}
@@ -330,7 +315,7 @@ struct ControlsOverlay: View {
.modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3))
#else
StreamControl(focusedField: $focusedField)
StreamControl(focusedField: $focusedField, presentingStreamMenu: $presentingStreamMenu)
#endif
}
@@ -369,29 +354,31 @@ struct ControlsOverlay: View {
.modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3))
#else
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
ControlsOverlayButton(
focusedField: $focusedField,
field: .captions,
onSelect: { presentingCaptionsMenu = true }
) {
HStack(spacing: 8) {
Image(systemName: "text.bubble")
if let captions = captionsBinding.wrappedValue,
let language = LanguageCodes(rawValue: captions.code)
{
Text("\(language.description.capitalized) (\(language.rawValue))")
.foregroundColor(.accentColor)
} else {
if captionsBinding.wrappedValue == nil {
Text("Not available")
} else {
if player.currentVideo?.captions.isEmpty == false {
Text("Disabled")
.foregroundColor(.accentColor)
} else {
Text("Not available")
}
}
}
.frame(maxWidth: 320)
}
.contextMenu {
.alert("Captions", isPresented: $presentingCaptionsMenu) {
Button("Disabled") { captionsBinding.wrappedValue = nil }
ForEach(availableCaptions) { caption in
ForEach(player.currentVideo?.captions ?? []) { caption in
Button(caption.description) { captionsBinding.wrappedValue = caption }
}
Button("Cancel", role: .cancel) {}
@@ -400,7 +387,7 @@ struct ControlsOverlay: View {
}
@ViewBuilder private var captionsPicker: some View {
let captions = availableCaptions
let captions = player.currentVideo?.captions ?? []
Picker("Captions", selection: captionsBinding) {
if captions.isEmpty {
Text("Not available").tag(Captions?.none)
@@ -412,31 +399,6 @@ struct ControlsOverlay: View {
}
}
.disabled(captions.isEmpty)
.onAppear {
loadCaptions()
}
}
private func loadCaptions() {
isLoadingCaptions = true
// Fetch captions asynchronously
Task {
let fetchedCaptions = await fetchCaptions()
await MainActor.run {
// Update state on the main thread
self.availableCaptions = fetchedCaptions
self.isLoadingCaptions = false
}
}
}
private func fetchCaptions() async -> [Captions] {
// Access currentVideo from the main actor context
await MainActor.run {
// Safely access the main actor-isolated currentVideo property
player.currentVideo?.captions ?? []
}
}
private var captionsBinding: Binding<Captions?> {
@@ -467,11 +429,15 @@ struct ControlsOverlay: View {
.frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40)
#else
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
ControlsOverlayButton(
focusedField: $focusedField,
field: .audioTrack,
onSelect: { presentingAudioTrackMenu = true }
) {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.frame(maxWidth: 320)
}
.contextMenu {
.alert("Audio Track", isPresented: $presentingAudioTrackMenu) {
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
Button(track.description) { player.selectedAudioTrackIndex = index }
}

View File

@@ -199,6 +199,16 @@ struct TimelineView: View {
.gesture(
DragGesture(minimumDistance: 5, coordinateSpace: .global)
.onChanged { value in
#if os(iOS)
// In fullscreen, ignore gestures that start in the top notification center area
// to allow system notification center gesture to work
if player.playingFullScreen {
if value.startLocation.y < Constants.notificationCenterZoneHeight {
return
}
}
#endif
if !dragging {
controls.removeTimer()
draggedFrom = current

View File

@@ -209,16 +209,22 @@ struct PlaybackSettings: View {
.controlSize(.large)
.frame(width: 100, alignment: .center)
#elseif os(iOS)
Menu {
ratePicker
} label: {
ZStack {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
.frame(width: 70)
Menu {
ratePicker
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
.frame(width: 70)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 70, height: 40)
#else
Text(player.rateLabel(player.currentRate))
@@ -331,12 +337,20 @@ struct PlaybackSettings: View {
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#else
Menu {
playbackModePicker
} label: {
ZStack {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
.foregroundColor(.accentColor)
Menu {
playbackModePicker
} label: {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
.foregroundColor(.accentColor)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.transaction { t in t.animation = .none }
#endif
}
@@ -356,15 +370,22 @@ struct PlaybackSettings: View {
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
Menu {
qualityProfilePicker
} label: {
ZStack {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
Menu {
qualityProfilePicker
} label: {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40)
#else
@@ -406,15 +427,22 @@ struct PlaybackSettings: View {
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
Menu {
StreamControl()
} label: {
ZStack {
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
.frame(width: 140, height: 40, alignment: .trailing)
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
.frame(width: 140, height: 40, alignment: .trailing)
Menu {
StreamControl()
} label: {
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
.frame(width: 140, height: 40, alignment: .trailing)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.frame(width: 140, height: 40, alignment: .trailing)
#else
StreamControl(focusedField: $focusedField)
@@ -429,18 +457,13 @@ struct PlaybackSettings: View {
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
Menu {
if videoCaptions?.isEmpty == false {
captionsPicker
}
} label: {
ZStack {
HStack(spacing: 4) {
Image(systemName: "text.bubble")
if let captions = player.captions,
let language = LanguageCodes(rawValue: captions.code)
{
Text("\(language.description.capitalized) (\(language.rawValue))")
.foregroundColor(.accentColor)
} else {
if videoCaptions?.isEmpty == true {
Text("Not available")
@@ -449,13 +472,38 @@ struct PlaybackSettings: View {
}
}
}
.foregroundColor(.accentColor)
.frame(alignment: .trailing)
.frame(height: 40)
.disabled(videoCaptions?.isEmpty == true)
Menu {
if videoCaptions?.isEmpty == false {
captionsPicker
}
} label: {
HStack(spacing: 4) {
Image(systemName: "text.bubble")
if let captions = player.captions,
let language = LanguageCodes(rawValue: captions.code)
{
Text("\(language.description.capitalized) (\(language.rawValue))")
} else {
if videoCaptions?.isEmpty == true {
Text("Not available")
} else {
Text("Disabled")
}
}
}
.foregroundColor(.accentColor)
.frame(alignment: .trailing)
.frame(height: 40)
.opacity(0)
.disabled(videoCaptions?.isEmpty == true)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.accentColor)
#else
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
HStack(spacing: 8) {
@@ -500,15 +548,22 @@ struct PlaybackSettings: View {
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
Menu {
audioTrackPicker
} label: {
ZStack {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
Menu {
audioTrackPicker
} label: {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40)
#else

View File

@@ -16,6 +16,16 @@ extension VideoPlayerView {
state = true
}
.onChanged { value in
#if os(iOS)
// In fullscreen, ignore gestures that start in the top notification center area
// to allow system notification center gesture to work
if player.playingFullScreen {
if value.startLocation.y < Constants.notificationCenterZoneHeight {
return
}
}
#endif
guard player.presentingPlayer,
!controlsOverlayModel.presenting,
dragGestureState,

View File

@@ -3,9 +3,11 @@ import SwiftUI
struct StreamControl: View {
#if os(tvOS)
var focusedField: FocusState<ControlsOverlay.Field?>.Binding?
@Binding var presentingStreamMenu: Bool
init(focusedField: FocusState<ControlsOverlay.Field?>.Binding?) {
init(focusedField: FocusState<ControlsOverlay.Field?>.Binding?, presentingStreamMenu: Binding<Bool>) {
self.focusedField = focusedField
_presentingStreamMenu = presentingStreamMenu
}
#endif
@@ -45,16 +47,20 @@ struct StreamControl: View {
.fixedSize()
#endif
#else
ControlsOverlayButton(focusedField: focusedField!, field: .stream) {
ControlsOverlayButton(
focusedField: focusedField!,
field: .stream,
onSelect: { presentingStreamMenu = true }
) {
Text(player.streamSelection?.shortQuality ?? "loading")
.frame(maxWidth: 320)
}
.contextMenu {
.alert("Stream Quality", isPresented: $presentingStreamMenu) {
ForEach(streams) { stream in
Button(stream.description) { player.streamSelection = stream }
}
Button("Close", role: .cancel) {}
Button("Cancel", role: .cancel) {}
}
#endif
}
@@ -79,7 +85,7 @@ struct StreamControl: View {
struct StreamControl_Previews: PreviewProvider {
static var previews: some View {
#if os(tvOS)
StreamControl(focusedField: .none)
StreamControl(focusedField: .none, presentingStreamMenu: .constant(false))
.injectFixtureEnvironmentObjects()
#else
StreamControl()

View File

@@ -189,6 +189,7 @@ struct VideoDetails: View {
@Environment(\.navigationStyle) private var navigationStyle
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
#endif
@Environment(\.colorScheme) private var colorScheme
@@ -397,7 +398,11 @@ struct VideoDetails: View {
}
}
}
#if os(iOS)
.padding(.bottom, fullScreen ? max(60, safeAreaModel.safeArea.bottom + 20) : player.playerSize.height + safeAreaModel.safeArea.bottom + 20)
#else
.padding(.bottom, 60)
#endif
}
#if os(iOS)
.onAppear {

View File

@@ -190,40 +190,7 @@ struct PlaylistsView: View {
#if os(iOS)
var playlistsMenu: some View {
let title = currentPlaylist?.title ?? "Playlists"
return Menu {
Menu {
selectPlaylistButton
} label: {
Label(title, systemImage: "list.and.film")
}
Section {
if let currentPlaylist {
playButtons
editPlaylistButton
if let account = accounts.current {
FavoriteButton(item: FavoriteItem(section: .playlist(account.id, currentPlaylist.id)))
.id(currentPlaylist.id)
}
}
}
if accounts.signedIn {
newPlaylistButton
}
ListingStyleButtons(listingStyle: $playlistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
return ZStack {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "list.and.film")
@@ -239,9 +206,61 @@ struct PlaylistsView: View {
.imageScale(.small)
.lineLimit(1)
.frame(maxWidth: 320)
.transaction { t in t.animation = nil }
Menu {
Menu {
selectPlaylistButton
} label: {
Label(title, systemImage: "list.and.film")
}
Section {
if let currentPlaylist {
playButtons
editPlaylistButton
if let account = accounts.current {
FavoriteButton(item: FavoriteItem(section: .playlist(account.id, currentPlaylist.id)))
.id(currentPlaylist.id)
}
}
}
if accounts.signedIn {
newPlaylistButton
}
ListingStyleButtons(listingStyle: $playlistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "list.and.film")
Text(title)
.font(.headline)
}
.foregroundColor(.primary)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
}
.imageScale(.small)
.lineLimit(1)
.frame(maxWidth: 320)
.opacity(0)
}
.disabled(!accounts.signedIn)
}
.disabled(!accounts.signedIn)
.transaction { t in t.animation = nil }
}
#endif

View File

@@ -3,27 +3,15 @@ import SwiftUI
import SwiftUIIntrospect
struct FocusableSearchTextField: View {
@ObservedObject private var state = SearchModel.shared
var body: some View {
SearchTextField()
#if os(macOS)
.introspect(.textField, on: .macOS(.v12, .v13, .v14, .v15)) { textField in
state.textField = textField
SearchModel.shared.textField = textField
}
.onAppear {
DispatchQueue.main.async {
state.textField?.becomeFirstResponder()
}
}
#elseif os(iOS)
.introspect(.textField, on: .iOS(.v15, .v16, .v17, .v18)) { textField in
state.textField = textField
}
.onChange(of: state.focused) { newValue in
if newValue, let textField = state.textField, !textField.isFirstResponder {
textField.becomeFirstResponder()
textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
SearchModel.shared.textField?.becomeFirstResponder()
}
}
#endif

View File

@@ -55,9 +55,9 @@ struct SearchTextField: View {
TextField("Search...", text: $state.queryText) {
state.changeQuery { query in
query.query = state.queryText
navigation.hideKeyboard()
}
RecentsModel.shared.addQuery(state.queryText)
navigation.hideKeyboard()
}
.disableAutocorrection(true)
.textFieldStyle(.plain)

View File

@@ -49,10 +49,10 @@ struct SearchView: View {
.opacity(state.queryText.isEmpty ? 0 : 1)
} else {
results
.backport
.scrollDismissesKeyboardInteractively()
}
}
.backport
.scrollDismissesKeyboardInteractively()
}
.environment(\.listingStyle, searchListingStyle)
.toolbar {

View File

@@ -16,10 +16,12 @@ struct AdvancedSettings: View {
@Default(.feedCacheSize) private var feedCacheSize
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
@Default(.videoLoadingRetryCount) private var videoLoadingRetryCount
@Default(.avPlayerAllowsNonStreamableFormats) private var avPlayerAllowsNonStreamableFormats
@State private var filesToShare = [MPVClient.logFile]
@State private var presentingShareSheet = false
@ObservedObject private var player = PlayerModel.shared
private var settings = SettingsModel.shared
var body: some View {
@@ -73,6 +75,10 @@ struct AdvancedSettings: View {
videoLoadingRetryCountField
}
Section(header: SettingsHeader(text: "AVPlayer"), footer: avPlayerNonStreamableFormatsFooter) {
avPlayerAllowsNonStreamableFormatsToggle
}
Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) {
showMPVPlaybackStatsToggle
#if !os(tvOS)
@@ -370,6 +376,22 @@ struct AdvancedSettings: View {
Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted))
.foregroundColor(.secondary)
}
var avPlayerAllowsNonStreamableFormatsToggle: some View {
Toggle("Enable non-streamable formats (MP4/AVC1)", isOn: $avPlayerAllowsNonStreamableFormats)
.onChange(of: avPlayerAllowsNonStreamableFormats) { _ in
// Trigger refresh of available streams when setting changes
if let video = player.currentVideo {
player.loadAvailableStreams(video)
}
}
}
@ViewBuilder var avPlayerNonStreamableFormatsFooter: some View {
Text("Non-streamable video formats (MP4/AVC1) may take a long time to start playback with AVPlayer. These formats require downloading metadata before playback can begin. Limited to 1080p maximum. For better performance with these formats, use MPV backend instead.")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
struct AdvancedSettings_Previews: PreviewProvider {

View File

@@ -185,7 +185,7 @@ struct BrowsingSettings: View {
#if os(iOS)
Toggle("Show Documents", isOn: $showDocuments)
if Constants.isIPad {
if Constants.isIPhone {
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
.onChange(of: lockPortraitWhenBrowsing) { lock in
if lock {
@@ -258,11 +258,13 @@ struct BrowsingSettings: View {
private var visibleSectionsSettings: some View {
Section(header: SettingsHeader(text: "Sections".localized())) {
ForEach(VisibleSection.allCases, id: \.self) { section in
MultiselectRow(
title: section.title,
selected: visibleSections.contains(section)
) { value in
toggleSection(section, value: value)
if section != .trending || FeatureFlags.trendingEnabled {
MultiselectRow(
title: section.title,
selected: visibleSections.contains(section)
) { value in
toggleSection(section, value: value)
}
}
}
}
@@ -279,7 +281,9 @@ struct BrowsingSettings: View {
Spacer()
Picker("Startup section", selection: $startupSection) {
ForEach(StartupSection.allCases, id: \.rawValue) { section in
Text(section.label).tag(section)
if section != .trending || FeatureFlags.trendingEnabled {
Text(section.label).tag(section)
}
}
}
.modifier(SettingsPickerModifier())
@@ -287,7 +291,9 @@ struct BrowsingSettings: View {
#else
Picker("Startup section", selection: $startupSection) {
ForEach(StartupSection.allCases, id: \.rawValue) { section in
Text(section.label).tag(section)
if section != .trending || FeatureFlags.trendingEnabled {
Text(section.label).tag(section)
}
}
}
.modifier(SettingsPickerModifier())

View File

@@ -9,6 +9,7 @@ struct InstanceSettings: View {
@State private var frontendURL = ""
@State private var proxiesVideos = false
@State private var invidiousCompanion = false
@State private var hideVideosWithoutDuration = false
var body: some View {
List {
@@ -97,6 +98,16 @@ struct InstanceSettings: View {
.onChange(of: invidiousCompanion) { newValue in
InstancesModel.shared.setInvidiousCompanion(instance, newValue)
}
Section(footer: Text("This can be used to hide shorts".localized())) {
hideVideosWithoutDurationToggle
.onAppear {
hideVideosWithoutDuration = instance.hideVideosWithoutDuration
}
.onChange(of: hideVideosWithoutDuration) { newValue in
InstancesModel.shared.setHideVideosWithoutDuration(instance, newValue)
}
}
}
}
#if os(tvOS)
@@ -116,6 +127,10 @@ struct InstanceSettings: View {
Toggle("Invidious companion", isOn: $invidiousCompanion)
}
private var hideVideosWithoutDurationToggle: some View {
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
}
private func removeAccount(_ account: Account) {
AccountsModel.remove(account)
accountsChanged.toggle()

View File

@@ -360,7 +360,7 @@ struct SettingsView: View {
case .locations:
return 600
case .advanced:
return 630
return 700
case .importExport:
return 580
case .help:

View File

@@ -223,6 +223,7 @@ struct FeedView: View {
var header: some View {
HStack(spacing: 16) {
#if os(tvOS)
// swiftlint:disable:next deployment_target
if #available(tvOS 17.0, *) {
Menu {
accountsPicker

View File

@@ -168,22 +168,7 @@ struct TrendingView: View {
#if os(iOS)
var trendingMenu: some View {
Menu {
countryButton
categoryButton
ListingStyleButtons(listingStyle: $trendingListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
ZStack {
HStack(spacing: 12) {
Text("\(country.flag) \(country.name)")
.font(.headline)
@@ -194,6 +179,35 @@ struct TrendingView: View {
.imageScale(.small)
}
.frame(maxWidth: 320)
Menu {
countryButton
categoryButton
ListingStyleButtons(listingStyle: $trendingListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
HStack(spacing: 12) {
Text("\(country.flag) \(country.name)")
.font(.headline)
.foregroundColor(.primary)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.small)
}
.frame(maxWidth: 320)
.opacity(0)
}
}
}
#endif

View File

@@ -76,6 +76,8 @@ struct ThumbnailView: View {
}
var placeholder: some View {
Rectangle().fill(Color("PlaceholderColor"))
Rectangle()
.fill(Color("PlaceholderColor"))
.aspectRatio(Constants.aspectRatio16x9, contentMode: .fill)
}
}

View File

@@ -432,14 +432,11 @@ struct VideoCell: View {
}
private var thumbnailImage: some View {
Group {
VideoCellThumbnail(video: video)
#if os(tvOS)
.frame(minHeight: 320)
#endif
}
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
VideoCellThumbnail(video: video)
#if os(tvOS)
.frame(minHeight: 320)
#endif
.mask(RoundedRectangle(cornerRadius: thumbnailRoundingCornerRadius))
}
private var time: String? {
@@ -477,11 +474,10 @@ struct VideoCellThumbnail: View {
private var thumbnails: ThumbnailsModel { .shared }
var body: some View {
let (url, quality) = thumbnails.best(video)
let aspectRatio = (quality == .default || quality == .high) ? Constants.aspectRatio4x3 : Constants.aspectRatio16x9
let (url, _) = thumbnails.best(video)
ThumbnailView(url: url)
.aspectRatio(aspectRatio, contentMode: .fill)
.aspectRatio(Constants.aspectRatio16x9, contentMode: .fill)
}
}

View File

@@ -54,7 +54,7 @@ struct ContentItemView: View {
return false
}
guard hideShorts, item.contentType == .video, let video = item.video else {
guard FeatureFlags.hideShortsEnabled, hideShorts, item.contentType == .video, let video = item.video else {
return true
}

View File

@@ -5,22 +5,24 @@ struct HideShortsButtons: View {
@Default(.hideShorts) private var hideShorts
var body: some View {
Button {
hideShorts.toggle()
} label: {
Group {
if hideShorts {
Label("Short videos: hidden", systemImage: "bolt.slash.fill")
.help("Short videos: hidden")
} else {
Label("Short videos: visible", systemImage: "bolt.fill")
.help("Short videos: visible")
if FeatureFlags.hideShortsEnabled {
Button {
hideShorts.toggle()
} label: {
Group {
if hideShorts {
Label("Short videos: hidden", systemImage: "bolt.slash.fill")
.help("Short videos: hidden")
} else {
Label("Short videos: visible", systemImage: "bolt.fill")
.help("Short videos: visible")
}
}
#if os(tvOS)
.font(.caption)
.imageScale(.small)
#endif
}
#if os(tvOS)
.font(.caption)
.imageScale(.small)
#endif
}
}
}

View File

@@ -144,10 +144,15 @@ struct OpenVideosView: View {
Spacer()
#endif
#if os(iOS)
Menu {
playbackModePicker
} label: {
ZStack {
Text(playbackMode.description)
Menu {
playbackModePicker
} label: {
Text(playbackMode.description)
.opacity(0)
}
}
#else
playbackModePicker

View File

@@ -90,21 +90,10 @@ struct PopularView: View {
#if os(iOS)
private var popularMenu: some View {
Menu {
ListingStyleButtons(listingStyle: $popularListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
ZStack {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "arrow.up.right.circle.fill")
Image(systemName: "chart.bar.fill")
.foregroundColor(.primary)
.imageScale(.small)
@@ -117,8 +106,38 @@ struct PopularView: View {
.foregroundColor(.accentColor)
.imageScale(.small)
}
.transaction { t in t.animation = nil }
Menu {
ListingStyleButtons(listingStyle: $popularListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "chart.bar.fill")
.foregroundColor(.primary)
.imageScale(.small)
Text("Popular")
.font(.headline)
.foregroundColor(.primary)
}
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.small)
}
.opacity(0)
}
}
.transaction { t in t.animation = nil }
}
#endif

View File

@@ -33,23 +33,21 @@ struct VideoContextMenuView: View {
var body: some View {
ZStack {
// Conditional overlay to block taps on underlying views
if isOverlayVisible {
Color.clear
.contentShape(Rectangle())
#if !os(tvOS)
// This is not available on tvOS < 16 so we leave out.
// TODO: remove #if when setting the minimum deployment target to >= 16
.onTapGesture {
// Dismiss overlay without triggering other interactions
isOverlayVisible = false
}
#endif
.ignoresSafeArea() // Ensure overlay covers the entire screen
.accessibilityLabel("Dismiss context menu")
.accessibilityHint("Tap to close the context")
.accessibilityAddTraits(.isButton)
}
#if os(iOS)
// Conditional overlay to block taps on underlying views
if isOverlayVisible {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
// Dismiss overlay without triggering other interactions
isOverlayVisible = false
}
.ignoresSafeArea() // Ensure overlay covers the entire screen
.accessibilityLabel("Dismiss context menu")
.accessibilityHint("Tap to close the context")
.accessibilityAddTraits(.isButton)
}
#endif
if video.videoID != Video.fixtureID {
contextMenu
@@ -156,7 +154,9 @@ struct VideoContextMenuView: View {
}
#if os(tvOS)
Button("Cancel", role: .cancel) {}
if #unavailable(tvOS 18.0) {
Button("Cancel", role: .cancel) {}
}
#endif
}

View File

@@ -162,6 +162,23 @@ struct YatteeApp: App {
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
NotificationCenter.default.addObserver(
forName: .accountConfigurationComplete,
object: nil,
queue: .main
) { _ in
let startupSection = Defaults[.startupSection]
var section: TabSelection? = startupSection.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
NavigationModel.shared.tabSelection = section ?? .search
}
if !Defaults[.lastAccountIsPublic] {
AccountsModel.shared.configureAccount()
}
@@ -180,17 +197,6 @@ struct YatteeApp: App {
}
}
let startupSection = Defaults[.startupSection]
var section: TabSelection? = startupSection.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
NavigationModel.shared.tabSelection = section ?? .search
DispatchQueue.main.async {
playlists.load()
}
@@ -231,6 +237,10 @@ struct YatteeApp: App {
self.migrateQualityProfiles()
}
DispatchQueue.global(qos: .userInitiated).async {
self.cleanupDisabledFeatures()
}
#if os(iOS)
DispatchQueue.global(qos: .userInitiated).async {
self.migrateRotateToLandscapeOnEnterFullScreen()
@@ -285,6 +295,34 @@ struct YatteeApp: App {
}
#endif
func cleanupDisabledFeatures() {
// Remove trending from visible sections if feature flag is disabled
if !FeatureFlags.trendingEnabled {
var visibleSections = Defaults[.visibleSections]
if visibleSections.contains(.trending) {
visibleSections.remove(.trending)
Defaults[.visibleSections] = visibleSections
}
// Reset startup section if set to trending
if Defaults[.startupSection] == .trending {
Defaults[.startupSection] = .home
}
// Remove trending favorites
let trendingFavorites = favorites.all.filter { item in
if case .trending = item.section {
return true
}
return false
}
for favorite in trendingFavorites {
favorites.remove(favorite)
}
}
}
var navigationStyle: NavigationStyle {
#if os(iOS)
return horizontalSizeClass == .compact ? .tab : .sidebar

View File

@@ -43,6 +43,9 @@
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
1B81344D4D2A0B0363850A9E /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
2446210B2B03C320154634A5 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
3528A0FEB2B02A52B715041C /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3700155A271B0D4D0049C794 /* PipedAPI.swift */; };
@@ -207,6 +210,9 @@
37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
3732BFD028B83763009F3F4D /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 3732BFCF28B83763009F3F4D /* KeychainAccess */; };
3736882B2ECE7947006B1D1F /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736882A2ECE7947006B1D1F /* Notification+Names.swift */; };
3736882C2ECE7947006B1D1F /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736882A2ECE7947006B1D1F /* Notification+Names.swift */; };
3736882D2ECE7947006B1D1F /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3736882A2ECE7947006B1D1F /* Notification+Names.swift */; };
3738535429451DC800D2D0CB /* BookmarksCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */; };
3738535529451DC800D2D0CB /* BookmarksCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */; };
3738535629451DC800D2D0CB /* BookmarksCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */; };
@@ -1071,6 +1077,8 @@
37FFC440272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
37FFC441272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
37FFC442272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
4EDC5582D5232B58E0E6A3CD /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
C61471C67790128B7638173B /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
E24DC6582BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
E24DC6592BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
E24DC65A2BFA124100BF6187 /* UserAgentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24DC6572BFA124100BF6187 /* UserAgentManager.swift */; };
@@ -1086,6 +1094,9 @@
E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; };
E69D11698A85867A28CD6A5A /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
F18DFC08B722DE4D5ACB791A /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
F3BFD18BABAA233ADA094AC6 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D886FD1371688A42060DF82 /* FeatureFlags.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -1215,6 +1226,7 @@
373197D82732015300EF734F /* RelatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedView.swift; sourceTree = "<group>"; };
37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; };
37367E582B8F63C200436163 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
3736882A2ECE7947006B1D1F /* Notification+Names.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Names.swift"; sourceTree = "<group>"; };
3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksCacheModel.swift; sourceTree = "<group>"; };
373C8FE3275B955100CB5936 /* CommentsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsPage.swift; sourceTree = "<group>"; };
373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
@@ -1558,6 +1570,7 @@
3DA101AD287C30F50027D920 /* DEVELOPMENT_TEAM.template.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.template.xcconfig; sourceTree = "<group>"; };
3DA101AE287C30F50027D920 /* DEVELOPMENT_TEAM.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.xcconfig; sourceTree = "<group>"; };
3DA101AF287C30F50027D920 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
5D886FD1371688A42060DF82 /* FeatureFlags.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = "<group>"; };
E24DC6572BFA124100BF6187 /* UserAgentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentManager.swift; sourceTree = "<group>"; };
E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatus.swift; sourceTree = "<group>"; };
E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = "<group>"; };
@@ -2240,6 +2253,7 @@
37C7A9022679058300E721B4 /* Extensions */ = {
isa = PBXGroup;
children = (
3736882A2ECE7947006B1D1F /* Notification+Names.swift */,
379775922689365600DD52A8 /* Array+Next.swift */,
37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */,
376578842685429C00D4EA09 /* CaseIterable+Next.swift */,
@@ -2325,6 +2339,7 @@
37D4B0C22671614700C925CA /* YatteeApp.swift */,
37D4B0C42671614800C925CA /* Assets.xcassets */,
37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
5D886FD1371688A42060DF82 /* FeatureFlags.swift */,
);
path = Shared;
sourceTree = "<group>";
@@ -3064,6 +3079,7 @@
3762C46D2BF66CDD008E50B8 /* EnvironmentValues.swift in Sources */,
37095E82291DC85400301883 /* ShareViewController.swift in Sources */,
3762C47A2BF66F04008E50B8 /* Strings.swift in Sources */,
C61471C67790128B7638173B /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3076,6 +3092,7 @@
37C0C0FF28665EAC007F6F78 /* VideosApp.swift in Sources */,
378FFBC92866018A009E3FBE /* URLParserTests.swift in Sources */,
371B88F82A1A310100D57683 /* String+Format.swift in Sources */,
3528A0FEB2B02A52B715041C /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3297,6 +3314,7 @@
37BA794F26DC3E0E002A0235 /* Int+Format.swift in Sources */,
378E9C3C2945565500B2D696 /* SubscriptionsView.swift in Sources */,
37D6025928C17375009E8D98 /* PlaybackStatsView.swift in Sources */,
3736882D2ECE7947006B1D1F /* Notification+Names.swift in Sources */,
37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
374C053B2724614F009BDDBE /* PlayerTVMenu.swift in Sources */,
37A9965A26D6F8CA006E3224 /* HorizontalCells.swift in Sources */,
@@ -3403,6 +3421,7 @@
37B795902771DAE0001CF27B /* OpenURLHandler.swift in Sources */,
37732FF02703A26300F04329 /* AccountValidationStatus.swift in Sources */,
37A7D72F2B681011009CB1ED /* OtherDataSettingsGroupImporter.swift in Sources */,
2446210B2B03C320154634A5 /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3644,6 +3663,7 @@
37A81BFA294BD1440081D322 /* WatchView.swift in Sources */,
3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */,
376BE50C27349108009AD608 /* BrowsingSettings.swift in Sources */,
3736882C2ECE7947006B1D1F /* Notification+Names.swift in Sources */,
3710A55629488C7D006F8025 /* PlaceholderListItem.swift in Sources */,
37EBD8CB27AF26C200F1C24B /* MPVBackend.swift in Sources */,
37D4B19826717E1500C925CA /* Video.swift in Sources */,
@@ -3710,6 +3730,7 @@
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
37A362BF29537AAA00BDF328 /* PlaybackSettings.swift in Sources */,
4EDC5582D5232B58E0E6A3CD /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3718,6 +3739,7 @@
buildActionMask = 2147483647;
files = (
37D4B0D92671614900C925CA /* Tests_iOS.swift in Sources */,
F3BFD18BABAA233ADA094AC6 /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3771,6 +3793,7 @@
3766AFD2273DA97D00686348 /* Int+FormatTests.swift in Sources */,
3774124F27387D2300423605 /* SubscribedChannelsModel.swift in Sources */,
3774126127387D2D00423605 /* AccountsModel.swift in Sources */,
F18DFC08B722DE4D5ACB791A /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -3942,6 +3965,7 @@
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
3756C2A82861131100E4B059 /* NetworkState.swift in Sources */,
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
3736882B2ECE7947006B1D1F /* Notification+Names.swift in Sources */,
37E75CCD2B6AEB01003A6237 /* RecentlyOpenedExporter.swift in Sources */,
377FF891291A99580028EB0B /* HistoryView.swift in Sources */,
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
@@ -4064,6 +4088,7 @@
3797758D2689345500DD52A8 /* Store.swift in Sources */,
37484C2F26FC844700287258 /* InstanceSettings.swift in Sources */,
37A7D7312B681011009CB1ED /* OtherDataSettingsGroupImporter.swift in Sources */,
1B81344D4D2A0B0363850A9E /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -4072,6 +4097,7 @@
buildActionMask = 2147483647;
files = (
37D4B176267164B000C925CA /* YatteeUITests.swift in Sources */,
E69D11698A85867A28CD6A5A /* FeatureFlags.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -4142,7 +4168,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Open in Yattee";
@@ -4173,7 +4199,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -4204,7 +4230,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -4224,7 +4250,7 @@
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -4388,7 +4414,7 @@
CODE_SIGN_ENTITLEMENTS = "iOS/Yattee (iOS).entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
@@ -4442,7 +4468,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
GCC_PREPROCESSOR_DEFINITIONS = "GLES_SILENCE_DEPRECATION=1";
@@ -4496,7 +4522,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -4535,7 +4561,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
DEAD_CODE_STRIPPING = YES;
"DEVELOPMENT_TEAM[sdk=macosx*]" = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -4570,7 +4596,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4593,7 +4619,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4618,7 +4644,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4642,7 +4668,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
@@ -4668,7 +4694,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -4708,7 +4734,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
"CODE_SIGN_IDENTITY[sdk=appletvos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
DEVELOPMENT_ASSET_PATHS = "";
"DEVELOPMENT_TEAM[sdk=appletvos*]" = 78Z5H3M6RJ;
ENABLE_PREVIEWS = YES;
@@ -4748,7 +4774,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -4771,7 +4797,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 209;
CURRENT_PROJECT_VERSION = 241;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -5034,7 +5060,7 @@
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
minimumVersion = 26.0.0;
};
};
37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */ = {

View File

@@ -61,7 +61,7 @@
"location" : "https://github.com/mpvkit/MPVKit.git",
"state" : {
"branch" : "main",
"revision" : "360b5002bf607a94f24ec8977db94bd9811d5357"
"revision" : "fef0f54bfd7e37e0547e057880b28992540ddbcc"
}
},
{
@@ -168,8 +168,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : {
"revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336",
"version" : "1.3.0"
"revision" : "a08b87f96b41055577721a6e397562b21ad52454",
"version" : "26.0.0"
}
},
{

View File

@@ -22,13 +22,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
OrientationTracker.shared.startDeviceOrientationTracking()
OrientationModel.shared.startOrientationUpdates()
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
logger.error("Failed to set audio session category: \(error)")
}
UIApplication.shared.beginReceivingRemoteControlEvents()
#endif

View File

@@ -6,6 +6,12 @@ enum Orientation {
static var logger = Logger(label: "stream.yattee.orientation")
static func lockOrientation(_ orientation: UIInterfaceOrientationMask) {
// Orientation locking is only for iPhone, not iPad
guard Constants.isIPhone else {
logger.info("skipping orientation lock on iPad")
return
}
if let delegate = AppDelegate.instance {
delegate.orientationLock = orientation
@@ -18,6 +24,12 @@ enum Orientation {
}
static func lockOrientation(_ orientation: UIInterfaceOrientationMask, andRotateTo rotateOrientation: UIInterfaceOrientation? = nil) {
// Orientation locking and rotation is only for iPhone, not iPad
guard Constants.isIPhone else {
logger.info("skipping orientation lock and rotation on iPad")
return
}
lockOrientation(orientation)
guard let rotateOrientation else {

View File

@@ -12,6 +12,7 @@ struct InstancesSettings: View {
@State private var frontendURL = ""
@State private var proxiesVideos = false
@State private var invidiousCompanion = false
@State private var hideVideosWithoutDuration = false
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var accounts = AccountsModel.shared
@@ -109,6 +110,18 @@ struct InstancesSettings: View {
.onChange(of: invidiousCompanion) { newValue in
InstancesModel.shared.setInvidiousCompanion(selectedInstance, newValue)
}
hideVideosWithoutDurationToggle
.onAppear {
hideVideosWithoutDuration = selectedInstance.hideVideosWithoutDuration
}
.onChange(of: hideVideosWithoutDuration) { newValue in
InstancesModel.shared.setHideVideosWithoutDuration(selectedInstance, newValue)
}
Text("This can be used to hide shorts")
.font(.caption)
.foregroundColor(.secondary)
}
if selectedInstance != nil, !selectedInstance.app.supportsAccounts {
@@ -201,6 +214,10 @@ struct InstancesSettings: View {
private var invidiousCompanionToggle: some View {
Toggle("Invidious companion", isOn: $invidiousCompanion)
}
private var hideVideosWithoutDurationToggle: some View {
Toggle("Experimental: Hide videos without duration", isOn: $hideVideosWithoutDuration)
}
}
struct InstancesSettingsView_Previews: PreviewProvider {

View File

@@ -4,25 +4,52 @@ struct ControlsOverlayButton<LabelView: View>: View {
var focusedField: FocusState<ControlsOverlay.Field?>.Binding
var field: ControlsOverlay.Field
let label: LabelView
var onSelect: (() -> Void)?
init(
focusedField: FocusState<ControlsOverlay.Field?>.Binding,
field: ControlsOverlay.Field,
onSelect: (() -> Void)? = nil,
@ViewBuilder label: @escaping () -> LabelView
) {
self.focusedField = focusedField
self.field = field
self.onSelect = onSelect
self.label = label()
}
var body: some View {
label
.padding()
.frame(width: 400)
.focusable()
let isFocused = focusedField.wrappedValue == field
if let onSelect {
Button(action: onSelect) {
label
.foregroundColor(isFocused ? .black : .white)
.padding()
.frame(width: 400)
}
.buttonStyle(TVButtonStyle(isFocused: isFocused))
.focused(focusedField, equals: field)
.background(focusedField.wrappedValue == field ? Color.white : Color.secondary)
.foregroundColor(focusedField.wrappedValue == field ? Color.black : Color.white)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
label
.foregroundColor(isFocused ? .black : .white)
.padding()
.frame(width: 400)
.focusable()
.focused(focusedField, equals: field)
.background(isFocused ? Color.white : Color.gray.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
}
struct TVButtonStyle: ButtonStyle {
let isFocused: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(isFocused ? Color.white : Color.gray.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
}
}

View File

@@ -29,7 +29,7 @@ struct TVNavigationView: View {
.tag(TabSelection.popular)
}
if visibleSections.contains(.trending) {
if FeatureFlags.trendingEnabled && visibleSections.contains(.trending) {
LazyView(TrendingView())
.tabItem { Text("Trending") }
.tag(TabSelection.trending)