[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 <ethanpippin2343@gmail.com>
This commit is contained in:
parent
4a63b52b17
commit
216375905c
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -173,6 +173,14 @@ extension StoredValues.Keys {
|
|||
)
|
||||
}
|
||||
|
||||
static var enabledTrailers: Key<TrailerSelection> {
|
||||
CurrentUserKey(
|
||||
"enabledTrailers",
|
||||
domain: "enabledTrailers",
|
||||
default: .all
|
||||
)
|
||||
}
|
||||
|
||||
static var itemViewAttributes: Key<[ItemViewAttribute]> {
|
||||
CurrentUserKey(
|
||||
"itemViewAttributes",
|
||||
|
|
|
@ -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<BackgroundState> = []
|
||||
|
@ -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 }
|
||||
|
|
|
@ -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<Content: View>: 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
|
||||
}
|
||||
}
|
|
@ -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,
|
|
@ -33,7 +33,7 @@ extension ItemView {
|
|||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Group {
|
||||
Section(L10n.metadata) {
|
||||
Button(L10n.findMissing, systemImage: "magnifyingglass") {
|
||||
viewModel.send(
|
||||
.refreshMetadata(
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Content: View>: 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<MediaSourceInfo?> {
|
||||
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?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = "<group>"; };
|
||||
4E5508722D13AFE3002A5345 /* UserProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImage.swift; sourceTree = "<group>"; };
|
||||
4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = "<group>"; };
|
||||
4E5D3EC72D8920AF003E2772 /* TrailerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailerMenu.swift; sourceTree = "<group>"; };
|
||||
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
|
||||
4E5EE5502D67CE9000982290 /* ImageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCard.swift; sourceTree = "<group>"; };
|
||||
4E5EE5522D67CFAB00982290 /* ImageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCard.swift; sourceTree = "<group>"; };
|
||||
|
@ -1420,6 +1426,10 @@
|
|||
4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = "<group>"; };
|
||||
4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = "<group>"; };
|
||||
4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = "<group>"; };
|
||||
4EB3F02A2D8C803F00EBEDAA /* TrailerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailerSelection.swift; sourceTree = "<group>"; };
|
||||
4EB3F0362D8CD33100EBEDAA /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = "<group>"; };
|
||||
4EB3F0382D8CD5CC00EBEDAA /* TrailerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailerMenu.swift; sourceTree = "<group>"; };
|
||||
4EB3F03A2D8CD6A700EBEDAA /* VersionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionMenu.swift; sourceTree = "<group>"; };
|
||||
4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = "<group>"; };
|
||||
4EB538B42CE3C76D00EB72D5 /* ServerUserPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserPermissionsView.swift; sourceTree = "<group>"; };
|
||||
4EB538BC2CE3CCCF00EB72D5 /* MediaPlaybackSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackSection.swift; sourceTree = "<group>"; };
|
||||
|
@ -1475,7 +1485,6 @@
|
|||
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
|
||||
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
|
||||
4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = "<group>"; };
|
||||
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = "<group>"; };
|
||||
4EFAC12B2D1E255600E40880 /* EditServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerUserAccessTagsView.swift; sourceTree = "<group>"; };
|
||||
4EFAC12E2D1E2EB900E40880 /* EditAccessTagRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessTagRow.swift; sourceTree = "<group>"; };
|
||||
4EFAC1322D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserAccessTagsView.swift; sourceTree = "<group>"; };
|
||||
|
@ -2497,16 +2506,13 @@
|
|||
path = AddItemElementView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */ = {
|
||||
|
@ -2525,6 +2531,15 @@
|
|||
path = ServerUserLiveTVAccessView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E5D3ECB2D893F0B003E2772 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */,
|
||||
4E5D3EC72D8920AF003E2772 /* TrailerMenu.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2851,6 +2866,40 @@
|
|||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EB3F0302D8CCD3500EBEDAA /* ActionButtonHStack */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E18E01D9288747230022598C /* ActionButtonHStack.swift */,
|
||||
4EB3F0312D8CD1EF00EBEDAA /* Components */,
|
||||
);
|
||||
path = ActionButtonHStack;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EB3F0312D8CD1EF00EBEDAA /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EB3F0382D8CD5CC00EBEDAA /* TrailerMenu.swift */,
|
||||
4EB3F03A2D8CD6A700EBEDAA /* VersionMenu.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EB3F0342D8CD2B500EBEDAA /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EB3F0352D8CD32900EBEDAA /* ActionButton */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EB3F0362D8CD33100EBEDAA /* ActionButton.swift */,
|
||||
);
|
||||
path = ActionButton;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EB538B32CE3C75900EB72D5 /* ServerUserPermissionsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3116,6 +3165,15 @@
|
|||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EFACFC22D8BCA4C00D09281 /* PlayButton */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E1C926022887565C002A7A66 /* PlayButton.swift */,
|
||||
4EB3F0342D8CD2B500EBEDAA /* Components */,
|
||||
);
|
||||
path = PlayButton;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -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<Content: View>: 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<MediaSourceInfo?> {
|
||||
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?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
Loading…
Reference in New Issue