From 216375905cab8906c27b1b65a45b46d0c4428831 Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Fri, 4 Apr 2025 23:13:20 -0600 Subject: [PATCH] [iOS & tvOS] Trailers (#1456) * ItemViewModel Trailers * iOS done. * Sections >>> Divider * tvOS kind of. * Button/Menu cleanup * Huge ActionButton overhaul * Error Handling, ActionButton/Menu standardization, and ActionButtonLayout cleanup part 1. * cleanup * cleanup * Combine ActionButton logic. Complete ActionButton rework and animation/style rework. Should this be 3 files?? * Dumb sizing error. Get size from WIDTH not HEIGHT! Height is always 100 and Width is larger. * Pressed buttons are but focused buttons but slight less. Pressed buttons are still bigger than default, unfocused buttons. TIL. * Cleanup / Structure * Remove Test. * New Setting. Version on PlayButton Row. Complete TrailerMenu revamp. Make ActionButtonLayout a single row. * Spacing & remove test logic * VERY WIP * Fix the compact-ness * Linting. * Remove Testing logic. * Pre-Cleanup - WIP * Finalized. Moved ScrollingText to tvOS Only. * MediaURL? = nil but it's already nil by default. * Error on the View not the button. This was NOT showing for the button since it lived on the Menu. This resolves this. * wip * Update VersionMenu.swift * Remove scrollingText from this PR. * Remove labels & iOS Action Button cleanup / no foregroundStyle on de-selected. * ActionButtonScaling * .card all buttons in ActionButton * Slow and less bounce-i-fy the menu animations. Also, slight padding * Wait, don't add this padding this isn't needed. * localize --------- Co-authored-by: Ethan Pippin --- Shared/Objects/TrailerSelection.swift | 36 +++++ Shared/Strings/Strings.swift | 10 ++ .../StoredValue/StoredValues+User.swift | 8 + .../ItemViewModel/ItemViewModel.swift | 22 ++- .../ItemView/Components/ActionButton.swift | 126 +++++++++++++++ .../ActionButtonHStack.swift | 73 ++++++--- .../Components}/RefreshMetadataButton.swift | 2 +- .../Components/TrailerMenu.swift | 150 ++++++++++++++++++ .../ActionButtons/ActionButton.swift | 64 -------- .../Components/ActionButtons/ActionMenu.swift | 54 ------- .../ActionButtons/VersionMenu.swift | 59 ------- .../PlayButton/Components/VersionMenu.swift | 52 ++++++ .../{ => PlayButton}/PlayButton.swift | 35 +++- .../EpisodeItemContentView.swift | 2 +- .../ScrollViews/CinematicScrollView.swift | 2 +- .../Components/Sections/ItemSection.swift | 4 + Swiftfin.xcodeproj/project.pbxproj | 92 +++++++++-- .../ActionButton/ActionButton.swift | 86 ++++++++++ .../Components/ActionButtonHStack.swift | 118 -------------- .../ActionButtonHStack.swift | 127 +++++++++++++++ .../Components/TrailerMenu.swift | 140 ++++++++++++++++ .../Components/VersionMenu.swift | 49 ++++++ .../Components/Sections/ItemSection.swift | 7 + Translations/en.lproj/Localizable.strings | 15 ++ 24 files changed, 992 insertions(+), 341 deletions(-) create mode 100644 Shared/Objects/TrailerSelection.swift create mode 100644 Swiftfin tvOS/Views/ItemView/Components/ActionButton.swift rename Swiftfin tvOS/Views/ItemView/Components/{ActionButtons => ActionButtonHStack}/ActionButtonHStack.swift (70%) rename Swiftfin tvOS/Views/ItemView/Components/{ActionButtons => ActionButtonHStack/Components}/RefreshMetadataButton.swift (98%) create mode 100644 Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift delete mode 100644 Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift delete mode 100644 Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift delete mode 100644 Swiftfin tvOS/Views/ItemView/Components/ActionButtons/VersionMenu.swift create mode 100644 Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift rename Swiftfin tvOS/Views/ItemView/Components/{ => PlayButton}/PlayButton.swift (77%) create mode 100644 Swiftfin/Views/ItemView/Components/ActionButton/ActionButton.swift delete mode 100644 Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift create mode 100644 Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift create mode 100644 Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift create mode 100644 Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift diff --git a/Shared/Objects/TrailerSelection.swift b/Shared/Objects/TrailerSelection.swift new file mode 100644 index 00000000..54469cd1 --- /dev/null +++ b/Shared/Objects/TrailerSelection.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct TrailerSelection: OptionSet, CaseIterable, Displayable, Hashable, Storable { + + let rawValue: Int + + static let local = TrailerSelection(rawValue: 1 << 0) + static let external = TrailerSelection(rawValue: 1 << 1) + static let none = TrailerSelection(rawValue: 1 << 2) + static let all: TrailerSelection = [.local, .external] + + static let allCases: [TrailerSelection] = [.none, .local, .external, .all] + + var displayTitle: String { + switch self { + case .all: + return L10n.all + case .local: + return L10n.local + case .external: + return L10n.external + case .none: + return L10n.none + default: + return L10n.unknown + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index aac334e9..43644754 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -538,6 +538,8 @@ internal enum L10n { internal static let enableAllLibraries = L10n.tr("Localizable", "enableAllLibraries", fallback: "Enable all libraries") /// Enabled internal static let enabled = L10n.tr("Localizable", "enabled", fallback: "Enabled") + /// Enabled trailers + internal static let enabledTrailers = L10n.tr("Localizable", "enabledTrailers", fallback: "Enabled trailers") /// End Date internal static let endDate = L10n.tr("Localizable", "endDate", fallback: "End Date") /// Ended @@ -594,6 +596,8 @@ internal enum L10n { internal static let existsOnServer = L10n.tr("Localizable", "existsOnServer", fallback: "This item exists on your Jellyfin Server.") /// Experimental internal static let experimental = L10n.tr("Localizable", "experimental", fallback: "Experimental") + /// External + internal static let external = L10n.tr("Localizable", "external", fallback: "External") /// Failed logins internal static let failedLogins = L10n.tr("Localizable", "failedLogins", fallback: "Failed logins") /// Favorited @@ -748,6 +752,8 @@ internal enum L10n { internal static let liveTvRecordingManagement = L10n.tr("Localizable", "liveTvRecordingManagement", fallback: "Live TV recording management") /// Loading user failed internal static let loadingUserFailed = L10n.tr("Localizable", "loadingUserFailed", fallback: "Loading user failed") + /// Local + internal static let local = L10n.tr("Localizable", "local", fallback: "Local") /// Local Servers internal static let localServers = L10n.tr("Localizable", "localServers", fallback: "Local Servers") /// Lock All Fields @@ -1338,6 +1344,8 @@ internal enum L10n { internal static let timestampType = L10n.tr("Localizable", "timestampType", fallback: "Timestamp Type") /// Title internal static let title = L10n.tr("Localizable", "title", fallback: "Title") + /// Trailer + internal static let trailer = L10n.tr("Localizable", "trailer", fallback: "Trailer") /// Trailers internal static let trailers = L10n.tr("Localizable", "trailers", fallback: "Trailers") /// Trailing Value @@ -1364,6 +1372,8 @@ internal enum L10n { internal static let type = L10n.tr("Localizable", "type", fallback: "Type") /// Unable to find host internal static let unableToFindHost = L10n.tr("Localizable", "unableToFindHost", fallback: "Unable to find host") + /// Unable to open trailer + internal static let unableToOpenTrailer = L10n.tr("Localizable", "unableToOpenTrailer", fallback: "Unable to open trailer") /// Unable to perform device authentication internal static let unableToPerformDeviceAuth = L10n.tr("Localizable", "unableToPerformDeviceAuth", fallback: "Unable to perform device authentication") /// Unable to perform device authentication. You may need to enable Face ID in the Settings app for Swiftfin. diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 7a0d2e70..b1b849b5 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -173,6 +173,14 @@ extension StoredValues.Keys { ) } + static var enabledTrailers: Key { + CurrentUserKey( + "enabledTrailers", + domain: "enabledTrailers", + default: .all + ) + } + static var itemViewAttributes: Key<[ItemViewAttribute]> { CurrentUserKey( "itemViewAttributes", diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index c29abb51..ad5b67bb 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -72,6 +72,8 @@ class ItemViewModel: ViewModel, Stateful { private(set) var similarItems: [BaseItemDto] = [] @Published private(set) var specialFeatures: [BaseItemDto] = [] + @Published + private(set) var localTrailers: [BaseItemDto] = [] @Published var backgroundStates: Set = [] @@ -127,11 +129,13 @@ class ItemViewModel: ViewModel, Stateful { async let fullItem = getFullItem() async let similarItems = getSimilarItems() async let specialFeatures = getSpecialFeatures() + async let localTrailers = getLocalTrailers() let results = try await ( fullItem: fullItem, similarItems: similarItems, - specialFeatures: specialFeatures + specialFeatures: specialFeatures, + localTrailers: localTrailers ) guard !Task.isCancelled else { return } @@ -150,6 +154,7 @@ class ItemViewModel: ViewModel, Stateful { self.similarItems = results.similarItems self.specialFeatures = results.specialFeatures + self.localTrailers = results.localTrailers Notifications[.itemMetadataDidChange].post(results.fullItem) } @@ -177,11 +182,13 @@ class ItemViewModel: ViewModel, Stateful { async let fullItem = getFullItem() async let similarItems = getSimilarItems() async let specialFeatures = getSpecialFeatures() + async let localTrailers = getLocalTrailers() let results = try await ( fullItem: fullItem, similarItems: similarItems, - specialFeatures: specialFeatures + specialFeatures: specialFeatures, + localTrailers: localTrailers ) guard !Task.isCancelled else { return } @@ -194,6 +201,7 @@ class ItemViewModel: ViewModel, Stateful { self.item = results.fullItem self.similarItems = results.similarItems self.specialFeatures = results.specialFeatures + self.localTrailers = results.localTrailers self.state = .content } @@ -326,6 +334,16 @@ class ItemViewModel: ViewModel, Stateful { .filter { $0.extraType?.isVideo ?? false } } + private func getLocalTrailers() async throws -> [BaseItemDto] { + + guard let itemID = item.id else { return [] } + + let request = Paths.getLocalTrailers(userID: userSession.user.id, itemID: itemID) + let response = try? await userSession.client.send(request) + + return response?.value ?? [] + } + private func setIsPlayed(_ isPlayed: Bool) async throws { guard let itemID = item.id else { return } diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButton.swift new file mode 100644 index 00000000..544bdbe5 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButton.swift @@ -0,0 +1,126 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct ActionButton: View { + + // MARK: - Environment Objects + + @Environment(\.isSelected) + private var isSelected + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + private let content: () -> Content + private let icon: String + private let isCompact: Bool + private let selectedIcon: String? + private let title: String + private let onSelect: () -> Void + + private var labelIconName: String { + isSelected ? selectedIcon ?? icon : icon + } + + // MARK: - Body + + var body: some View { + Group { + if Content.self == EmptyView.self { + Button(action: onSelect) { + labelView + } + .buttonStyle(.card) + } else { + Menu(content: content) { + labelView + } + .scaleEffect(isFocused ? 1.2 : 1.0) + .animation( + .spring(response: 0.2, dampingFraction: 1), value: isFocused + ) + .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .focused($isFocused) + } + } + .focused($isFocused) + } + + // MARK: - Label Views + + private var labelView: some View { + ZStack { + let isButton = Content.self == EmptyView.self + + if isButton, isSelected { + RoundedRectangle(cornerRadius: 10) + .fill( + isFocused ? AnyShapeStyle(HierarchicalShapeStyle.primary) : + AnyShapeStyle(HierarchicalShapeStyle.primary.opacity(0.5)) + ) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(isFocused ? Color.white : Color.white.opacity(0.5)) + } + + Label(title, systemImage: labelIconName) + .focusEffectDisabled() + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.black) + .labelStyle(.iconOnly) + .rotationEffect(isCompact ? .degrees(90) : .degrees(0)) + } + .accessibilityLabel(title) + } + } +} + +// MARK: - Initializers + +extension ItemView.ActionButton { + + // MARK: Button Initializer + + init( + _ title: String, + icon: String, + selectedIcon: String, + onSelect: @escaping () -> Void + ) where Content == EmptyView { + self.title = title + self.icon = icon + self.isCompact = false + self.selectedIcon = selectedIcon + self.onSelect = onSelect + self.content = { EmptyView() } + } + + // MARK: Menu Initializer + + init( + _ title: String, + icon: String, + isCompact: Bool = false, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.icon = icon + self.isCompact = isCompact + self.selectedIcon = nil + self.onSelect = {} + self.content = content + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift similarity index 70% rename from Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift rename to Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift index 89e0ee53..9ec50ddd 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -12,6 +12,15 @@ extension ItemView { struct ActionButtonHStack: View { + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion: Bool + @StoredValue(.User.enableItemEditing) + private var enableItemEditing: Bool + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement: Bool + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + // MARK: - Observed, State, & Environment Objects @EnvironmentObject @@ -23,15 +32,6 @@ extension ItemView { @StateObject private var deleteViewModel: DeleteItemViewModel - // MARK: - Defaults - - @StoredValue(.User.enableItemDeletion) - private var enableItemDeletion: Bool - @StoredValue(.User.enableItemEditing) - private var enableItemEditing: Bool - @StoredValue(.User.enableCollectionManagement) - private var enableCollectionManagement: Bool - // MARK: - Dialog States @State @@ -64,6 +64,20 @@ extension ItemView { } } + // MARK: - Has Trailers + + private var hasTrailers: Bool { + if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { + return true + } + + if enabledTrailers.contains(.external), viewModel.item.remoteTrailers?.isNotEmpty == true { + return true + } + + return false + } + // MARK: - Initializer init(viewModel: ItemViewModel) { @@ -74,60 +88,67 @@ extension ItemView { // MARK: - Body var body: some View { - HStack(alignment: .center, spacing: 24) { + HStack(alignment: .center, spacing: 20) { - // MARK: - Toggle Played + // MARK: Toggle Played + + let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true ActionButton( - title: L10n.played, + L10n.played, icon: "checkmark.circle", selectedIcon: "checkmark.circle.fill" ) { viewModel.send(.toggleIsPlayed) } .foregroundStyle(.purple) - .environment(\.isSelected, viewModel.item.userData?.isPlayed ?? false) - .frame(minWidth: 80, maxWidth: .infinity) + .environment(\.isSelected, isCheckmarkSelected) + .frame(minWidth: 100, maxWidth: .infinity) - // MARK: - Toggle Favorite + // MARK: Toggle Favorite + + let isHeartSelected = viewModel.item.userData?.isFavorite == true ActionButton( - title: L10n.favorited, + L10n.favorited, icon: "heart.circle", selectedIcon: "heart.circle.fill" ) { viewModel.send(.toggleIsFavorite) } .foregroundStyle(.pink) - .environment(\.isSelected, viewModel.item.userData?.isFavorite ?? false) - .frame(minWidth: 80, maxWidth: .infinity) + .environment(\.isSelected, isHeartSelected) + .frame(minWidth: 100, maxWidth: .infinity) - // MARK: - Select Merged Version + // MARK: Watch a Trailer - if let mediaSources = viewModel.playButtonItem?.mediaSources, mediaSources.count > 1 { - VersionMenu(viewModel: viewModel, mediaSources: mediaSources) - .frame(minWidth: 80, maxWidth: .infinity) + if hasTrailers { + TrailerMenu( + localTrailers: viewModel.localTrailers, + externalTrailers: viewModel.item.remoteTrailers ?? [] + ) } - // MARK: - Additional Menu Options + // MARK: Advanced Options if canRefresh || canDelete { - ActionMenu { + ActionButton(L10n.advanced, icon: "ellipsis", isCompact: true) { if canRefresh { RefreshMetadataButton(item: viewModel.item) } if canDelete { - Divider() Button(L10n.delete, systemImage: "trash", role: .destructive) { showConfirmationDialog = true } } } - .frame(minWidth: 30, maxWidth: 50) + .frame(width: 60) } } .frame(height: 100) + .padding(.top, 1) + .padding(.bottom, 10) .confirmationDialog( L10n.deleteItemConfirmationMessage, isPresented: $showConfirmationDialog, diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift similarity index 98% rename from Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift rename to Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift index a8328b88..fec88a96 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift @@ -33,7 +33,7 @@ extension ItemView { var body: some View { Menu { - Group { + Section(L10n.metadata) { Button(L10n.findMissing, systemImage: "magnifyingglass") { viewModel.send( .refreshMetadata( diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift new file mode 100644 index 00000000..e96e3fcf --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift @@ -0,0 +1,150 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Factory +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct TrailerMenu: View { + + @Injected(\.logService) + private var logger + + // MARK: - Stored Value + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Observed & Envirnoment Objects + + @EnvironmentObject + private var router: ItemCoordinator.Router + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Notification State + + @State + private var selectedRemoteURL: MediaURL? + + let localTrailers: [BaseItemDto] + let externalTrailers: [MediaURL] + + private var showLocalTrailers: Bool { + enabledTrailers.contains(.local) && localTrailers.isNotEmpty + } + + private var showExternalTrailers: Bool { + enabledTrailers.contains(.external) && externalTrailers.isNotEmpty + } + + // MARK: - Body + + var body: some View { + Group { + switch localTrailers.count + externalTrailers.count { + case 1: + trailerButton + default: + trailerMenu + } + } + .errorMessage($error) + } + + // MARK: - Single Trailer Button + + private var trailerButton: some View { + ActionButton( + L10n.trailers, + icon: "movieclapper", + selectedIcon: "movieclapper" + ) { + if showLocalTrailers, let firstTrailer = localTrailers.first { + playLocalTrailer(firstTrailer) + } + + if showExternalTrailers, let firstTrailer = externalTrailers.first { + playExternalTrailer(firstTrailer) + } + } + } + + // MARK: - Multiple Trailers Menu Button + + @ViewBuilder + private var trailerMenu: some View { + ActionButton(L10n.trailers, icon: "movieclapper") { + + if showLocalTrailers { + Section(L10n.local) { + ForEach(localTrailers) { trailer in + Button( + trailer.name ?? L10n.trailer, + systemImage: "play.fill" + ) { + playLocalTrailer(trailer) + } + } + } + } + + if showExternalTrailers { + Section(L10n.external) { + ForEach(externalTrailers, id: \.self) { mediaURL in + Button( + mediaURL.name ?? L10n.trailer, + systemImage: "arrow.up.forward" + ) { + playExternalTrailer(mediaURL) + } + } + } + } + } + } + + // MARK: - Play: Local Trailer + + private func playLocalTrailer(_ trailer: BaseItemDto) { + if let selectedMediaSource = trailer.mediaSources?.first { + router.route( + to: \.videoPlayer, + OnlineVideoPlayerManager(item: trailer, mediaSource: selectedMediaSource) + ) + } else { + logger.log(level: .error, "No media sources found") + error = JellyfinAPIError(L10n.unknownError) + } + } + + // MARK: - Play: External Trailer + + private func playExternalTrailer(_ trailer: MediaURL) { + if let url = URL(string: trailer.url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) { success in + guard !success else { return } + + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } else { + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift deleted file mode 100644 index b03acaec..00000000 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension ItemView { - - struct ActionButton: View { - - // MARK: - Environment Objects - - @Environment(\.isSelected) - private var isSelected - - // MARK: - Focus State - - @FocusState - private var isFocused: Bool - - // MARK: - Item Variables - - let title: String - let icon: String - let selectedIcon: String - - // MARK: - Item Actions - - let onSelect: () -> Void - - // MARK: - Body - - var body: some View { - Button(action: onSelect) { - ZStack { - if isSelected { - Rectangle() - .fill( - isFocused ? AnyShapeStyle(HierarchicalShapeStyle.primary) : - AnyShapeStyle(HierarchicalShapeStyle.primary.opacity(0.5)) - ) - } else { - Rectangle() - .fill(isFocused ? Color.white : Color.white.opacity(0.5)) - } - - Label(title, systemImage: isSelected ? selectedIcon : icon) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(.black) - .labelStyle(.iconOnly) - } - } - .padding(0) - .focused($isFocused) - .buttonStyle(.card) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift deleted file mode 100644 index 1d939f63..00000000 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension ItemView { - - struct ActionMenu: View { - - // MARK: - Focus State - - @FocusState - private var isFocused: Bool - - // MARK: - Menu Items - - @ViewBuilder - let menuItems: Content - - // MARK: - Body - - var body: some View { - Menu { - menuItems - } label: { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(isFocused ? Color.white : Color.white.opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.clear, lineWidth: 2) - ) - - Label(L10n.menuButtons, systemImage: "ellipsis") - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(.black) - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .focused($isFocused) - .scaleEffect(isFocused ? 1.20 : 1.0) - .animation(.easeInOut(duration: 0.15), value: isFocused) - .menuStyle(.borderlessButton) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/VersionMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/VersionMenu.swift deleted file mode 100644 index 2ca3bd58..00000000 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/VersionMenu.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct VersionMenu: View { - - // MARK: - Focus State - - @FocusState - private var isFocused: Bool - - @ObservedObject - var viewModel: ItemViewModel - - let mediaSources: [MediaSourceInfo] - - // MARK: - Body - - var body: some View { - Menu { - ForEach(mediaSources, id: \.hashValue) { mediaSource in - Button { - viewModel.send(.selectMediaSource(mediaSource)) - } label: { - if let selectedMediaSource = viewModel.selectedMediaSource, selectedMediaSource == mediaSource { - Label(selectedMediaSource.displayTitle, systemImage: "checkmark") - } else { - Text(mediaSource.displayTitle) - } - } - } - } label: { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(isFocused ? Color.white : Color.white.opacity(0.5)) - - Label(L10n.version, systemImage: "list.dash") - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(.black) - .labelStyle(.iconOnly) - } - } - .focused($isFocused) - .scaleEffect(isFocused ? 1.20 : 1.0) - .animation(.easeInOut(duration: 0.15), value: isFocused) - .menuStyle(.borderlessButton) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift new file mode 100644 index 00000000..f7e956b1 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct VersionMenu: View { + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + @ObservedObject + var viewModel: ItemViewModel + + let mediaSources: [MediaSourceInfo] + + // MARK: - Selected Media Source Binding + + private var selectedMediaSource: Binding { + Binding( + get: { viewModel.selectedMediaSource }, + set: { newSource in + if let newSource { + viewModel.send(.selectMediaSource(newSource)) + } + } + ) + } + + // MARK: - Body + + var body: some View { + ActionButton(L10n.version, icon: "list.dash") { + Picker(L10n.version, selection: selectedMediaSource) { + ForEach(mediaSources, id: \.hashValue) { mediaSource in + Text(mediaSource.displayTitle) + .tag(mediaSource as MediaSourceInfo?) + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift similarity index 77% rename from Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift rename to Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift index c71d0538..fb92d154 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift @@ -7,6 +7,7 @@ // import Factory +import JellyfinAPI import SwiftUI extension ItemView { @@ -25,6 +26,20 @@ extension ItemView { @FocusState private var isFocused: Bool + // MARK: - Media Sources + + private var mediaSources: [MediaSourceInfo] { + viewModel.playButtonItem?.mediaSources ?? [] + } + + // MARK: - Multiple Media Sources + + private var multipleVersions: Bool { + mediaSources.count > 1 + } + + // MARK: - Title + private var title: String { if let seriesViewModel = viewModel as? SeriesItemViewModel { return seriesViewModel.playButtonItem?.seasonEpisodeLabel ?? L10n.play @@ -33,7 +48,22 @@ extension ItemView { } } + // MARK: - Body + var body: some View { + HStack(spacing: 20) { + playButton + + if multipleVersions { + VersionMenu(viewModel: viewModel, mediaSources: mediaSources) + .frame(width: 100, height: 100) + } + } + } + + // MARK: - Play Button + + private var playButton: some View { Button { if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource { router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: playButtonItem, mediaSource: selectedMediaSource)) @@ -47,10 +77,11 @@ extension ItemView { .font(.title3) Text(title) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .foregroundStyle(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) .fontWeight(.semibold) } - .frame(width: 400, height: 100) + .padding(20) + .frame(width: multipleVersions ? 320 : 440, height: 100, alignment: .center) .background { if isFocused { viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white diff --git a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift index 73725ec0..d16bdd5d 100644 --- a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift @@ -128,7 +128,7 @@ extension EpisodeItemView.ContentView { .focused($focusedLayer, equals: .playButton) ItemView.ActionButtonHStack(viewModel: viewModel) - .frame(width: 400) + .frame(width: 440) } .frame(width: 450) .padding(.leading, 150) diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift index 33384541..914b4291 100644 --- a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -123,7 +123,7 @@ extension ItemView { .focused($focusedLayer, equals: .playButton) ItemView.ActionButtonHStack(viewModel: viewModel) - .frame(width: 400) + .frame(width: 440) } .frame(width: 450) .padding(.leading, 150) diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift index 861d8f00..fd71a756 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -22,6 +22,8 @@ extension CustomizeViewsSettings { @StoredValue(.User.itemViewAttributes) private var itemViewAttributes + @StoredValue(.User.enabledTrailers) + private var enabledTrailers @StoredValue(.User.enableItemEditing) private var enableItemEditing @@ -38,6 +40,8 @@ extension CustomizeViewsSettings { router.route(to: \.itemViewAttributes, $itemViewAttributes) } + ListRowMenu(L10n.enabledTrailers, selection: $enabledTrailers) + /// Enable Refreshing Items from All Visible LIbraries if userSession?.user.permissions.items.canEditMetadata ?? false { Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index fa30cca7..3243e41f 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 4E5508732D13AFED002A5345 /* UserProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */; }; 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; + 4E5D3EC82D8920AF003E2772 /* TrailerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5D3EC72D8920AF003E2772 /* TrailerMenu.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; 4E5EE5512D67CE9500982290 /* ImageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EE5502D67CE9000982290 /* ImageCard.swift */; }; 4E5EE5532D67CFAB00982290 /* ImageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EE5522D67CFAB00982290 /* ImageCard.swift */; }; @@ -193,6 +194,11 @@ 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; }; 4EB1A8CE2C9B2D0800F43898 /* ActiveSessionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */; }; + 4EB3F02B2D8C804200EBEDAA /* TrailerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB3F02A2D8C803F00EBEDAA /* TrailerSelection.swift */; }; + 4EB3F02C2D8C804200EBEDAA /* TrailerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB3F02A2D8C803F00EBEDAA /* TrailerSelection.swift */; }; + 4EB3F0372D8CD33300EBEDAA /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB3F0362D8CD33100EBEDAA /* ActionButton.swift */; }; + 4EB3F0392D8CD5CF00EBEDAA /* TrailerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB3F0382D8CD5CC00EBEDAA /* TrailerMenu.swift */; }; + 4EB3F03B2D8CD6A900EBEDAA /* VersionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB3F03A2D8CD6A700EBEDAA /* VersionMenu.swift */; }; 4EB4ECE32CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; }; 4EB4ECE42CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; }; 4EB538B52CE3C77200EB72D5 /* ServerUserPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB538B42CE3C76D00EB72D5 /* ServerUserPermissionsView.swift */; }; @@ -256,7 +262,6 @@ 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; }; 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; }; 4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */; }; - 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; }; 4EFAC12C2D1E255900E40880 /* EditServerUserAccessTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */; }; 4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */; }; 4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */; }; @@ -1347,6 +1352,7 @@ 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = ""; }; 4E5508722D13AFE3002A5345 /* UserProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImage.swift; sourceTree = ""; }; 4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = ""; }; + 4E5D3EC72D8920AF003E2772 /* TrailerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailerMenu.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E5EE5502D67CE9000982290 /* ImageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCard.swift; sourceTree = ""; }; 4E5EE5522D67CFAB00982290 /* ImageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCard.swift; sourceTree = ""; }; @@ -1420,6 +1426,10 @@ 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = ""; }; 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = ""; }; + 4EB3F02A2D8C803F00EBEDAA /* TrailerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailerSelection.swift; sourceTree = ""; }; + 4EB3F0362D8CD33100EBEDAA /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; + 4EB3F0382D8CD5CC00EBEDAA /* TrailerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailerMenu.swift; sourceTree = ""; }; + 4EB3F03A2D8CD6A700EBEDAA /* VersionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionMenu.swift; sourceTree = ""; }; 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; 4EB538B42CE3C76D00EB72D5 /* ServerUserPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserPermissionsView.swift; sourceTree = ""; }; 4EB538BC2CE3CCCF00EB72D5 /* MediaPlaybackSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackSection.swift; sourceTree = ""; }; @@ -1475,7 +1485,6 @@ 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = ""; }; 4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = ""; }; - 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = ""; }; 4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerUserAccessTagsView.swift; sourceTree = ""; }; 4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessTagRow.swift; sourceTree = ""; }; 4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserAccessTagsView.swift; sourceTree = ""; }; @@ -2497,16 +2506,13 @@ path = AddItemElementView; sourceTree = ""; }; - 4E5334A02CD1A27C00D59FA8 /* ActionButtons */ = { + 4E5334A02CD1A27C00D59FA8 /* ActionButtonHStack */ = { isa = PBXGroup; children = ( - 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */, E1C926032887565C002A7A66 /* ActionButtonHStack.swift */, - 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */, - 4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */, - 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */, + 4E5D3ECB2D893F0B003E2772 /* Components */, ); - path = ActionButtons; + path = ActionButtonHStack; sourceTree = ""; }; 4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */ = { @@ -2525,6 +2531,15 @@ path = ServerUserLiveTVAccessView; sourceTree = ""; }; + 4E5D3ECB2D893F0B003E2772 /* Components */ = { + isa = PBXGroup; + children = ( + 4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */, + 4E5D3EC72D8920AF003E2772 /* TrailerMenu.swift */, + ); + path = Components; + sourceTree = ""; + }; 4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */ = { isa = PBXGroup; children = ( @@ -2851,6 +2866,40 @@ path = Components; sourceTree = ""; }; + 4EB3F0302D8CCD3500EBEDAA /* ActionButtonHStack */ = { + isa = PBXGroup; + children = ( + E18E01D9288747230022598C /* ActionButtonHStack.swift */, + 4EB3F0312D8CD1EF00EBEDAA /* Components */, + ); + path = ActionButtonHStack; + sourceTree = ""; + }; + 4EB3F0312D8CD1EF00EBEDAA /* Components */ = { + isa = PBXGroup; + children = ( + 4EB3F0382D8CD5CC00EBEDAA /* TrailerMenu.swift */, + 4EB3F03A2D8CD6A700EBEDAA /* VersionMenu.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EB3F0342D8CD2B500EBEDAA /* Components */ = { + isa = PBXGroup; + children = ( + 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EB3F0352D8CD32900EBEDAA /* ActionButton */ = { + isa = PBXGroup; + children = ( + 4EB3F0362D8CD33100EBEDAA /* ActionButton.swift */, + ); + path = ActionButton; + sourceTree = ""; + }; 4EB538B32CE3C75900EB72D5 /* ServerUserPermissionsView */ = { isa = PBXGroup; children = ( @@ -3116,6 +3165,15 @@ path = Components; sourceTree = ""; }; + 4EFACFC22D8BCA4C00D09281 /* PlayButton */ = { + isa = PBXGroup; + children = ( + E1C926022887565C002A7A66 /* PlayButton.swift */, + 4EB3F0342D8CD2B500EBEDAA /* Components */, + ); + path = PlayButton; + sourceTree = ""; + }; 4EFE80842D3EF80E0029CCB6 /* ActiveSessions */ = { isa = PBXGroup; children = ( @@ -3332,8 +3390,8 @@ E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */, 4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */, E14EDECA2B8FB66F000F00A4 /* ItemFilter */, - E1C925F328875037002A7A66 /* ItemViewType.swift */, 4E1A39322D56C83E00BAC1C7 /* ItemViewAttributes.swift */, + E1C925F328875037002A7A66 /* ItemViewType.swift */, E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */, E1DE2B4E2B983F3200F6715F /* LibraryParent */, 4E2AC4C02C6C48EB00DD600D /* MediaComponents */, @@ -3360,6 +3418,7 @@ E1A1528428FD191A00600579 /* TextPair.swift */, E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, + 4EB3F02A2D8C803F00EBEDAA /* TrailerSelection.swift */, E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */, 4E01446B2D0292E000193038 /* Trie.swift */, E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */, @@ -4921,7 +4980,8 @@ isa = PBXGroup; children = ( E18ACA902A15A2D600BB4F35 /* AboutView */, - E18E01D9288747230022598C /* ActionButtonHStack.swift */, + 4EB3F0352D8CD32900EBEDAA /* ActionButton */, + 4EB3F0302D8CCD3500EBEDAA /* ActionButtonHStack */, E18E01D7288747230022598C /* AttributeHStack.swift */, E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */, E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */, @@ -5174,11 +5234,12 @@ isa = PBXGroup; children = ( E1A16CA2288A7D0000EA4679 /* AboutView */, - 4E5334A02CD1A27C00D59FA8 /* ActionButtons */, + 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */, + 4E5334A02CD1A27C00D59FA8 /* ActionButtonHStack */, E1C926012887565C002A7A66 /* AttributeHStack.swift */, E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */, E1153D982BBA3E6100424D36 /* EpisodeSelector */, - E1C926022887565C002A7A66 /* PlayButton.swift */, + 4EFACFC22D8BCA4C00D09281 /* PlayButton */, E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */, E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */, ); @@ -5927,7 +5988,6 @@ E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */, 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */, - 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, 4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */, 4E97D1832D064748004B89AD /* ItemSection.swift in Sources */, @@ -6062,6 +6122,7 @@ E10432F72BE4426F006FF9DD /* FormatStyle.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */, + 4EB3F02B2D8C804200EBEDAA /* TrailerSelection.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* Notifications.swift in Sources */, E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */, @@ -6126,6 +6187,7 @@ 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, E146A9DC2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */, + 4E5D3EC82D8920AF003E2772 /* TrailerMenu.swift in Sources */, E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, @@ -6551,6 +6613,7 @@ 6264E88C273850380081A12A /* Strings.swift in Sources */, E145EB252BE055AD003BF6F3 /* ServerResponse.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, + 4EB3F0372D8CD33300EBEDAA /* ActionButton.swift in Sources */, E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, E10E67B72CF515130095365B /* Binding.swift in Sources */, @@ -6566,6 +6629,7 @@ E1ED7FE02CAA685900ACB6E3 /* ServerLogsView.swift in Sources */, 4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */, E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */, + 4EB3F03B2D8CD6A900EBEDAA /* VersionMenu.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, @@ -6575,6 +6639,7 @@ E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */, + 4EB3F02C2D8C804200EBEDAA /* TrailerSelection.swift in Sources */, 4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */, 4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */, 4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */, @@ -6714,6 +6779,7 @@ 4E35CE5F2CBED3F300DBD886 /* IntervalRow.swift in Sources */, 4E35CE602CBED3F300DBD886 /* DayOfWeekRow.swift in Sources */, 4E35CE612CBED3F300DBD886 /* TimeLimitSection.swift in Sources */, + 4EB3F0392D8CD5CF00EBEDAA /* TrailerMenu.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, 4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, diff --git a/Swiftfin/Views/ItemView/Components/ActionButton/ActionButton.swift b/Swiftfin/Views/ItemView/Components/ActionButton/ActionButton.swift new file mode 100644 index 00000000..2aabe863 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButton/ActionButton.swift @@ -0,0 +1,86 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct ActionButton: View { + + @Environment(\.isSelected) + private var isSelected + + private let content: () -> Content + private let icon: String + private let onSelect: () -> Void + private let selectedIcon: String? + private let title: String + + private var labelIconName: String { + isSelected ? selectedIcon ?? icon : icon + } + + // MARK: - Body + + var body: some View { + Group { + if Content.self == EmptyView.self { + Button( + title, + systemImage: labelIconName, + action: onSelect + ) + .buttonStyle(.plain) + } else { + Menu( + title, + systemImage: labelIconName, + content: content + ) + } + } + .symbolRenderingMode(.palette) + .labelStyle(.iconOnly) + .animation(.easeInOut(duration: 0.1), value: isSelected) + } + } +} + +// MARK: - Initializers + +extension ItemView.ActionButton { + + // MARK: Button Initializer + + init( + _ title: String, + icon: String, + selectedIcon: String? = nil, + onSelect: @escaping () -> Void + ) where Content == EmptyView { + self.title = title + self.icon = icon + self.selectedIcon = selectedIcon + self.onSelect = onSelect + self.content = { EmptyView() } + } + + // MARK: Menu Initializer + + init( + _ title: String, + icon: String, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.icon = icon + self.selectedIcon = icon + self.onSelect = {} + self.content = content + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift deleted file mode 100644 index 51e30cc0..00000000 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct ActionButtonHStack: View { - - @Default(.accentColor) - private var accentColor - - @Injected(\.downloadManager) - private var downloadManager: DownloadManager - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - private var viewModel: ItemViewModel - - private let equalSpacing: Bool - - init(viewModel: ItemViewModel, equalSpacing: Bool = true) { - self.viewModel = viewModel - self.equalSpacing = equalSpacing - } - - var body: some View { - HStack(alignment: .center, spacing: 15) { - Button { - UIDevice.impact(.light) - viewModel.send(.toggleIsPlayed) - } label: { - if viewModel.item.userData?.isPlayed ?? false { - Image(systemName: "checkmark.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle( - .primary, - accentColor - ) - } else { - Image(systemName: "checkmark.circle") - } - } - .buttonStyle(.plain) - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - - Button { - UIDevice.impact(.light) - viewModel.send(.toggleIsFavorite) - } label: { - if viewModel.item.userData?.isFavorite ?? false { - Image(systemName: "heart.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(Color.red) - } else { - Image(systemName: "heart") - } - } - .buttonStyle(.plain) - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - - if let playButtonItem = viewModel.playButtonItem, - let mediaSources = playButtonItem.mediaSources, - mediaSources.count > 1 - { - Menu { - ForEach(mediaSources, id: \.hashValue) { mediaSource in - Button { - viewModel.send(.selectMediaSource(mediaSource)) - } label: { - if let selectedMediaSource = viewModel.selectedMediaSource, selectedMediaSource == mediaSource { - Label(selectedMediaSource.displayTitle, systemImage: "checkmark") - } else { - Text(mediaSource.displayTitle) - } - } - } - } label: { - Image(systemName: "list.dash") - } - .buttonStyle(.plain) - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - } - - if viewModel.item.type == .movie || - viewModel.item.type == .episode, - Defaults[.Experimental.downloads] - { - DownloadTaskButton(item: viewModel.item) - .onSelect { task in - router.route(to: \.downloadTask, task) - } - .buttonStyle(.plain) - .frame(width: 25, height: 25) - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift new file mode 100644 index 00000000..9663d3df --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -0,0 +1,127 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import JellyfinAPI +import SwiftUI + +// TODO: replace `equalSpacing` handling with a `Layout` + +extension ItemView { + + struct ActionButtonHStack: View { + + @Default(.accentColor) + private var accentColor + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + + @ObservedObject + private var viewModel: ItemViewModel + + private let equalSpacing: Bool + + // MARK: - Has Trailers + + private var hasTrailers: Bool { + if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { + return true + } + + if enabledTrailers.contains(.external), viewModel.item.remoteTrailers?.isNotEmpty == true { + return true + } + + return false + } + + // MARK: - Initializer + + init(viewModel: ItemViewModel, equalSpacing: Bool = true) { + self.viewModel = viewModel + self.equalSpacing = equalSpacing + } + + // MARK: - Body + + var body: some View { + HStack(alignment: .center, spacing: 15) { + + // MARK: Toggle Played + + let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true + + ActionButton( + L10n.played, + icon: "checkmark.circle", + selectedIcon: "checkmark.circle.fill" + ) { + UIDevice.impact(.light) + viewModel.send(.toggleIsPlayed) + } + .environment(\.isSelected, isCheckmarkSelected) + .if(isCheckmarkSelected) { item in + item + .foregroundStyle( + .primary, + accentColor + ) + } + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + + // MARK: Toggle Favorite + + let isHeartSelected = viewModel.item.userData?.isFavorite == true + + ActionButton( + L10n.favorited, + icon: "heart", + selectedIcon: "heart.fill" + ) { + UIDevice.impact(.light) + viewModel.send(.toggleIsFavorite) + } + .environment(\.isSelected, isHeartSelected) + .if(isHeartSelected) { item in + item + .foregroundStyle(Color.red) + } + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + + // MARK: Select a Version + + if let mediaSources = viewModel.playButtonItem?.mediaSources, + mediaSources.count > 1 + { + VersionMenu(viewModel: viewModel, mediaSources: mediaSources) + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + } + + // MARK: Watch a Trailer + + if hasTrailers { + TrailerMenu( + localTrailers: viewModel.localTrailers, + externalTrailers: viewModel.item.remoteTrailers ?? [] + ) + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift new file mode 100644 index 00000000..386ea8bd --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift @@ -0,0 +1,140 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Factory +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct TrailerMenu: View { + + @Injected(\.logService) + private var logger + + // MARK: - Stored Value + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + + // MARK: - Observed & Envirnoment Objects + + @EnvironmentObject + private var router: MainCoordinator.Router + + // MARK: - Error State + + @State + private var error: Error? + + let localTrailers: [BaseItemDto] + let externalTrailers: [MediaURL] + + private var showLocalTrailers: Bool { + enabledTrailers.contains(.local) && localTrailers.isNotEmpty + } + + private var showExternalTrailers: Bool { + enabledTrailers.contains(.external) && externalTrailers.isNotEmpty + } + + // MARK: - Body + + var body: some View { + Group { + switch localTrailers.count + externalTrailers.count { + case 1: + trailerButton + default: + trailerMenu + } + } + .errorMessage($error) + } + + // MARK: - Single Trailer Button + + @ViewBuilder + private var trailerButton: some View { + ActionButton( + L10n.trailers, + icon: "movieclapper" + ) { + if showLocalTrailers, let firstTrailer = localTrailers.first { + playLocalTrailer(firstTrailer) + } + + if showExternalTrailers, let firstTrailer = externalTrailers.first { + playExternalTrailer(firstTrailer) + } + } + } + + // MARK: - Multiple Trailers Menu Button + + @ViewBuilder + private var trailerMenu: some View { + ActionButton(L10n.trailers, icon: "movieclapper") { + + if showLocalTrailers { + Section(L10n.local) { + ForEach(localTrailers) { trailer in + Button( + trailer.name ?? L10n.trailer, + systemImage: "play.fill" + ) { + playLocalTrailer(trailer) + } + } + } + } + + if showExternalTrailers { + Section(L10n.external) { + ForEach(externalTrailers, id: \.self) { mediaURL in + Button( + mediaURL.name ?? L10n.trailer, + systemImage: "arrow.up.forward" + ) { + playExternalTrailer(mediaURL) + } + } + } + } + } + } + + // MARK: - Play: Local Trailer + + private func playLocalTrailer(_ trailer: BaseItemDto) { + if let selectedMediaSource = trailer.mediaSources?.first { + router.route( + to: \.videoPlayer, + OnlineVideoPlayerManager(item: trailer, mediaSource: selectedMediaSource) + ) + } else { + logger.log(level: .error, "No media sources found") + error = JellyfinAPIError(L10n.unknownError) + } + } + + // MARK: - Play: External Trailer + + private func playExternalTrailer(_ trailer: MediaURL) { + if let url = URL(string: trailer.url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) { success in + guard !success else { return } + + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } else { + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift new file mode 100644 index 00000000..75438ba7 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct VersionMenu: View { + + @ObservedObject + var viewModel: ItemViewModel + + let mediaSources: [MediaSourceInfo] + + // MARK: - Selected Media Source Binding + + private var selectedMediaSource: Binding { + Binding( + get: { viewModel.selectedMediaSource }, + set: { newSource in + if let newSource = newSource { + viewModel.send(.selectMediaSource(newSource)) + } + } + ) + } + + // MARK: - Body + + var body: some View { + ActionButton(L10n.version, icon: "list.dash") { + Picker(L10n.version, selection: selectedMediaSource) { + ForEach(mediaSources, id: \.hashValue) { mediaSource in + Button { + Text(mediaSource.displayTitle) + } + .tag(mediaSource as MediaSourceInfo?) + } + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift index 713871c3..82165237 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -22,6 +22,8 @@ extension CustomizeViewsSettings { @StoredValue(.User.itemViewAttributes) private var itemViewAttributes + @StoredValue(.User.enabledTrailers) + private var enabledTrailers @StoredValue(.User.enableItemEditing) private var enableItemEditing @@ -38,6 +40,11 @@ extension CustomizeViewsSettings { router.route(to: \.itemViewAttributes, $itemViewAttributes) } + CaseIterablePicker( + L10n.enabledTrailers, + selection: $enabledTrailers + ) + /// Enable Editing Items from All Visible LIbraries if userSession?.user.permissions.items.canEditMetadata ?? false { Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index f8fb0672..662fd507 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -760,6 +760,9 @@ /// Enabled "enabled" = "Enabled"; +/// Enabled trailers +"enabledTrailers" = "Enabled trailers"; + /// End Date "endDate" = "End Date"; @@ -835,6 +838,9 @@ /// Experimental "experimental" = "Experimental"; +/// External +"external" = "External"; + /// Failed logins "failedLogins" = "Failed logins"; @@ -1057,6 +1063,9 @@ /// Loading user failed "loadingUserFailed" = "Loading user failed"; +/// Local +"local" = "Local"; + /// Local Servers "localServers" = "Local Servers"; @@ -1918,6 +1927,9 @@ /// Title "title" = "Title"; +/// Trailer +"trailer" = "Trailer"; + /// Trailers "trailers" = "Trailers"; @@ -1957,6 +1969,9 @@ /// Unable to find host "unableToFindHost" = "Unable to find host"; +/// Unable to open trailer +"unableToOpenTrailer" = "Unable to open trailer"; + /// Unable to perform device authentication "unableToPerformDeviceAuth" = "Unable to perform device authentication";