[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:
Joe Kribs 2025-04-04 23:13:20 -06:00 committed by GitHub
parent 4a63b52b17
commit 216375905c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 992 additions and 341 deletions

View File

@ -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
}
}
}

View File

@ -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.

View File

@ -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",

View File

@ -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 }

View File

@ -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
}
}

View File

@ -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,

View File

@ -33,7 +33,7 @@ extension ItemView {
var body: some View {
Menu {
Group {
Section(L10n.metadata) {
Button(L10n.findMissing, systemImage: "magnifyingglass") {
viewModel.send(
.refreshMetadata(

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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?)
}
}
}
}
}
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 */,

View File

@ -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
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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?)
}
}
}
}
}
}

View File

@ -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)

View File

@ -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";